php 132 lines · 4 tabs

Validate a Symfony Signup DTO With a Custom Constraint and Validator

Shared by codesnips Aug 2026
4 tabs
<?php

namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_CLASS)]
final class PasswordNotContainingEmail extends Constraint
{
    public string $message = 'Your password must not contain "{{ local }}" from your email address.';

    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT;
    }
}
4 files · php Explain with highlit

This snippet shows how a signup payload is validated in Symfony using a plain PHP DTO plus a reusable custom constraint that enforces a rule the built-in constraints cannot express: the requested password must not contain the local part of the email. The pattern keeps validation declarative and testable — the DTO carries attribute-based constraints, and a dedicated validator class holds the cross-field logic, so controllers stay thin.

In SignupRequest DTO, each property is annotated with framework constraints (Assert\NotBlank, Assert\Email, Assert\Length) using PHP 8 attributes. The class-level #[PasswordNotContainingEmail] attribute attaches the custom rule to the whole object, which is what allows the validator to see both email and plainPassword at once. Modeling the input as an immutable-ish DTO with public readonly properties gives a typed, serializer-friendly shape and separates transport concerns from the User entity.

In PasswordNotContainingEmail constraint, the constraint is a small metadata object. Setting getTargets() to CLASS_CONSTRAINT tells Symfony this rule applies to an entire object rather than a single field, and the $message property provides the default violation text with a {{ local }} placeholder that the validator fills in. The #[Attribute] marker makes it usable as an attribute on the DTO.

In PasswordNotContainingEmailValidator, validate() receives the whole SignupRequest as $value. It guards against the wrong constraint type with an UnexpectedTypeException, skips work when either field is empty (empty-value checks belong to NotBlank), then compares the lowercased password against the email's local part. When the rule is violated it calls buildViolation() and, crucially, atPath('plainPassword') so the error is attached to the correct field for form and API responses, not to the object root.

In RegistrationController, the JSON body is deserialized straight into the DTO via the Serializer, then passed to $validator->validate(). A non-empty ConstraintViolationList is turned into a 422 response; otherwise registration proceeds. This flow — deserialize, validate, branch — is idiomatic for API endpoints and avoids Symfony Forms entirely. The main trade-off is that cross-field logic lives in its own class rather than inline, which is more ceremony but far more reusable and unit-testable. A common pitfall is forgetting atPath(), which buries field errors at the root and confuses clients.


Related snips

Share this code

Here's the card — post it anywhere.

Validate a Symfony Signup DTO With a Custom Constraint and Validator — share card
Link copied