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.
Table of Contents
- 1. Why custom constraints instead of validation logic in the controller
- 2. Structure of a constraint class in Symfony
- 3. Implementing the ConstraintValidator
- 4. Injecting services into ConstraintValidators
- 5. Class-level constraints for cross-field validation
- 6. Validation groups: enabling rules depending on context
- 7. Using constraints as PHP attributes
- 8. Unit-testing custom constraints
- 9. Comparison: built-in vs. custom constraints
- 10. Summary
- 11. FAQ
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.