4min.

How to dynamically validate some data with Symfony Validator

From time to time, you have to validate data according to another value, or group of values.

We can do that quickly with plain PHP in a callback, or in a dedicated constraints like following:

class MyDto
{
    public bool $sendEmail;

    public string $email;

    #[Assert\Callback()]
    public function validate(ExecutionContextInterface $context): void
    {
        if ($this->sendEmail) {
            if (!isEmailValid($this->email)) {
                $context
                    ->buildViolation('The email is not valid')
                    ->atPath('email')
                    ->addViolation()
                ;
            }
        }
    }
}

But Ho! Where does the isEmailValid function came from? It would be better to be able to reuse all Symfony Constraints available, isn’t it?

This is what you’ll learn by reading this article.

Section intitulée use-caseUse case

Let’s take a more complex example to learn many new things!

Consider this class:

class Trigger
{
    private const TYPES = [
        'url',
        'requestHeaders',
        'requestMethods',
        'responseStatusCodes',
    ];

    #[Assert\NotBlank()]
    #[Assert\Choice(choices: self::TYPES)]
    public string $type;

    public array $options = [];
}

And I would like to validate the following payloads:

{
    "type": "url",
    "options": {
        "url": "http://google.fr"
    }
},

or that:

{
    "type": "responseStatusCodes",
    "options": {
        "statusCodes": [
            200
        ]
    }
}

So, how can we leverage Symfony to validate the options data according to type dynamically?

There are few options, let’s discover them.

Section intitulée with-code-callback-code-not-reusableWith Callback (not reusable)

class Trigger
{
    // ...

    #[Assert\Callback()]
    public function validate(ExecutionContextInterface $context): void
    {
        // Avoid PHP errors and also reporting the same errors many times.
        if (!isset($this->type) || !\in_array($this->type, self::TYPES, true)) {
            return;
        }

        // Get a groups a constraints for each `type` value
        $constraints = match ($this->type) {
            'url' => $this->getUrlConstraints(),
            'requestHeaders' => $this->getRequestHeadersConstraints(),
            'requestMethods' => $this->getRequestMethodsConstraints(),
            'responseStatusCodes' => $this->getResponseStatusCodes(),
            default => throw new \UnexpectedValueException(),
        };

        // All the magic occurs here!
        // We create a new validator, but in the very same context
        // And we suffix the `options` path
        $context
            ->getValidator()
            ->inContext($context)
            ->atPath('options')
            ->validate($this->options, $constraints)
        ;
    }

    private function getUrlConstraints(): array
    {
        return [
            // Since options should be an array, we use the `Collection` constraints
            // By default, all fields are required, so NotBlank are not required here
            new Assert\Collection([
                'url' => [
                    new Assert\Url(),
                ],
            ]),
        ];
    }

    private function getRequestHeadersConstraints(): array
    {
        return [
            // Since options should be an array, we use the `Collection` constraints
            // In this case, all fields are not required, so we must add NotBlank where needed
            new Assert\Collection([
                'fields' => [
                    'name' => [
                        new Assert\NotBlank(),
                        new Assert\Type('string'),
                    ],
                    'value' => [
                        new Assert\Type('string'),
                    ],
                    'operator' => [
                        new Assert\Choice(choices: Header::TYPE_CHOICES),
                    ],
                ],
                'allowMissingFields' => true,
            ]),
            // We can go deeper: we can even apply again the same principle to validate some part
            // of the $option according to another sub options value
            new Assert\Callback(function (array $options, ExecutionContextInterface $context) {
                // Avoid reporting many times the same errors.
                if (!\array_key_exists('operator', $options)) {
                    return;
                }
                if (\in_array($options['operator'], Header::TYPE_NEED_VALUE, true)) {
                    $constraints = [new Assert\NotBlank()];
                } else {
                    $constraints = [new Assert\Blank()];
                }
                // Let's run again the validator on some part of the options
                $context
                    ->getValidator()
                    ->inContext($context)
                    ->atPath('value')
                    ->validate($options['value'] ?? null, $constraints)
                    ->getViolations()
                ;
            }),
        ];
    }

