Symfony Validator: Writing Custom Constraint Classes
AI generated
SF
{ }
Symfony · Validator · PHP · Constraints
Symfony Validator: Writing Custom Constraint Classes
for Complex Validation Rules

The built-in Symfony Validator constraints cover standard cases, but business logic like unique slugs, domain-specific formats, or dependencies between fields calls for custom constraint classes. Implementing them correctly keeps validation logic testable, reusable, and cleanly separated from controllers.

16 min read Constraint · ConstraintValidator · Class-Level · Service Injection Symfony 7.x · PHP 8.3+

1. Why custom constraints instead of validation logic in the controller

The most common mistake in Symfony Validator integration is moving validation logic into controllers or services instead of extracting it into standalone constraint classes. A controller that checks whether a slug is unique, whether an IBAN matches a bank account, or whether a start date precedes an end date implicitly contains business rules that should apply consistently across the codebase. That logic cannot be reused without duplication, and it is harder to test than an isolated validator class.

The Symfony Validator offers exactly the right abstraction for this with its constraint system. A custom constraint class encapsulates the error messages and options, while the associated ConstraintValidator holds the check logic. Together, they can be attached to any property or class via PHP attributes, without the controller containing a single line of validation code. Symfony discovers custom constraints automatically through autowiring, provided the validator follows the naming convention or implements ConstraintValidatorInterface.

This separation has another benefit: error collections stay consistent. The Symfony Validator aggregates all violations across a single validation run, whether the error originates from a built-in constraint like NotBlank or from a custom constraint. The result is a ConstraintViolationList object that can be converted uniformly into form errors, API responses, or log entries.

2. Structure of a constraint class in Symfony

A custom constraint class in Symfony extends Symfony\Component\Validator\Constraint. The class defines two things: the error message as a public constant or property, and optional configuration parameters that can be supplied when the constraint is applied. Since PHP 8.0 the same class can be used as a PHP attribute, which requires it to carry the #[Attribute] attribute. In Symfony 7 this is the preferred approach because it requires no separate YAML or XML configuration.

The validatedBy() method returns the service name of the associated validator. Symfony derives it automatically by convention: for App\Validator\UniqueSlug the container looks for a service named App\Validator\UniqueSlugValidator. Anyone who wants a custom service name overrides validatedBy() and returns the fully qualified class name. The getTargets() method specifies whether the constraint may be applied to properties (PROPERTY_CONSTRAINT), classes (CLASS_CONSTRAINT), or both. An incorrect target results in a clear exception during the validation run rather than silent failure.

Constraint options are defined as constructor-less public properties. Since PHP 8.1, with constructor property promotion, they are declared directly in the constructor, given default values, and are thus immediately settable via attribute parameters: #[UniqueSlug(field: 'slug', message: 'This slug is already taken.')]. The base class Constraint handles mapping the named arguments to the constructor properties.


<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

/**
 * Constraint that validates the uniqueness of a slug in the database.
 * Usage: #[UniqueSlug] on a string property or via YAML/XML config.
 */
#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class UniqueSlug extends Constraint
{
    /**
     * Error message shown when the slug is already taken.
     * The {{ value }} placeholder is replaced with the submitted value.
     */
    public string $message = 'Der Slug "{{ value }}" ist bereits vergeben.';

    /**
     * Entity class to check against, defaults to null (caller must set it).
     */
    public ?string $entityClass = null;

    /**
     * Field name to check for uniqueness.
     */
    public string $field = 'slug';

    /**
     * Optional: exclude a specific ID from the uniqueness check (for edit forms).
     */
    public ?int $excludeId = null;

    /**
     * @param string|null $message  Custom error message
     * @param string|null $entityClass  FQCN of the entity to query
     */
    public function __construct(
        public readonly array $groups = [],
        public readonly mixed $payload = null,
        ?string $message = null,
        ?string $entityClass = null,
        string $field = 'slug',
        ?int $excludeId = null,
        array $options = [],
    ) {
        parent::__construct($options, $groups, $payload);

        if ($message !== null) {
            $this->message = $message;
        }
        if ($entityClass !== null) {
            $this->entityClass = $entityClass;
        }
        $this->field     = $field;
        $this->excludeId = $excludeId;
    }
}

3. Implementing the ConstraintValidator

The ConstraintValidator holds the actual check logic. It extends Symfony\Component\Validator\ConstraintValidator and implements the method validate(mixed $value, Constraint $constraint). The first task in every validate() method is a type check on the constraint and early returns for null or empty-string values, which the Symfony Validator should handle with separate constraints like NotBlank. This keeps every validator focused on its own area of responsibility.

