Constraints

Declare rules as attributes on a class, then check an object with the validator service and read the violation list it returns.

Syntax$validator->validate(mixed $value, Constraint|array|null $constraints = null, string|GroupSequence|array|null $groups = null): ConstraintViolationListInterface

A constraint is one rule about one value: this field must not be empty, that one must look like an email address, this number must fall between 18 and 120. You declare constraints on a class, then hand an object of that class to a service that checks them. The rules live next to the data, so the same object is judged the same way whether it arrived from a form, a JSON body or a console import.

How it works

Constraints are PHP attributes from the Symfony\Component\Validator\Constraints namespace, imported by convention as Assert. Symfony reads them by reflection and stores them as metadata for the class. The validator is the service that walks that metadata; you get it by type-hinting ValidatorInterface in a constructor. Its validate() method returns a violation list: a countable, iterable object with one violation per broken rule. Each violation carries a message and a property path such as email. An empty list means the object is valid. Nothing is thrown, and nothing is changed on the object.

The same rules, written out

Attributes are one of several mappings, not a separate feature. #[Assert\Length(min: 2)] on a property produces exactly the metadata that a static loadValidatorMetadata() method produces with $metadata->addPropertyConstraint('name', new Assert\Length(min: 2)). Same rule, same violations; the attribute simply keeps it visible on the property.

Worth memorising

NotBlank, NotNull, Length, Email, Choice, Range, Type, Count, Regex, the comparisons such as GreaterThan (which takes propertyPath to compare two properties of the same object), and Valid, which tells the validator to descend into a nested object. Without Valid, constraints inside that nested object are never checked.

Example

Example · php
<?php
// src/Dto/SignupInput.php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class SignupInput
{
    #[Assert\NotBlank]
    #[Assert\Length(min: 2, max: 60)]
    public string $name = '';

    #[Assert\NotBlank]
    #[Assert\Email]
    public string $email = '';

    #[Assert\NotNull]
    #[Assert\Range(min: 18, max: 120)]
    public ?int $age = null;

    #[Assert\Choice(choices: ['monthly', 'yearly'])]
    public string $plan = 'monthly';

    public string $password = '';

    // A constraint on a getter checks the value the method returns.
    // The violation is reported on the property path "passwordSafe".
    #[Assert\IsTrue(message: 'The password must not contain your name.')]
    public function isPasswordSafe(): bool
    {
        return '' === $this->name
            || !str_contains(strtolower($this->password), strtolower($this->name));
    }
}

When to use it

  • Reject a signup payload with a blank name or a malformed email address before anything is stored.
  • Keep the rules for an order in one place so a web form and a nightly import apply the same checks.
  • Require an end date later than the start date by comparing two properties of the same object.
  • Check a nested address object as part of validating the customer that owns it.

More examples

Running the validator and reading the violations

The violation list is iterable and countable, so `count($violations) > 0` is the usual failure test. Note that the empty name breaks two rules at once: NotBlank reports it as blank and Length reports it as too short, because LengthValidator returns early only on null, not on the empty string. Constraints such as Email and Regex do skip the empty string, which is why `email` here reports only one message. Reach for this shape whenever you validate outside a form, such as in a service or a console command.

Example · php
<?php
// src/Service/SignupMessages.php

declare(strict_types=1);

namespace App\Service;

use App\Dto\SignupInput;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class SignupMessages
{
    // The validator is injected and type-hinted on its interface.
    public function __construct(private readonly ValidatorInterface $validator)
    {
    }

    /**
     * @return list<string>
     */
    public function check(SignupInput $input): array
    {
        $violations = $this->validator->validate($input);

        $messages = [];

        foreach ($violations as $violation) {
            $messages[] = $violation->getPropertyPath().': '.$violation->getMessage();
        }

        return $messages;
    }
}

// For an input with name '', email 'nope' and age 12:
// Output:
// [
//     'name: This value should not be blank.',
//     'name: This value is too short. It should have 2 characters or more.',
//     'email: This value is not a valid email address.',
//     'age: This value should be between 18 and 120.',
// ]

The same rules without attributes

Both forms build the same metadata and produce the same violations, so you can mix them in one project and you will meet both in other people's code. The method form is useful when the rules are computed, for example when a constraint's options come from a parameter; attributes are easier to read and are the default in new projects.

Example · php
<?php
// src/Dto/SignupInput.php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\ClassMetadata;

final class SignupInput
{
    public string $name = '';
    public string $email = '';

    // Called once per class by the validator when it builds metadata.
    // This is the longhand of the attributes in the main example:
    //   #[Assert\NotBlank]
    //   #[Assert\Length(min: 2, max: 60)]
    //   public string $name = '';
    public static function loadValidatorMetadata(ClassMetadata $metadata): void
    {
        $metadata->addPropertyConstraint('name', new Assert\NotBlank());
        $metadata->addPropertyConstraint('name', new Assert\Length(min: 2, max: 60));
        $metadata->addPropertyConstraint('email', new Assert\NotBlank());
        $metadata->addPropertyConstraint('email', new Assert\Email());
    }
}

Descending into nested objects and collections

Valid makes the validator cascade. Violations from the nested objects come back in the same flat list, with property paths such as `shipping.postcode` and `lines[0].quantity`, which is what lets a form or a JSON error response put each message next to the right input. Reach for it whenever a request maps to more than one object.

Example · php
<?php
// src/Dto/OrderInput.php

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class OrderInput
{
    #[Assert\NotBlank]
    public string $reference = '';

    // Without Valid, the constraints declared inside AddressInput
    // are never checked when an OrderInput is validated.
    #[Assert\Valid]
    public AddressInput $shipping;

    /**
     * @var list<OrderLineInput>
     */
    #[Assert\Count(min: 1, minMessage: 'Add at least one line.')]
    #[Assert\Valid]
    public array $lines = [];

    public function __construct()
    {
        $this->shipping = new AddressInput();
    }
}

Discussion

  • Be the first to comment on this lesson.