Symfony Validator Component: Custom Constraints for Complex Business Logic

#symfony validator custom constraint
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

Most Symfony projects start with the constraints that ship with the framework. #[Assert\NotBlank], #[Assert\Email] and #[Assert\Length] cover the first six months. Then a requirement arrives that none of them can express: a discount code that must match the customer's plan, a booking that must not overlap an existing one, a field that is mandatory for one tenant and optional for another. At that point you need a Symfony validator custom constraint, and the official documentation gets thin exactly where the real work begins.

This post collects the patterns we use when validation logic touches the database, needs services from the container, or changes depending on who is calling. The running example is a multi-tenant SaaS where every tenant configures its own rules.

Anatomy of a Symfony validator custom constraint

A constraint in Symfony is always a pair. The constraint class is a small data object that carries the configuration and the error message. The validator class contains the logic. Symfony links them by convention: UniqueBookingSlot resolves to UniqueBookingSlotValidator unless you override validatedBy().

Since PHP 8, constraints are attributes. If you still maintain annotation-based constraints from the Symfony 4 era, the migration is mechanical but worth doing, because attributes are validated by the PHP parser itself and IDEs autocomplete them properly.

#[\Attribute(\Attribute::TARGET_CLASS)]
class UniqueBookingSlot extends Constraint
{
    public string $message = 'This slot overlaps an existing booking for {{ resource }}.';

    public function getTargets(): string|array
    {
        return self::CLASS_CONSTRAINT;
    }
}

Note the target. Property-level constraints see one value in isolation. A rule like "start and end must not overlap another booking for the same resource" needs three fields at once, so the constraint targets the class and the validator receives the whole object. This is the same approach UniqueEntity takes for composite uniqueness, but writing it yourself means you control the query, which matters as soon as "unique" comes with conditions such as ignoring cancelled bookings.

Injecting services into the validator

The part the basic tutorials skip: validators are regular services. With autoconfiguration enabled, any class extending ConstraintValidator is registered and tagged automatically, so constructor injection just works.

class UniqueBookingSlotValidator extends ConstraintValidator
{
    public function __construct(
        private readonly BookingRepository $bookings,
    ) {
    }

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

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

        $conflict = $this->bookings->findOverlap(
            $value->getResource(),
            $value->getStartsAt(),
            $value->getEndsAt(),
            excludeId: $value->getId(),
        );

        if ($conflict === null) {
            return;
        }

        $this->context->buildViolation($constraint->message)
            ->setParameter('{{ resource }}', $value->getResource()->getName())
            ->atPath('startsAt')
            ->addViolation();
    }
}

Three details here save debugging time later. The excludeId parameter keeps the constraint from rejecting an unchanged entity on edit. The atPath() call attaches the violation to a concrete property, which the form component picks up (more on that below). And the type guards at the top are not ceremony: when someone applies your constraint to the wrong class two years from now, an exception that names the expected type beats a silent pass.

One warning about database-backed validators: the repository query runs on every validation pass. If the constraint sits in a hot path such as an import loop, either batch the checks outside the validator or make sure the query hits an index. We have audited more than one system where a well-intentioned uniqueness check performed a table scan per row of a 50,000-row CSV import. Slow validation of this kind is a recurring find in our code audits.

Per-tenant rules read from the database

Now the interesting case. In a multi-tenant product, tenants often configure their own rules: tenant A requires a cost center on every purchase order, tenant B caps order values at 10,000 euros, tenant C does neither. Hardcoding these as separate constraints does not scale past the third tenant. Instead, one generic constraint delegates to configuration.

#[\Attribute(\Attribute::TARGET_PROPERTY)]
class TenantRule extends Constraint
{
    public function __construct(
        public string $rule,
        mixed $options = null,
        ?array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct($options, $groups, $payload);
    }
}

The validator resolves the current tenant, loads its rule set, and applies whatever the configuration says:

class TenantRuleValidator extends ConstraintValidator
{
    public function __construct(
        private readonly TenantContext $tenantContext,
        private readonly TenantRuleProvider $rules,
    ) {
    }

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

        $config = $this->rules->for(
            $this->tenantContext->current(),
            $constraint->rule,
        );