When validation fails, a violation is created via $this->context->buildViolation($constraint->message). The builder API methods let you set placeholder values for the error message (setParameter('{{ value }}', $value)), pin the violation to a specific path within an object (atPath('email')), and assign a specific error code (setCode(UniqueSlug::NOT_UNIQUE_ERROR)). The final call to addViolation() registers the error in the ConstraintViolationList without aborting validation, all remaining constraints continue to be checked.

Important: the ConstraintValidator must not hold state between two validation calls. It is a service managed as a singleton by the container. All stateful data belongs in local variables of the validate() method or is brought in via constructor injection as stateless services. This rule also applies to injected repositories: the repository itself is stateless, only the EntityManager holds state, and that is managed as a scoped service.


<?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;

/**
 * Validates that a slug value is unique within a given entity class.
 */
final class UniqueSlugValidator extends ConstraintValidator
{
    public function __construct(
        private readonly EntityManagerInterface $entityManager,
    ) {}

    /**
     * Check that the given slug does not already exist in the database.
     *
     * @throws UnexpectedTypeException  if the constraint is not a UniqueSlug instance
     * @throws UnexpectedValueException if the value is not a string
     */
    public function validate(mixed $value, Constraint $constraint): void
    {
        // Type guard, ensures correct constraint class
        if (!$constraint instanceof UniqueSlug) {
            throw new UnexpectedTypeException($constraint, UniqueSlug::class);
        }

        // Skip validation for null and empty values, use NotBlank for those
        if (null === $value || '' === $value) {
            return;
        }

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

        if ($constraint->entityClass === null) {
            throw new \LogicException('UniqueSlug constraint requires entityClass to be set.');
        }

        // Build query dynamically based on entity class and field name
        $qb = $this->entityManager->createQueryBuilder()
            ->select('COUNT(e.id)')
            ->from($constraint->entityClass, 'e')
            ->where('e.' . $constraint->field . ' = :value')
            ->setParameter('value', $value);

        // Exclude current record when editing (prevents false positives)
        if ($constraint->excludeId !== null) {
            $qb->andWhere('e.id != :excludeId')
               ->setParameter('excludeId', $constraint->excludeId);
        }

        $count = (int) $qb->getQuery()->getSingleScalarResult();

        if ($count > 0) {
            // Build violation with placeholder replacement and specific error code
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $value)
                ->setCode('UNIQUE_SLUG_NOT_UNIQUE')
                ->addViolation();
        }
    }
}

4. Injecting services into ConstraintValidators

The Symfony Validator integrates fully with the Symfony service container. A ConstraintValidator is a regular service and supports constructor injection with autowiring. This lets you inject repository classes, external API clients, caches, or other services directly into the validator, without a manual service definition in services.yaml, as long as autowiring is enabled. This is the decisive advantage over validation logic in the controller: the validator receives all its dependencies from the container, not from the controller.

Anyone with several ConstraintValidators sharing the same dependency can introduce an abstract base validator that receives the shared services via constructor property promotion. All concrete validators then extend it. An alternative, and cleaner in modern Symfony, is the delegate pattern: the validator delegates complex database queries to a dedicated ValidationQueryService that encapsulates the SQL logic. This keeps the validator thin and the query service independently testable.

A common mistake with service injection in ConstraintValidators: circular dependencies, when a validator injects an entity-related service class that in turn uses the validator. The solution is a clean separation of layers: the validator only knows about the repository or a query service, never about application services or command handlers that could themselves trigger validation.

5. Class-level constraints for cross-field validation

Property-level constraints validate a single property in isolation. For validation rules that involve multiple fields of an object simultaneously, such as an end date coming after a start date, or at least one of two optional email addresses being provided, you need class-level constraints in the Symfony Validator. The difference lies in the getTargets() method: it returns Constraint::CLASS_CONSTRAINT, and the ConstraintValidator receives the entire object instead of a single field value.

In the validate() method of a class-level validator, the passed $value is the complete object. You access all its fields and can check arbitrarily complex dependencies. Violations are assigned to a specific field with atPath('fieldName') so that form renderers display the error message in the right place. Without atPath(), the error appears at the object level, which is often rendered in the wrong place in forms.

Class-level constraints are declared as an attribute on the class, not on a property. The attribute target must then include \Attribute::TARGET_CLASS. The typical use case in Symfony projects is DTO classes for forms and API requests: the DTO contains all incoming data, and the class-level constraint validates the interplay of the fields before any service processes the data.


<?php

declare(strict_types=1);

namespace App\Validator;

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

/**
 * Class-level constraint: validates that the end date is after the start date.
 * Applied on the DTO class, not on individual properties.
 */
