Custom Constraints

Write a constraint class and its validator, inject services into it, and put a rule that spans two fields on the class itself.

Syntax$this->context->buildViolation($constraint->message)->setParameter('{{ slug }}', $value)->atPath('slug')->addViolation();

Built-in constraints cover shapes: lengths, formats, ranges. A rule that belongs to your application needs two classes. The constraint is a declaration: it holds the options and the message and carries no logic. The constraint validator holds the logic and is registered as a service. The split exists because the declaration is cached metadata while the logic may need a database connection or an HTTP client.

How the pair finds each other

The constraint extends Constraint and is marked #[\Attribute] so it can be used as one. Symfony looks up its validator through Constraint::validatedBy(), which by default returns the constraint's class name with Validator appended. The validator extends ConstraintValidator and implements validate(mixed $value, Constraint $constraint): void. It returns nothing. A failure is reported with $this->context->buildViolation($constraint->message)->addViolation(), optionally with setParameter() to fill a placeholder such as {{ slug }}. With the default service configuration your validator is autowired and tagged validator.constraint_validator, so you declare dependencies in the constructor and type-hint interfaces.

A rule across two fields

Return self::CLASS_CONSTRAINT from getTargets() and the validator receives the whole object rather than one property; use atPath('endsAt') so the violation still lands on a field. Before writing classes, check the short forms: #[Assert\GreaterThan(propertyPath: 'startsAt')] and #[Assert\Expression('this.endsAt > this.startsAt')] express that same rule with no new code. The hand-written constraint earns its place when it needs a service, several messages, or options.

Two habits

Return early on null and the empty string, leaving presence to NotBlank. Throw UnexpectedTypeException when the constraint is not yours and UnexpectedValueException when the value is a type you cannot handle.

Example

Example · php
<?php
// src/Validator/UniqueSlug.php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Attribute\HasNamedArguments;
use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class UniqueSlug extends Constraint
{
    // {{ slug }} is filled in by the validator with setParameter().
    public string $message = 'The slug "{{ slug }}" is already taken.';

    // HasNamedArguments makes $entityClass a required, named option:
    //   #[UniqueSlug(entityClass: Article::class)]
    #[HasNamedArguments]
    public function __construct(
        public string $entityClass,
        ?string $message = null,
        ?array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct(null, $groups, $payload);

        $this->message = $message ?? $this->message;
    }
}

// Symfony will look for App\Validator\UniqueSlugValidator, because the
// inherited validatedBy() returns static::class.'Validator'.

When to use it

  • Reject a slug that another article already uses, with the message naming the slug.
  • Refuse an end date that is not later than the start date, reporting the error on the end date field.
  • Check a VAT number against an internal service by injecting that service's client into the validator.
  • Require exactly one of several mutually exclusive fields to be filled in.

More examples

The validator, with a dependency injected

This is the shape of every custom validator: guard the constraint type, ignore empty values, do the work, add a violation. Because the dependency is injected rather than pulled from the container, the class can be unit tested with a double. A lookup like this one is a check, not a guarantee: a unique index in the schema is what makes the rule hold when two requests arrive at once.

Example · php
<?php
// src/Validator/UniqueSlugValidator.php

declare(strict_types=1);

namespace App\Validator;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

final class UniqueSlugValidator extends ConstraintValidator
{
    // Validators are services, so dependencies arrive through the constructor.
    public function __construct(private readonly EntityManagerInterface $entityManager)
    {
    }

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueSlug) {
            throw new UnexpectedTypeException($constraint, UniqueSlug::class);
        }

        // Leave presence to NotBlank; stay quiet on nothing.
        if (null === $value || '' === $value) {
            return;
        }

        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        $taken = $this->entityManager
            ->getRepository($constraint->entityClass)
            ->count(['slug' => $value]);

        if ($taken > 0) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ slug }}', $value)
                ->addViolation();
        }
    }
}

A constraint that targets the whole object

A property-level constraint can only see one value, so any rule that compares two fields has to be declared on the class. Note that this constraint needs no constructor: it has no required options, so the inherited one is enough. Targeting the class is also how you express rules such as "exactly one of these three fields must be filled in".

Example · php
<?php
// src/Validator/ValidDateRange.php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_CLASS)]
final class ValidDateRange extends Constraint
{
    public string $message = 'The end date must be later than the start date.';

    // Tells the validator to pass the object, not a single property.
    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}

// Used on the class it judges:
//
//   #[ValidDateRange]
//   final class EventInput
//   {
//       public ?\DateTimeImmutable $startsAt = null;
//       public ?\DateTimeImmutable $endsAt = null;
//   }

Its validator, reporting on a named field

`atPath()` is what makes a class-level rule usable in a form or an API response: the message comes back with the property path `endsAt`, so it renders next to that input. Reach for a class constraint plus atPath whenever the rule is about a combination of fields but the user needs to be told which one to change.

Example · php
<?php
// src/Validator/ValidDateRangeValidator.php

declare(strict_types=1);

namespace App\Validator;

use App\Dto\EventInput;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

final class ValidDateRangeValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof ValidDateRange) {
            throw new UnexpectedTypeException($constraint, ValidDateRange::class);
        }

        if (!$value instanceof EventInput) {
            throw new UnexpectedValueException($value, EventInput::class);
        }

        // NotNull on each property reports a missing date; not this class.
        if (null === $value->startsAt || null === $value->endsAt) {
            return;
        }

        if ($value->endsAt > $value->startsAt) {
            return;
        }

        $this->context->buildViolation($constraint->message)
            // Without atPath the violation has an empty property path and a
            // form can only show it as a global error.
            ->atPath('endsAt')
            ->addViolation();
    }
}

Discussion

  • Be the first to comment on this lesson.