Skip to content

Validation — constraints, groups, DTO validation, expressions

TL;DR — Le Validator component est indépendant des Forms : il valide n'importe quel objet, scalar, ou collection via constraints PHP attributes (#[Assert\NotBlank]). Groups = activer des sous-ensembles de règles selon le contexte (create vs update). #[Assert\Valid] cascade dans les nested objects. #[Assert\Callback], #[Assert\When] et #[Assert\Expression] couvrent les règles métier dynamiques. Pour les API, valider un DTO directement (sans Form) est la voie standard.

🧠 Mental model — ASCII diagram + analogie

   $errors = $validator->validate($object, groups: ['create']);


   ┌──────────────────────────────────────────────────────┐
   │ Loader (AttributeLoader)                              │
   │   - reflects $object class                            │
   │   - collects #[Assert\*] attributes per property      │
   │   - builds ClassMetadata cache                        │
   └──────────────────────────────────────────────────────┘


   ┌──────────────────────────────────────────────────────┐
   │ Iterate each constraint:                              │
   │   - If groups match → run validator                   │
   │   - If #[Assert\Valid] → recurse into nested object   │
   │   - On violation → append to ConstraintViolationList  │
   └──────────────────────────────────────────────────────┘


   ConstraintViolationList implements Countable, IteratorAggregate
        ↳ count() == 0  → valid
        ↳ each Violation has: message, propertyPath, root, invalidValue

Analogie : Validator = contrôleur qualité dans une usine. Tu lui présentes un produit (objet), il a une checklist (constraints). Tu peux activer la checklist "production" ou "exportation" (groups). S'il trouve un défaut, il te liste tous les problèmes — pas juste le premier (sauf si tu le configures ainsi).

Le modèle mental qui compte vraiment

Trois invariants qu'un staff engineer garde en tête :

  1. Constraint vs Validator, c'est le pattern Strategy. Une Constraint est une valeur de configuration (DTO immuable : message, options, groups). Le ConstraintValidator est le comportement. Le validator est récupéré du service container par Constraint::validatedBy() (par défaut <Constraint>::class . 'Validator'). Conséquence : un validator doit être stateless entre deux validate() — il est partagé. Ne stocke jamais d'état dans une propriété d'instance ; tout passe par $this->context.

  2. La validation est un parcours d'arbre, pas une boucle plate. #[Assert\Valid] transforme la validation en DFS sur le graphe d'objets. Le propertyPath que tu vois (items[3].sku) est construit par accumulation pendant la descente. C'est pourquoi atPath() est relatif au nœud courant, pas à la racine.

  3. Le résultat est append-only et sans court-circuit global. Par défaut, toutes les constraints de tous les groupes actifs s'exécutent et accumulent leurs violations. Le seul mécanisme de court-circuit est local : #[Assert\Sequentially] (arrête au premier fail de cette séquence) et GroupSequence (arrête le groupe N+1 si le groupe N a échoué). Comprendre ça évite de payer un appel HTTP coûteux (lookup SIRET) alors que le format de base est déjà invalide.

Coût réel : la ClassMetadata (reflection + parsing des attributes) est construite une fois puis mise en cache (Psr\Cache → APCu/validator.mapping.cache.adapter en prod). En dev sans cache chaud, valider 10 k objets reflète 10 k fois si le cache pool n'est pas warm — d'où cache:warmup au déploiement.

🛠️ Code minimal — DTO validation + custom constraint + expression

php
// src/Dto/CreateOrderDto.php
<?php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class CreateOrderDto
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public ?string $customerEmail = null;

    #[Assert\NotBlank]
    #[Assert\Regex(
        pattern: '/^[A-Z]{2}\d{6}$/',
        message: 'Invalid SKU format (e.g. AB123456)',
    )]
    public ?string $sku = null;

    #[Assert\Positive]
    #[Assert\Range(min: 1, max: 100)]
    public int $quantity = 1;

    #[Assert\NotNull]
    #[Assert\Currency]
    public ?string $currency = null;

    #[Assert\NotBlank(groups: ['payment'])]
    #[Assert\CardScheme(schemes: ['VISA', 'MASTERCARD'], groups: ['payment'])]
    public ?string $cardNumber = null;

    #[Assert\GreaterThan('today', groups: ['delivery'])]
    public ?\DateTimeInterface $expectedDelivery = null;

    /** @var LineItem[] */
    #[Assert\Valid]
    #[Assert\Count(min: 1, max: 50)]
    public array $items = [];

    #[Assert\When(
        expression: 'this.quantity > 10',
        constraints: [
            new Assert\NotBlank(message: 'Bulk orders require a PO number'),
        ],
    )]
    public ?string $purchaseOrderNumber = null;

    #[Assert\Callback]
    public function validateItemsTotal(\Symfony\Component\Validator\Context\ExecutionContextInterface $context): void
    {
        $total = array_sum(array_map(fn(LineItem $li) => $li->price * $li->quantity, $this->items));
        if ($total < 100) {
            $context->buildViolation('Order total must be at least 100')
                ->atPath('items')
                ->addViolation();
        }
    }
}
php
// src/Dto/LineItem.php
<?php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class LineItem
{
    #[Assert\NotBlank]
    public ?string $sku = null;

    #[Assert\Positive]
    public int $quantity = 1;

    #[Assert\Positive]
    public int $price = 0; // cents
}