#[\Attribute(\Attribute::TARGET_CLASS)]
final class DateRangeValid extends Constraint
{
    public string $message = 'Das Enddatum muss nach dem Startdatum liegen.';

    // Returns CLASS_CONSTRAINT so Symfony passes the whole object to validate()
    public function getTargets(): string|array
    {
        return self::CLASS_CONSTRAINT;
    }
}

/**
 * Validator for the DateRangeValid constraint.
 * Receives the full DTO object and checks start/end date relationship.
 */
final class DateRangeValidValidator extends ConstraintValidator
{
    /**
     * Validate that endDate > startDate on the submitted DTO.
     */
    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof DateRangeValid) {
            throw new UnexpectedTypeException($constraint, DateRangeValid::class);
        }

        // $value is the whole object (DTO/Entity) when getTargets() = CLASS_CONSTRAINT
        if (!method_exists($value, 'getStartDate') || !method_exists($value, 'getEndDate')) {
            return;
        }

        $startDate = $value->getStartDate();
        $endDate   = $value->getEndDate();

        // Skip if either date is null, use NotNull for that separately
        if ($startDate === null || $endDate === null) {
            return;
        }

        if ($endDate <= $startDate) {
            // atPath() pins the violation to the endDate field in the form
            $this->context->buildViolation($constraint->message)
                ->atPath('endDate')
                ->addViolation();
        }
    }
}

// Usage on a DTO class:
// #[DateRangeValid]
// final class EventCreateDto
// {
//     public ?DateTimeImmutable $startDate = null;
//     public ?DateTimeImmutable $endDate   = null;
// }

6. Validation groups: enabling rules depending on context

The Symfony Validator supports validation groups so the same data type can be validated with different rules in different contexts. When creating a user, a password field is required; when editing, it is optional. With validation groups, the same DTO class is used for both scenarios, and the constraints receive a groups option. The Default group always applies unless explicit groups are passed.

In Symfony forms you set groups via the validation_groups option of the form type or via a GroupSequenceProvider. The latter enables dynamic group selection based on the object's state: if the object is new, creation rules apply; if it is already persisted, update rules apply. Custom constraint classes accept the groups attribute automatically because it is defined on the base class Constraint.

For API Platform integrations in a Symfony project, you control validation groups via operation configuration: the POST operation uses ['Default', 'Create'], the PUT operation ['Default', 'Update']. Your custom constraint classes then carry the matching group specifically, without a validator having to be duplicated. This significantly reduces maintenance effort when validation rules differ between creation and update.

7. Using constraints as PHP attributes

Since PHP 8.0, constraint classes can be used as native PHP attributes. Within the Symfony ecosystem this has been officially supported since Symfony 5.2 and has been the preferred configuration path since Symfony 7. The advantage: validation rules sit directly next to the properties, are IDE-supported (autocomplete, type checks), and are subject to PHP parsing instead of YAML parsing. For custom constraint classes, the class must carry the native PHP attribute #[\Attribute], and the constructor must define the parameters that are passed as attribute arguments.

When using it as an attribute, keep in mind that PHP attributes do not support argument inheritance between classes. The constructor of your custom constraint class must explicitly define every parameter you want. The base class Constraint expects, as its first constructor argument, either an options array or individual named arguments. Symfony 7 recommends the named-arguments variant because it is readable and IDE-friendly.

A practical tip for projects with many custom constraints: keep all constraint classes in a single namespace, App\Validator, and place the validator classes directly next to them (App\Validator\UniqueSlugValidator). This makes the ClassName + Validator convention automatically usable by the Symfony Validator, without manual validatedBy() overrides. PhpStan- and Psalm-level-8-compatible custom constraints are possible if generics are used for the constraint type.

8. Unit-testing custom constraints

Testing custom ConstraintValidator classes is straightforward with the Symfony Validator test-case framework. The abstract class ConstraintValidatorTestCase from the symfony/validator package provides the validation context and includes helper methods for checking violations. You extend it, return the fully qualified class name of the validator, and can call $this->validator->validate($value, new MyConstraint()) directly in your test methods. After validation, you check with $this->assertNoViolation() or $this->buildViolation($message)->assertRaised().

For ConstraintValidators with service dependencies, you use PHPUnit mocks. The repository is passed as a mock that returns defined values for known inputs. This keeps the unit test fully isolated from the database: you only test the validator's decision logic, not the SQL correctness of the repository. The latter is tested in separate repository integration tests that use a real database connection.

Integration tests with the full Symfony Validator service check whether the constraint configuration in the container is correct, that is, whether autowiring kicks in, the service is found, and all dependencies are resolved. For this, you boot the Symfony kernel in the test and pull the validator from the container. These tests are slower but necessary to catch configuration errors that are not visible in the unit test.