    // ...
}

Section intitulée with-dedicated-code-constraint-code-reusableWith dedicated Constraint (reusable)

In this case, we want to be able to reuse the constraint, so we create a couple of Constraint and ConstraintValidator.

Section intitulée the-code-constraint-codeThe Constraint

#[\Attribute(\Attribute::TARGET_CLASS)]
class TriggerType extends Constraint
{
    public function getTargets()
    {
        return self::CLASS_CONSTRAINT;
    }
}

Section intitulée the-code-constraintvalidator-codeThe ConstraintValidator

class TriggerTypeValidator extends ConstraintValidator
{
    public function __construct(
        // This service locator contains many Constraints Builder
        // The key is the Trigger `type`, and the value is the Builder
        private ContainerInterface $definitions,
    ) {
    }

    public function validate($trigger, Constraint $constraint)
    {
        $type = $trigger->type ?? null;

        // Avoid reporting many times the same errors.
        if (!$type) {
            return;
        }

        if (!$this->definitions->has($type)) {
            $this
                ->context
                ->buildViolation('The type is not valid.')
                ->atPath('type')
                ->setParameter('{{ type }}', $type)
                ->addViolation()
            ;

            return;
        }

        /** @var TriggerDefinitionInterface */
        $definition = $this->definitions->get($type);

        // All the magic occurs here!
        // It's exactly the same mechanism as with the Callback Constraint
        $this->context
            ->getValidator()
            ->inContext($this->context)
            ->atPath('options')
            ->validate($trigger->options, $definition->getConstraints())
        ;
    }
}

Have you noticed the ContainerInterface $definitions?

In order to have a system that is really generic, and extensible, we have created a new interface TriggerDefinitionInterface.

Then, according to the trigger type, we match the definition, and we validate the data according to it.

Section intitulée the-code-triggerdefinitioninterface-code-interfaceThe TriggerDefinitionInterface Interface

Implementation has to build the constraints (getConstraints) associated with a trigger type (getType).

interface TriggerDefinitionInterface
{
    public static function getType(): string;

    public function getConstraints(): array;
}

An example:

class SliceTriggerTypeDefinition implements TriggerDefinitionInterface
{
    public static function getType(): string
    {
        return 'slice';
    }

    public function getConstraints(): array
    {
        return [
            new Assert\Collection([
                'from' => [
                    new Assert\Type('int'),
                ],
                'to' => [
                    new Assert\Type('int'),
                ],
            ]),
        ];
    }
}

Section intitulée dependency-injection-containerDependency Injection Container

And now, let’s leverage Symfony DIC features to register all TriggerDefinition and inject them in our validator:

services:
    _defaults:
        autowire: true
        autoconfigure: true

    _instanceof:
        App\Trigger\TriggerDefinitionInterface:
            tags:
                - { name: trigger.type.definition }

    App\Validator\Constraints\TriggerTypeValidator:
        arguments:
            $definitions: !tagged_locator { tag: trigger.type.definition, default_index_method: 'getType'}

We use two main features:

  • _instanceof to automatically add a tag on service that implements TriggerDefinitionInterface
  • !tagged_locator to create a Service Locator with all our services. And in order to not break the service’s lazy loading, we use default_index_method. This method will be called to assign the service name in the service locator.

Section intitulée conclusionConclusion

That’s all, with not so much code, we have build a very powerful system to validate some data according to some data dynamically!

This system behaves really well when the data shape is really generic. For example in a CMS (block system), in e-commerce (product details), etc.

But, you should not abuse too much of it. Usually, it would be better to have dedicated DTO for each situation.

Commentaires et discussions

Nos formations sur ce sujet

Notre expertise est aussi disponible sous forme de formations professionnelles !

Voir toutes nos formations

Ces clients ont profité de notre expertise