        if ($config === null) {
            return; // tenant has not enabled this rule
        }

        if ($config->required && ($value === null || $value === '')) {
            $this->context->buildViolation($config->requiredMessage)->addViolation();
            return;
        }

        if ($config->max !== null && $value > $config->max) {
            $this->context->buildViolation($config->maxMessage)
                ->setParameter('{{ max }}', (string) $config->max)
                ->addViolation();
        }
    }
}

Usage on the entity stays declarative: #[TenantRule('purchase_order.amount')]. The entity does not know which tenants enforce what, and product managers can change rules without a deployment.

Two production notes. Cache the rule provider aggressively, because this validator runs on every submit and the rules change rarely; a request-scoped memoization plus a short Redis TTL is usually enough. And log which rule produced a violation, including the tenant id. When a customer reports "the form will not let me save", support needs to see which configured rule fired rather than a generic message.

Validation groups: create is not edit

The same object frequently has different rules in different contexts. A password is mandatory on registration but absent on profile edit. A SKU is free to choose on creation but immutable afterwards. Validation groups express this without duplicating the model.

#[Assert\NotBlank(groups: ['registration'])]
#[Assert\Length(min: 12, groups: ['registration'])]
private ?string $plainPassword = null;

The form decides which groups apply, and a callback makes that decision dynamic:

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'data_class' => User::class,
        'validation_groups' => function (FormInterface $form): array {
            $user = $form->getData();

            return $user->getId() === null
                ? ['Default', 'registration']
                : ['Default'];
        },
    ]);
}

Constraints without an explicit group belong to Default, so the callback above runs the shared rules in both contexts and the registration rules only for new users. Resist the temptation to create groups per form field or per tenant; groups model contexts such as create, edit and import. The per-tenant variance belongs in the TenantRule pattern from the previous section, and mixing the two mechanisms produces rule sets nobody can reason about.

Group sequences are worth knowing about too. #[Assert\GroupSequence(['Basic', 'Expensive'])] on the class runs the cheap constraints first and the database-backed ones only if the cheap ones pass. That ordering saves you the overlap query for a form that is missing its required fields anyway.

Cascading into nested objects and collections

Validation stops at object boundaries unless you tell it not to. An Order containing an array of OrderLine objects validates its own properties and ignores the lines entirely, which surprises most people the first time.

#[Assert\Valid]
#[Assert\Count(min: 1, minMessage: 'An order needs at least one line.')]
private Collection $lines;

#[Assert\Valid] cascades into each line and runs whatever constraints the OrderLine class declares, including custom ones. Violations carry property paths like lines[2].quantity, so error messages land on the right row. For scalar collections there is #[Assert\All], which applies a list of constraints to every element without requiring a dedicated class.

Getting errors onto the right form field

Class-level constraints have one rough edge in the form layer: a violation without a property path attaches to the form itself and renders at the top, far from the field the user needs to fix. You have two tools for this.

The first is atPath() in the validator, as shown earlier. Because our overlap violation points at startsAt, the form renders it next to the start field with no extra configuration. The second is the error_mapping form option, useful when the violation path does not match a form field name, for example when a mapped entity property is split across two unmapped inputs. Prefer atPath() when you own the validator. It keeps the knowledge of where an error belongs next to the logic that produces it, instead of scattering mapping rules across every form that uses the constraint.

Where this pays off

The pattern set above (class-level constraints for multi-field rules, service injection for database checks, one configurable constraint for tenant variance, groups for context, cascade for nesting) covers nearly every validation requirement we have met in ten years of Symfony work. The payoff is that business rules live in one testable place instead of being smeared across controllers, form listeners and JavaScript.

That last point deserves a sentence: constraint validators are plain classes and unit test cleanly with an in-memory rule provider and a stubbed repository. If your current codebase validates in controllers with if-statements, moving that logic into constraints is a refactoring with quick returns, and one we regularly carry out as part of legacy modernization projects.

If you are building a multi-tenant product and the validation layer is where clean architecture goes to die, we can help. Wolf-Tech designs and builds custom Symfony applications and reviews existing ones. Write to hello@wolf-tech.io or have a look around wolf-tech.io.