9. Comparison: built-in vs. custom constraints

The Symfony Validator ships with over 70 built-in constraints. Before writing a custom constraint class, it is worth checking the documentation: Compound constraints combine several built-in constraints into one, Callback constraints allow inline validation directly in the entity class, and Expression constraints use the Symfony ExpressionLanguage for simple rule expressions. Custom classes are only truly necessary when the check logic needs service dependencies, custom error types, or class-level context.

Requirement Built-in Constraint Custom Constraint Class Recommendation
Database check (uniqueness) UniqueEntity (Doctrine) Custom UniqueSlug with repository Custom for complex queries
Cross-field validation Not directly possible Class-level constraint Always a custom class
Simple rule combination Compound constraint Unnecessarily complex Prefer Compound
External API validation Not available Custom + service injection Custom class with HTTP client
Inline logic without reuse Callback constraint Overkill Callback is enough

The decision to write a custom constraint class is warranted whenever the check logic is reused, external services are needed, or the violation should be reported with a specific error code. Callback constraints have no service access and are harder to test. Expression constraints are good for simple conditional expressions that need no PHP logic.

Mironsoft

Symfony development, validation architecture and PHP backends

Want to structure validation logic cleanly in Symfony?

We build custom Symfony Validator constraints, class-level validations and testable validation architectures for complex PHP backends, from the DTO through the ConstraintValidator to the API integration.

Constraint design

Custom constraint classes and validator services for domain-specific validation rules

Cross-field validation

Class-level constraints for dependencies between fields and context-sensitive groups

Test coverage

Unit and integration tests for all validators with ConstraintValidatorTestCase and mocks

10. Summary

Custom Symfony Validator constraint classes are the right approach as soon as validation logic needs to be reused, service dependencies like repositories or external APIs are required, or several fields need to be checked together. The separation between the constraint class (metadata, error message, options) and the ConstraintValidator (check logic) keeps both parts small, testable, and understandable. Class-level constraints solve cross-field validations elegantly, without duplication.

Integration with the Symfony service container via autowiring turns ConstraintValidators into fully-fledged services that can have any dependency injected. Validation groups allow context-dependent activation of rules without having to write separate DTOs for creation and update. Anyone who consistently uses custom constraints keeps controllers thin, makes validation logic testable independently of the transport layer, and ensures consistent error messages across the entire application.

Symfony Validator: Custom Constraints, the Essentials at a Glance

Constraint class

Extends Constraint, carries #[\Attribute], defines error message and options. getTargets() determines property-level or class-level.

ConstraintValidator

Extends ConstraintValidator, implements validate(). Type check first, early return for null/empty, violation via buildViolation()->addViolation().

Service injection

Validator is a regular Symfony service. Constructor injection with autowiring, repository, HTTP client, or cache injectable directly without manual configuration.

Testing

ConstraintValidatorTestCase for unit tests with mock dependencies. assertNoViolation() and buildViolation()->assertRaised() for clear assertions.

11. FAQ: Symfony Validator and Custom Constraint Classes

1When do I need a custom constraint class?
For reuse, service dependencies (repository, HTTP client), or cross-field checks. For simple inline logic, the Callback constraint is enough.
2Constraint vs. ConstraintValidator?
Constraint = metadata object with error message and options. ConstraintValidator = class with the actual validate() logic. Together they form a complete validator.
3Injecting a repository into a validator?
Via constructor injection. The validator is a regular Symfony service with autowiring. Declare the EntityManager or repository directly as a readonly parameter.
4What are class-level constraints?
Constraints at the class level, validate() receives the entire object. Needed for dependencies between fields. getTargets() returns CLASS_CONSTRAINT.
5Assigning a violation to a field?
With atPath('fieldName') in the builder. Without atPath, the violation appears at the object level, often rendered in the wrong place in forms.
6How do I test a custom validator?
ConstraintValidatorTestCase as a base. assertNoViolation() and buildViolation()->assertRaised(). Pass dependencies as PHPUnit mocks, no real database access.
7Constraints as a PHP attribute?
The constraint class must carry #[\Attribute]. Constructor parameters are passed directly as attribute arguments. In Symfony 7 the preferred configuration path.
8What are validation groups?
Groups enable constraints depending on context. Default always applies. Custom groups like Create or Update are enabled at the validation call site or in the form configuration.
9When Compound constraint instead of a custom class?
Compound combines several built-in constraints without custom validator logic. Good for reuse without service dependencies. If a database check or external logic is needed, use a custom class.
10May a ConstraintValidator hold state?
No. The validator is a singleton service. All stateful data belongs in local variables of the validate() method, never as an instance variable.