Custom constraint :

php
// src/Validator/UniqueEmail.php
<?php
namespace App\Validator;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)]
final class UniqueEmail extends Constraint
{
    public string $message = 'The email {{ value }} is already in use.';

    public function getTargets(): string|array
    {
        return self::PROPERTY_CONSTRAINT;
    }
}
php
// src/Validator/UniqueEmailValidator.php
<?php
namespace App\Validator;

use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class UniqueEmailValidator extends ConstraintValidator
{
    public function __construct(private readonly UserRepository $users) {}

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof UniqueEmail) {
            throw new UnexpectedTypeException($constraint, UniqueEmail::class);
        }
        if (null === $value || '' === $value) {
            return;
        }
        if ($this->users->existsByEmail((string) $value)) {
            $this->context->buildViolation($constraint->message)
                ->setParameter('{{ value }}', (string) $value)
                ->addViolation();
        }
    }
}

Usage dans controller :

php
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;

#[Route('/orders', methods: ['POST'])]
public function create(
    #[MapRequestPayload(validationGroups: ['Default', 'payment'])] CreateOrderDto $dto,
): JsonResponse {
    // Si validation fail, MapRequestPayload throw ValidationFailedException auto → 422
    // Pour validation manuelle :
    // $errors = $this->validator->validate($dto, groups: ['Default', 'payment']);
    // if (count($errors) > 0) { throw new ValidationFailedException($dto, $errors); }

    return $this->json($this->orderService->place($dto), 201);
}

Validation manuelle d'un scalar :

php
$violations = $validator->validate(
    $request->query->get('email'),
    [new Assert\NotBlank(), new Assert\Email()],
);
if (count($violations) > 0) { /* ... */ }

🎯 Patterns courants

  1. Groups par contexte['Default', 'create'] pour POST, ['Default', 'update'] pour PUT. Le groupe Default contient les constraints sans groups: spécifié.
  2. #[Assert\Valid] cascade — sur une propriété object ou array, déclenche la validation récursive. Sans, les nested objects sont ignorés.
  3. #[Assert\When] (6.2+) — conditional validation : applique des constraints uniquement si une expression ExpressionLanguage est vraie. Beaucoup plus propre qu'un Callback. Depuis 7.3, tu peux passer des values: extra à l'expression (ex. injecter un service exposé). À ne pas confondre avec #[Assert\Expression] qui est lui-même une violation si l'expression est fausse — When est un wrapper conditionnel autour d'autres constraints.
  4. #[Assert\Expression] — pour des règles cross-property : expression: "this.endDate > this.startDate". Évalué via ExpressionLanguage component.
  5. Custom constraint reusableUniqueEmail, PhoneNumber, IbanFormat, BusinessRule. Plus testable et expressif qu'un Callback inline.
  6. #[Assert\Sequentially] — exécute les constraints en série, arrête au premier fail. Évite un message d'erreur "Invalid email AND too long" alors que la valeur est juste vide.

🧩 GroupSequence — ordonner la validation pour économiser le compute

Quand des constraints coûtent cher (DB lookup, HTTP, crypto), tu ne veux pas les exécuter si le format de base est déjà cassé. GroupSequence impose un ordre : le groupe N+1 ne s'exécute que si le groupe N n'a produit aucune violation.

php
<?php
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\GroupSequenceProviderInterface;

#[Assert\GroupSequence(['VendorSignupDto', 'remote'])]
final class VendorSignupDto
{
    // groupe 'Default' (alias du nom de classe ici) : format pur, gratuit
    #[Assert\NotBlank]
    #[Assert\Regex('/^\d{14}$/')]
    public string $siret = '';

