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 (createvsupdate).#[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, invalidValueAnalogie : 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 :
Constraint vs Validator, c'est le pattern Strategy. Une
Constraintest une valeur de configuration (DTO immuable : message, options, groups). LeConstraintValidatorest le comportement. Le validator est récupéré du service container parConstraint::validatedBy()(par défaut<Constraint>::class . 'Validator'). Conséquence : un validator doit être stateless entre deuxvalidate()— il est partagé. Ne stocke jamais d'état dans une propriété d'instance ; tout passe par$this->context.La validation est un parcours d'arbre, pas une boucle plate.
#[Assert\Valid]transforme la validation en DFS sur le graphe d'objets. LepropertyPathque tu vois (items[3].sku) est construit par accumulation pendant la descente. C'est pourquoiatPath()est relatif au nœud courant, pas à la racine.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) etGroupSequence(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.adapteren 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:warmupau déploiement.
🛠️ Code minimal — DTO validation + custom constraint + expression
// 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();
}
}
}// 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 :
// 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;
}
}// 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 :
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 :
$violations = $validator->validate(
$request->query->get('email'),
[new Assert\NotBlank(), new Assert\Email()],
);
if (count($violations) > 0) { /* ... */ }🎯 Patterns courants
- Groups par contexte —
['Default', 'create']pour POST,['Default', 'update']pour PUT. Le groupeDefaultcontient les constraints sansgroups:spécifié. #[Assert\Valid]cascade — sur une propriété object ou array, déclenche la validation récursive. Sans, les nested objects sont ignorés.#[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 desvalues: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 —Whenest un wrapper conditionnel autour d'autres constraints.#[Assert\Expression]— pour des règles cross-property :expression: "this.endDate > this.startDate". Évalué via ExpressionLanguage component.- Custom constraint reusable —
UniqueEmail,PhoneNumber,IbanFormat,BusinessRule. Plus testable et expressif qu'unCallbackinline. #[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
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.
#[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
GroupSequenceau niveau classe, le groupeDefaultest remplacé par la séquence. Référencer le groupe par le nom de la classe (pasDefault) à l'intérieur de la séquence, sinon récursion infinie. Symfony lève uneGroupDefinitionExceptionexplicite si tu metsDefaultdedans.
🚦 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
// 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
$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
validatorstranslation domain. Un message custom qui interpole la valeur soumise (placeholdervalue) 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()remplaceenableAnnotationMapping(). - 6.0 : annotations Doctrine désactivées par défaut. Uniquement attributes.
validation.yamltoujours supporté pour config externe. - 6.1 :
#[Assert\NoSuspiciousCharacters](anti-spoofing Unicode), amélioration#[Assert\Url]pourrelativeProtocol: true. - 6.2 :
#[Assert\When](validation conditionnelle via ExpressionLanguage),#[Assert\PasswordStrength]natif (entropie type Zxcvbn),#[Assert\WordCount]. - 6.3 :
#[Assert\CssColor], meilleur supportBackedEnumnatif. - 6.4 LTS :
ConstraintViolationInterface::getConstraint()accessible,ValidatorBuilderstandalone amélioré,#[Assert\Charset],#[Assert\Week]. - 7.0 : suppression de plusieurs deprecated constraints (legacy
True/Falsealiases sans namespace), suppression duLegacyContextinterne. PHP 8.2+ requis. - 7.1 :
#[Assert\Cidr],#[Assert\MacAddress],#[Assert\Week], supportWhenGroupSequence. - 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 desvalues:supplémentaires injectées dans l'expression ; nouveaux messages pluralisables ;#[Assert\IsTrue]/IsFalsesur méthodes statiques.
⚠️ Pitfalls
Defaultgroup oublié — si tu passesvalidate($obj, groups: ['create'])sans'Default', les constraints sansgroups:ne s'exécutent pas. Toujours['Default', 'create'].#[Assert\Valid]manquant sur collection — la collection est validée (Count, etc.), mais pas les items. Toujours mettre#[Assert\Valid]sur la propriété.- 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. #[Assert\Choice]avec enum BackedEnum — si la propriété est déjà typéeMyEnum, le typesystem PHP garantit la valeur : pas besoin deChoice. Si tu valides la valeur brute (string désérialisée avant cast), utilise#[Assert\Choice(callback: [MyEnum::class, 'values'])](avec une méthodevalues()qui renvoiearray_column(self::cases(), 'value')) ou#[Assert\Type(type: MyEnum::class)]pour vérifier que c'est bien une instance. Ne passe pasMyEnum::cases()(objets enum) àchoices:quand l'input est un scalaire — la comparaison stricte échouera.- 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. Avecautoconfigure: true, ça passe ; sinon tag manuellementvalidator.constraint_validator. Expressionsans escaping —expression: "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.- Validation côté DTO + côté Form — duplication. Choisir un endroit (typiquement DTO), Form ré-utilise via
validation_groups. - 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
// 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 :
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 :
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é.
// 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);
}
}// 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;
}
}// 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,
) {}
}// 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 →
Callbackinline. #[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.cachedoit 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
UniqueEmailouValidSiretfait 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 unUniqueXqui fait une requête → 500 requêtes. Préfère une validation batch (unCallbackau niveau parent qui charge tout en unWHERE 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 desiret_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).
MapRequestPayloadproduit déjà cette distinction.
Tableau de décision — où poser la règle
| Règle | Outil | Pourquoi |
|---|---|---|
Format/présence (NotBlank, Email, Length) | Attribute sur DTO | Dé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 + Validator | Testable, injectable, nommée |
| Unicité / lookup externe | Custom Validator (groupe remote) | Isole l'I/O, GroupSequence pour court-circuiter |
| Invariant toujours vrai (jamais cassable) | Type PHP / readonly / enum / Value Object | Le 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
🏋️ 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.