Videa Blog

How to use Dynamic Constraints with Symfony/Validator

Jáchym Toušek  

Some edge-cases with Symfony Validator might force you to create a constraint dynamically during the validation. This article will show you how to do it and how to solve error mapping for such constraints.

Example Use Case

You have an Address entity with a country and a zipcode. There is a ZipCodeConstraint constraint available but requires you to specify the country in the options. But you cannot do that in an annotation because the country is present in another field of the Address entity.

use SLLH\IsoCodesValidator\Constraints\ZipCodeConstraint;
use Symfony\Component\Validator\Constraints as Assert;

class Address
{
    // street, city, etc.

    /**
     * @var string
     * @Assert\NotBlank()
     * @Assert\Country()
     */
    protected $country;

    /**
     * @todo Validate this field based on the country specified in $this->country.
     * @var string
     * @Assert\NotBlank()
     * @ZipCodeConstraint(country = "???")
     */
    protected $zipcode;

    // getters and setters
}

Creating the constraint dynamically

Well, it's of course impossible to simply fix the code above by replacing the question marks with something. So we need another approach. The way to go in this case is the Callback constraint.

use SLLH\IsoCodesValidator\Constraints\ZipCodeConstraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class Address
{
    // same as above

    /**
     * @Assert\Callback()
     */
    public function validateZipcode(ExecutionContextInterface $context)
    {
        $constraint = new ZipCodeConstraint(['country' => $this->country]);
        $violations = $context->getValidator()->validate($this->zipcode, $constraint);

        foreach ($violations as $violation) {
            $context->getViolations()->add($violation);
        }
    }
}

Correct violation context

Now this is almost correct except for one flaw. If the zipcode is not valid, the violation is not attached to the zipcode field but to the Address class instead. Fortunately there is a way to solve this problem as well using ContextualValidator.

use SLLH\IsoCodesValidator\Constraints\ZipCodeConstraint;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

class Address
{
    // same as above

    /**
     * @Assert\Callback()
     */
    public function validateZipcode(ExecutionContextInterface $context)
    {
        $constraint = new ZipCodeConstraint(['country' => $this->country]);
        $context
            ->getValidator()
            ->inContext($context)
            ->atPath('zipcode')
            ->validate($this->zipcode, $constraint, [Constraint::DEFAULT_GROUP]);
    }
}

And that's it! The violation will be added to the field directly in the intended context so there is no need to duplicate it.

This approach should solve most of the advanced use-cases you might have. Along with the tips from my previous articles it makes Symfony/Validator a really powerful validation tool.

Usage with Nette Framework

If you want to use the Symfony/Validator component in your Nette application, you will need Kdyby/Validator.