    // groupe 'remote' : lookup INSEE — ne tourne QUE si le format passe
    #[ValidSiret(groups: ['remote'])]
    public string $siretRemote = ''; // (illustratif : même champ, groupe distinct)
}

Variante dynamique : GroupSequenceProviderInterface (ou #[Assert\GroupSequenceProvider]) calcule l'ordre des groupes à partir de l'état de l'objet — ex. un compte premium déclenche un groupe de règles supplémentaires.

php
#[Assert\GroupSequenceProvider]
final class Account implements GroupSequenceProviderInterface
{
    public bool $isPremium = false;

    public function getGroupSequence(): array|\Symfony\Component\Validator\Constraints\GroupSequence
    {
        $groups = ['Account'];
        if ($this->isPremium) {
            $groups[] = 'premium';
        }
        return $groups;
    }
}

Piège classique : quand tu utilises GroupSequence au niveau classe, le groupe Default est remplacé par la séquence. Référencer le groupe par le nom de la classe (pas Default) à l'intérieur de la séquence, sinon récursion infinie. Symfony lève une GroupDefinitionException explicite si tu mets Default dedans.

🚦 Mapper les violations vers une réponse HTTP (RFC 7807)

Une ConstraintViolationList n'est pas une réponse API. Le mapping violations → payload JSON est une décision d'architecture. Le standard moderne est RFC 7807 / RFC 9457 (application/problem+json). Symfony fournit ConstraintViolationListNormalizer (active framework.serializer + le Serializer component) qui sérialise une ConstraintViolationListInterface au format Hydra/RFC 7807 directement.

php
<?php
// Avec MapRequestPayload : la 422 est produite automatiquement.
// Mais pour valider à la main et garder le contrôle du payload :
use Symfony\Component\Validator\Exception\ValidationFailedException;

$violations = $this->validator->validate($dto, groups: ['Default', 'remote']);
if (count($violations) > 0) {
    // Laisse le framework normaliser : throw → 422 + problem+json
    throw new ValidationFailedException($dto, $violations);
}

Pour une forme stable et versionnée de l'API (front mobile qui mappe code → message i18n), n'expose jamais message directement : expose propertyPath + code (le setCode() machine-readable que tu as posé sur la violation) + un parameters. Le message reste un fallback debug.

php
<?php
$errors = [];
foreach ($violations as $v) {
    $errors[] = [
        'field'      => $v->getPropertyPath(),
        'code'       => $v->getCode(),                 // ex. 'siret_inactive'
        'parameters' => $v->getParameters(),           // ex. ['{{ naf }}' => '52']
        'message'    => (string) $v->getMessage(),     // fallback debug uniquement
    ];
}

Sécurité : la traduction des messages est côté serveur via le validators translation domain. Un message custom qui interpole la valeur soumise (placeholder value) peut leaker cette valeur dans la réponse (ex. un mot de passe dans un message "X is too weak"). Vérifie tes messages avant de les exposer hors d'un contexte authentifié.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : #[Assert\*] PHP 8 attributes stables. enableAttributeMapping() remplace enableAnnotationMapping().
  • 6.0 : annotations Doctrine désactivées par défaut. Uniquement attributes. validation.yaml toujours supporté pour config externe.
  • 6.1 : #[Assert\NoSuspiciousCharacters] (anti-spoofing Unicode), amélioration #[Assert\Url] pour relativeProtocol: true.
  • 6.2 : #[Assert\When] (validation conditionnelle via ExpressionLanguage), #[Assert\PasswordStrength] natif (entropie type Zxcvbn), #[Assert\WordCount].
  • 6.3 : #[Assert\CssColor], meilleur support BackedEnum natif.
  • 6.4 LTS : ConstraintViolationInterface::getConstraint() accessible, ValidatorBuilder standalone amélioré, #[Assert\Charset], #[Assert\Week].
  • 7.0 : suppression de plusieurs deprecated constraints (legacy True/False aliases sans namespace), suppression du LegacyContext interne. PHP 8.2+ requis.
  • 7.1 : #[Assert\Cidr], #[Assert\MacAddress], #[Assert\Week], support WhenGroupSequence.
  • 7.2 : #[Assert\Slug], #[Assert\Twig] (valide qu'une string est un template Twig syntaxiquement correct), #[Assert\Yaml/Json] raffinés.
  • 7.3 : #[Assert\When] accepte des values: supplémentaires injectées dans l'expression ; nouveaux messages pluralisables ; #[Assert\IsTrue]/IsFalse sur méthodes statiques.

⚠️ Pitfalls

  1. Default group oublié — si tu passes validate($obj, groups: ['create']) sans 'Default', les constraints sans groups: ne s'exécutent pas. Toujours ['Default', 'create'].
  2. #[Assert\Valid] manquant sur collection — la collection est validée (Count, etc.), mais pas les items. Toujours mettre #[Assert\Valid] sur la propriété.
  3. Constraint sur readonly property avec validation_groups — Symfony ne peut pas remettre la valeur en cas d'erreur de transform → exceptions ésotériques. Soit readonly + new instance, soit mutable.
  4. #[Assert\Choice] avec enum BackedEnum — si la propriété est déjà typée MyEnum, le typesystem PHP garantit la valeur : pas besoin de Choice. Si tu valides la valeur brute (string désérialisée avant cast), utilise #[Assert\Choice(callback: [MyEnum::class, 'values'])] (avec une méthode values() qui renvoie array_column(self::cases(), 'value')) ou #[Assert\Type(type: MyEnum::class)] pour vérifier que c'est bien une instance. Ne passe pas MyEnum::cases() (objets enum) à choices: quand l'input est un scalaire — la comparaison stricte échouera.
  5. Custom validator non-injectable — par défaut le validator a accès à $this->context, mais si tu type-hint dependencies, il doit être un service. Avec autoconfigure: true, ça passe ; sinon tag manuellement validator.constraint_validator.
  6. Expression sans escapingexpression: "this.foo == 'bar'" est OK car évalué dans un sandbox. Mais ne PAS construire l'expression à partir d'input user → ExpressionLanguage = exécution de code.
  7. Validation côté DTO + côté Form — duplication. Choisir un endroit (typiquement DTO), Form ré-utilise via validation_groups.
  8. Cascade infinite loop — entité A → B → A avec #[Assert\Valid] partout. Le validator a une protection contre les cycles, mais build une métadata bizarre. Évite avec un design plat.

🧪 Testing

php
// tests/Dto/CreateOrderDtoTest.php
<?php
namespace App\Tests\Dto;

use App\Dto\CreateOrderDto;
use App\Dto\LineItem;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;

final class CreateOrderDtoTest extends TestCase
{
    private ValidatorInterface $validator;

    protected function setUp(): void
    {
        $this->validator = Validation::createValidatorBuilder()
            ->enableAttributeMapping()
            ->getValidator();
    }

    public function testValidDto(): void
    {
        $dto = new CreateOrderDto();
        $dto->customerEmail = '[email protected]';
        $dto->sku = 'AB123456';
        $dto->quantity = 5;
        $dto->currency = 'EUR';

        $item = new LineItem();
        $item->sku = 'AB123456';
        $item->quantity = 5;
        $item->price = 5000;
        $dto->items = [$item];

        $errors = $this->validator->validate($dto);
        self::assertCount(0, $errors);
    }

    public function testQuantityOverThresholdRequiresPo(): void
    {
        $dto = new CreateOrderDto();
        $dto->customerEmail = '[email protected]';
        $dto->sku = 'AB123456';
        $dto->quantity = 50;
        $dto->currency = 'EUR';
        $dto->items = [/* valid item */];

        $errors = $this->validator->validate($dto);
        $messages = array_map(fn($v) => (string) $v->getMessage(), iterator_to_array($errors));
        self::assertContains('Bulk orders require a PO number', $messages);
    }

    public function testValidationGroups(): void
    {
        $dto = new CreateOrderDto();
        // missing cardNumber but valid otherwise
        $errors = $this->validator->validate($dto, groups: ['Default']);
        // payment group not triggered
        foreach ($errors as $error) {
            self::assertNotSame('cardNumber', $error->getPropertyPath());
        }
    }
}

Tester un custom constraint validator avec service container :

php
final class UniqueEmailValidatorTest extends KernelTestCase
{
    public function testRejectsExistingEmail(): void
    {
        self::bootKernel();
        $validator = static::getContainer()->get('validator');

        $obj = new class { #[UniqueEmail] public string $email = '[email protected]'; };
        $errors = $validator->validate($obj);
        self::assertGreaterThan(0, count($errors));
    }
}

Debug CLI :

bash
php bin/console debug:validator App\\Dto\\CreateOrderDto

🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique : validation custom de clauses contractuelles

Contexte : un cabinet d'avocats utilise un éditeur de contrats où l'utilisateur compose un document à partir de clauses paramétriques (Article 1, 2, 3...). Certaines clauses sont incompatibles (clause "exclusivité" exclut clause "non-concurrence sectorielle"), d'autres exigent un ordre précis. Le contrat ne doit JAMAIS être signable s'il viole une règle métier.

L'équipe écrit une contrainte custom #[CompatibleClauses] posée sur la ContractDto. Le CompatibleClausesValidator charge une ClauseCompatibilityMatrix (chargée depuis YAML, ~80 règles), vérifie chaque paire de clauses présentes, propage les violations avec un propertyPath précis (clauses[5]). Les groupes de validation Assert\Sequentially(['Default', 'businessRules']) permettent de vérifier d'abord la structure puis les règles métier — économise du compute si la structure est déjà invalide.

Bénéfice : un avocat junior ne peut pas générer un contrat incohérent ; les erreurs sont remontées dans l'éditeur avec lien vers la clause fautive. Aucune violation détectée en prod sur les 8 derniers mois (avant cette validation, ~3 contrats fautifs/mois).

Scénario 2 — FinTech : validation IBAN, RIB et BIC avec contrôle MOD-97

Contexte : néobanque pro qui doit valider les coordonnées bancaires saisies par ses clients lors d'un ajout de bénéficiaire. Symfony fournit Assert\Iban (structure + MOD-97), Assert\Bic. Mais la banque veut aller plus loin : (1) vérifier que l'IBAN appartient à un pays SEPA, (2) extraire automatiquement la country, bankCode, branchCode, (3) interroger une whitelist anti-fraud (IBAN d'arnaqueurs connus partagés entre banques).

L'équipe écrit #[ValidSepaIban] qui chaîne : Assert\Iban standard, puis dans son validator custom, lookup async dans Redis de la blacklist. Si IBAN blacklisté, violation avec code IBAN_BLACKLISTED mappable côté UI vers un message "support contact required". Les sanctions OFAC sont vérifiées via service injecté OfacScreeningClient.

Pour le RIB français legacy (toujours utilisé par anciens partenaires), une contrainte #[ValidRib] valide les 23 chiffres + clé RIB MOD-97 calculée.

Scénario 3 — E-commerce B2B (ManoMano-like) : validation SIRET avec lookup INSEE

Contexte : marketplace B2B où les vendors professionnels doivent fournir leur SIRET au signup. Validation : (1) format 14 chiffres, (2) checksum Luhn, (3) existence dans la base SIRENE INSEE, (4) statut "actif" (pas en cessation), (5) NAF compatible avec la catégorie de produits qu'ils vont vendre.

#[ValidSiret] est posé sur le DTO inscription. Son validator (injecté avec InseeSireneClient HTTP client) appelle l'API officielle pendant la validation — atteint en PostValidator car potentiellement lent (250 ms). En cas d'indisponibilité INSEE, fallback graceful : on accepte la création mais on flag le compte pour vérification manuelle ; un kernel.terminate listener relance la vérif en async via Messenger.

Cache : tout SIRET déjà vérifié est mis en Redis 30 jours. Le taux de hit cache est de 87% (beaucoup de vendors recommandent la marketplace à leurs confrères du même secteur).

🛠️ Exemple end-to-end

Use case : validation SIRET custom avec lookup INSEE pour onboarding vendor marketplace, fallback async sur indisponibilité.

php
// src/Validation/Constraint/ValidSiret.php
<?php
declare(strict_types=1);

namespace App\Validation\Constraint;

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_PARAMETER)]
final class ValidSiret extends Constraint
{
    public const string INVALID_FORMAT = 'siret_invalid_format';
    public const string INVALID_CHECKSUM = 'siret_invalid_checksum';
    public const string NOT_FOUND = 'siret_not_found';
    public const string INACTIVE = 'siret_inactive';

    public function __construct(
        public string $messageFormat = 'Le SIRET doit contenir 14 chiffres.',
        public string $messageChecksum = 'Le SIRET est invalide (clé Luhn incorrecte).',
        public string $messageNotFound = 'SIRET introuvable dans la base SIRENE.',
        public string $messageInactive = 'L\'entreprise est marquée comme cessée d\'activité.',
        public ?string $expectedNafSection = null,
        ?array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct(options: null, groups: $groups, payload: $payload);
    }
}
php
// src/Validation/Validator/ValidSiretValidator.php
<?php
declare(strict_types=1);

namespace App\Validation\Validator;

use App\Sirene\InseeSireneClient;
use App\Sirene\Exception\SireneUnavailableException;
use App\Validation\Constraint\ValidSiret;
use Psr\Log\LoggerInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
use Symfony\Component\Validator\Exception\UnexpectedValueException;

final class ValidSiretValidator extends ConstraintValidator
{
    public function __construct(
        private readonly InseeSireneClient $insee,
        private readonly LoggerInterface $logger,
    ) {}

    public function validate(mixed $value, Constraint $constraint): void
    {
        if (!$constraint instanceof ValidSiret) {
            throw new UnexpectedTypeException($constraint, ValidSiret::class);
        }
        if ($value === null || $value === '') {
            return;
        }
        if (!is_string($value)) {
            throw new UnexpectedValueException($value, 'string');
        }

        if (!preg_match('/^\d{14}$/', $value)) {
            $this->context->buildViolation($constraint->messageFormat)
                ->setCode(ValidSiret::INVALID_FORMAT)
                ->addViolation();
            return;
        }

        if (!$this->validateLuhn($value)) {
            $this->context->buildViolation($constraint->messageChecksum)
                ->setCode(ValidSiret::INVALID_CHECKSUM)
                ->addViolation();
            return;
        }

        try {
            $info = $this->insee->lookupSiret($value);
        } catch (SireneUnavailableException $e) {
            $this->logger->warning('INSEE down, deferred verification', ['siret' => $value]);
            return; // accepte, vérification async
        }

        if ($info === null) {
            $this->context->buildViolation($constraint->messageNotFound)
                ->setCode(ValidSiret::NOT_FOUND)
                ->addViolation();
            return;
        }

        if (!$info->isActive) {
            $this->context->buildViolation($constraint->messageInactive)
                ->setCode(ValidSiret::INACTIVE)
                ->addViolation();
            return;
        }

        if ($constraint->expectedNafSection !== null
            && !str_starts_with($info->nafCode, $constraint->expectedNafSection)) {
            $this->context->buildViolation('NAF "{{ naf }}" incompatible avec la catégorie attendue.')
                ->setParameter('{{ naf }}', $info->nafCode)
                ->addViolation();
        }
    }

    private function validateLuhn(string $digits): bool
    {
        $sum = 0;
        for ($i = strlen($digits) - 1, $alt = false; $i >= 0; --$i, $alt = !$alt) {
            $d = (int) $digits[$i];
            if ($alt) {
                $d *= 2;
                if ($d > 9) {
                    $d -= 9;
                }
            }
            $sum += $d;
        }

        return $sum % 10 === 0;
    }
}
php
// src/Vendor/Dto/VendorSignupDto.php
<?php
declare(strict_types=1);

namespace App\Vendor\Dto;

use App\Validation\Constraint\ValidSiret;
use Symfony\Component\Validator\Constraints as Assert;

final readonly class VendorSignupDto
{
    public function __construct(
        #[Assert\NotBlank]
        #[Assert\Length(min: 2, max: 100)]
        public string $companyName,

        #[Assert\NotBlank]
        #[ValidSiret(expectedNafSection: '47')]  // commerce de détail
        public string $siret,

        #[Assert\NotBlank]
        #[Assert\Email]
        public string $email,

        #[Assert\NotBlank]
        #[Assert\Regex(pattern: '/^\+33[1-9]\d{8}$/', message: 'Numéro de téléphone français au format +33...')]
        public string $phoneE164,
    ) {}
}
php
// src/Controller/Vendor/SignupController.php
<?php
declare(strict_types=1);

namespace App\Controller\Vendor;

use App\Vendor\Dto\VendorSignupDto;
use App\Vendor\Service\VendorOnboarder;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;

final class SignupController
{
    public function __construct(private readonly VendorOnboarder $onboarder) {}

    #[Route('/api/vendors/signup', name: 'vendor_signup', methods: ['POST'])]
    public function __invoke(#[MapRequestPayload] VendorSignupDto $dto): JsonResponse
    {
        $vendor = $this->onboarder->register($dto);

        return new JsonResponse(['vendorId' => $vendor->id, 'status' => 'pending_review'], 201);
    }
}

Le #[MapRequestPayload] désérialise + valide automatiquement. En cas d'erreur (SIRET invalide, NAF incompatible), Symfony retourne 422 Unprocessable Entity avec violations détaillées par champ. Si INSEE est down, le SIRET passe — le vendor est créé en statut pending_review, et un Messenger handler relance la vérif quand INSEE est revenu.


🔁 Quand utiliser / éviter

  • Validator + DTO : API endpoints, command handlers, message handlers. Le standard moderne.
  • Form + Validator : forms HTML (Form active validation automatiquement). Évite de re-valider manuellement après $form->isValid().
  • Custom constraint : règle métier réutilisée ≥ 3 fois (IBAN, SIRET, password policy). Évite si c'est utilisé 1 seule fois → Callback inline.
  • #[Assert\When] : règle conditionnelle simple. Pour logique complexe (cross-service, DB lookup), Callback ou custom constraint.
  • Validation cascade : nested DTOs. Évite si profondeur > 3 niveaux (mauvais design plutôt).

🏭 Production : perf, observabilité, scale

Performance

  • Cache de métadata : framework.validation.cache doit pointer sur un pool persistant (APCu/cache.system) en prod. Sans ça, chaque requête re-reflète les classes. Mesurable : un endpoint qui valide un DTO de 30 propriétés passe de ~1.2 ms (cache froid) à ~0.05 ms (chaud).
  • Constraints I/O = le vrai coût. Un UniqueEmail ou ValidSiret fait un appel DB/HTTP dans le thread de la requête. Sous charge, N validations concurrentes = N connexions. Mitige avec : GroupSequence (ne valide le coûteux que si le cheap passe), cache Redis du résultat, et un timeout strict sur le client HTTP (sinon une validation peut bloquer un worker indéfiniment).
  • N+1 caché : #[Assert\Valid] sur une collection de 500 items, chacun avec un UniqueX qui fait une requête → 500 requêtes. Préfère une validation batch (un Callback au niveau parent qui charge tout en un WHERE sku IN (...)).

Observabilité

  • Logge les codes de violation, pas les messages (les messages sont i18n et bruyants). Un compteur validation_failed{field, code} révèle quel champ casse le plus (ex. pic de siret_inactive = problème data en amont).
  • Distingue 400 (payload malformé : JSON invalide, type mismatch au mapping) de 422 (payload bien formé mais sémantiquement invalide). MapRequestPayload produit déjà cette distinction.

Tableau de décision — où poser la règle

RègleOutilPourquoi
Format/présence (NotBlank, Email, Length)Attribute sur DTODéclaratif, gratuit, cachable
Cross-property (endDate > startDate)#[Assert\Expression] ou #[Assert\Callback]Accès aux autres propriétés
Conditionnelle simple#[Assert\When]Lisible, pas de méthode
Règle métier réutilisée ≥3×Custom Constraint + ValidatorTestable, injectable, nommée
Unicité / lookup externeCustom Validator (groupe remote)Isole l'I/O, GroupSequence pour court-circuiter
Invariant toujours vrai (jamais cassable)Type PHP / readonly / enum / Value ObjectLe compilateur > le validator. Ne valide pas ce que le type garantit

Principe staff : la validation n'est pas la première ligne de défense de l'invariant — c'est le typesystem. Un Email value object qui throw dans son constructeur rend l'état invalide inreprésentable. Le Validator sert à transformer une entrée non fiable (HTTP) en violations présentables à l'utilisateur, pas à protéger ton domaine. Garde les deux couches.

🏋️ Exercices

1. DTO de réservation avec règles conditionnelles — implémenter

Objectif : valider un BookingDto (checkIn, checkOut, guests, roomType) où checkOut > checkIn, guests ≤ capacité du roomType, et specialRequest obligatoire seulement si roomType === 'suite'. Indice/Solution : #[Assert\Expression('this.checkOut > this.checkIn')] au niveau classe pour le cross-field, #[Assert\When(expression: "this.roomType == 'suite'", constraints: [new Assert\NotBlank()])] sur specialRequest. La capacité par type → #[Assert\Callback] qui lit une map ['suite' => 4, ...].

2. Custom constraint UniqueSku injectable + test KernelTestCase — implémenter

Objectif : écrire #[UniqueSku] qui rejette un SKU déjà en base, avec setCode('SKU_TAKEN'), et le tester via le vrai container. Indice/Solution : Constraint (target property), ConstraintValidator avec __construct(private ProductRepository $repo), autoconfigure tague automatiquement validator.constraint_validator. Test : bootKernel(), getContainer()->get('validator'), objet anonyme avec l'attribut.

3. GroupSequence pour court-circuiter un lookup coûteux — production-grade

Objectif : pour le DTO SIRET de ce chapitre, garantir que l'appel INSEE ne part jamais si le format/Luhn échoue, et le prouver par un test qui mock le client et asserte 0 appel. Indice/Solution : #[Assert\GroupSequence(['VendorSignupDto', 'remote'])], mets ValidSiret(groups: ['remote']), format en Default. Test : un mock InseeSireneClient avec ->expects(self::never())->method('lookupSiret') et un SIRET au mauvais format.

4. Mapper les violations en RFC 7807 stable — production-grade

Objectif : un ValidationFailedException → réponse application/problem+json avec {type, title, status: 422, errors: [{field, code, parameters}]}, sans jamais exposer le message brut. Indice/Solution : un kernel.exception listener (ou un ExceptionEvent subscriber) qui attrape ValidationFailedException, itère getViolations(), construit le payload depuis getPropertyPath() + getCode() + getParameters(). Header Content-Type: application/problem+json.

5. Tuer un N+1 de validation — break-then-fix

Objectif : un endpoint d'import valide un array de 1000 RowDto, chacun avec #[UniqueSku] (1 requête/item). Reproduire les 1000 requêtes (profiler / query logger), puis ramener à 1 requête. Indice/Solution : retire UniqueSku des items, ajoute un #[Assert\Callback] au niveau du DTO racine qui collecte tous les SKU, fait un WHERE sku IN (:skus), et émet une violation atPath("rows[$i].sku") par doublon trouvé. Mesure avant/après avec doctrine.dbal query count.

6. Validation cross-service sécurisée avec #[Assert\When] + values (7.3) — break-then-fix

Objectif : autoriser un montant de virement > 10 000 € seulement si le compte a le flag kycVerified. D'abord l'écrire naïvement avec une expression qui appelle un service via this, observer pourquoi c'est fragile/non testable, puis le refaire proprement. Indice/Solution : naïf = #[Assert\Expression("this.amount <= 10000 or this.account.kycVerified")] couple le DTO à l'entité Account. Mieux : passer la donnée pré-résolue via #[Assert\When(expression: "this.amount > 10000", constraints: [new Assert\IsTrue()], values: ...)] ou résoudre kycVerified en amont et le poser comme propriété booléenne du DTO. Leçon : la validation ne doit pas faire d'I/O dans une expression sandboxée.

🎤 En entretien

Q : Quelle est la différence entre Constraint et ConstraintValidator, et pourquoi cette séparation ? R : Constraint = configuration immuable (Strategy pattern : message, options, groups), ConstraintValidator = comportement récupéré du container et partagé/stateless. La séparation rend le validator injectable (services, repos) et testable, et permet de mettre la métadata en cache puisque la Constraint est sérialisable.

Q : Tu valides un DTO avec un appel HTTP dans un custom constraint. Quels sont les risques en prod et comment les gérer ? R : I/O synchrone dans le thread requête → un worker bloqué si pas de timeout, N+1 sur les collections, latence non bornée. Mitigations : timeout strict sur le client, GroupSequence pour ne tenter le lookup qu'après le format, cache Redis du résultat, fallback graceful (accepter + flag pending_review + re-vérif async via Messenger), et validation batch au niveau parent plutôt que par item.

Q : Pourquoi validate($obj, groups: ['create']) peut laisser passer un objet manifestement invalide ? R : Le groupe Default n'est pas inclus : toutes les constraints sans groups: explicite appartiennent à Default et ne tournent pas. Il faut ['Default', 'create']. Subtilité : avec une GroupSequence au niveau classe, Default est remplacé par la séquence et se réfère au nom de la classe.

Q : Où placerais-tu un invariant qui ne doit JAMAIS être violable, et pourquoi pas dans le Validator ? R : Dans le typesystem — un Value Object qui throw dans son constructeur rend l'état invalide inreprésentable, garanti à la compilation/instanciation, pas seulement quand quelqu'un pense à appeler validate(). Le Validator traite l'entrée non fiable (HTTP) et produit des violations présentables. Les deux couches coexistent : type pour l'invariant du domaine, Validator pour l'UX de l'erreur.

🔗 Liens

Bibliothèque tech perso — Achref