Groups and Sequences

Run different rules on create and update with groups, stop after the first failing step with sequences, and make rules conditional.

Syntax$validator->validate($object, null, ['Default', 'create']); // or: new GroupSequence(['AccountInput', 'Remote'])

One class often needs different rules in different situations. A new account must supply an email address; an edit of that account must not change it. A group is a name you attach to constraints so you can run a subset of them. Every constraint accepts a groups option, and validate() takes the groups to run as its third argument.

Default and the class-name group

Two groups exist without you declaring them. Default holds every constraint with no explicit group. The class short name, for example AccountInput, names the same set when you validate that object directly. Once you pass group names, Default is no longer implied: ['create'] runs only the create constraints, while ['Default', 'create'] runs both.

Sequences stop early

A group sequence is an ordered list of groups where each step runs only if the previous step produced no violations. That keeps an expensive check, such as one that calls another system, out of the common case where a cheap check has already failed. #[Assert\GroupSequence(['PaymentInput', 'Remote'])] on the class and new GroupSequence(['PaymentInput', 'Remote']) passed as the third argument of validate() are the same thing; the attribute version replaces the class's Default group so it applies everywhere that object is validated. For a single property, #[Assert\Sequentially([...])] does the same job without naming groups: it stops at the first failing constraint in the list.

Conditional rules

#[Assert\When] applies constraints only when a condition holds, with an optional otherwise list for when it does not. The condition is an expression string, which needs the symfony/expression-language package, or a closure, which needs PHP 8.5.

Example

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

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class AccountInput
{
    // No group given, so these belong to Default and run every time.
    #[Assert\NotBlank]
    #[Assert\Length(min: 2, max: 60)]
    public string $displayName = '';

    // Checked only when creating: the address cannot be changed later.
    #[Assert\NotBlank(groups: ['create'])]
    #[Assert\Email(groups: ['create'])]
    public string $email = '';

    // Sequentially stops at the first failure for this property, so the
    // Regex never reports on a value that is already too short. The groups
    // option is copied to every constraint in the list.
    #[Assert\Sequentially([
        new Assert\NotBlank(),
        new Assert\Length(min: 12),
        new Assert\Regex('/[^a-zA-Z]/', message: 'Add a digit or a symbol.'),
    ], groups: ['create'])]
    public string $password = '';
}

When to use it

  • Require an email address when an account is created but leave it untouched on edit.
  • Skip validation for the "back" button of a multi-step form while still validating the "next" button.
  • Hold an expensive remote check in a second group so it only runs on input that already looks right.
  • Cap a discount at 100 when it is a percentage and at a currency amount when it is not.

More examples

Choosing the groups at the call site

The object does not decide which rules apply; the caller does. In a form the same choice is made with the `validation_groups` option, set in `configureOptions()` or when creating the form, and a submit button configured with `'validation_groups' => false` skips the constraints entirely. Reach for this when one DTO or entity serves both a create and an update endpoint.

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

declare(strict_types=1);

namespace App\Service;

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

final class AccountRules
{
    public function __construct(private readonly ValidatorInterface $validator)
    {
    }

    public function countFailures(AccountInput $input, bool $isNew): int
    {
        // Naming groups drops Default, so list it when you still want the
        // ungrouped constraints to run.
        $groups = $isNew ? ['Default', 'create'] : ['Default', 'update'];

        return count($this->validator->validate($input, null, $groups));
    }
}

// For displayName 'A', email '' and password 'short':
// Output:
// $isNew = true  -> 3   (displayName too short, email blank, password too short)
// $isNew = false -> 1   (displayName too short)

A sequence that keeps a slow check for last

The first step is the cheap, local shape check; the second holds work you would rather not do on obviously broken input. Note the other consequence of attaching a sequence to the class: calling validate() with just the `Remote` group runs that group alone, not the sequence, because the sequence is reached through the Default group.

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

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;

// Shorthand: attached to the class, this sequence replaces the Default group.
// Longhand, the same two steps at the call site:
//   $validator->validate($input, null, new GroupSequence(['PaymentInput', 'Remote']));
#[Assert\GroupSequence(['PaymentInput', 'Remote'])]
final class PaymentInput
{
    #[Assert\NotBlank]
    #[Assert\Length(exactly: 16)]
    public string $cardNumber = '';

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

    // Reached only if every constraint in the PaymentInput group passed.
    #[Assert\Callback(groups: ['Remote'])]
    public function checkToken(ExecutionContextInterface $context, mixed $payload): void
    {
        if (!str_starts_with($this->token, 'tok_')) {
            $context->buildViolation('This token is not recognised.')
                ->atPath('token')
                ->addViolation();
        }
    }
}

Rules that depend on another field

When keeps a conditional rule on the property it concerns instead of pushing it into a controller. Use it for the common shape where one field changes the meaning of another: a percentage capped at 100, a postcode required only for some countries, an end date required only for fixed-term contracts.

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

declare(strict_types=1);

namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class DiscountInput
{
    #[Assert\Choice(choices: ['percent', 'absolute'])]
    public string $type = 'percent';

    // 'this' is the object being validated, 'value' the property's value.
    // Expression strings need symfony/expression-language installed.
    #[Assert\When(
        expression: 'this.type == "percent"',
        constraints: [new Assert\Range(min: 1, max: 100)],
        otherwise: [new Assert\Positive()],
    )]
    public int $value = 0;

    // The same condition as a closure, which requires PHP 8.5. It has to be a
    // static function: PHP allows closures in constant expressions, but not
    // arrow functions, because those capture implicitly. The first argument is
    // the object being validated.
    #[Assert\When(
        expression: static function (DiscountInput $input): bool {
            return 'absolute' === $input->type;
        },
        constraints: [new Assert\LessThanOrEqual(5000)],
    )]
    public int $cap = 0;
}

Discussion

  • Be the first to comment on this lesson.