← Tous les cours / Symfony Validator avancé Cours
00 — VALIDATORINTERFACE

ValidatorInterface et violations

Le Validator Symfony transforme des règles déclaratives en ConstraintViolationList — vide = valide.

Le composant Validator de Symfony repose sur une idée simple : tu déclares des règles (les contraintes) sur tes objets ou valeurs, et le Validator les évalue. Le résultat est toujours une ConstraintViolationList — si elle est vide, tout est valide.

ValidatorInterface — les quatre méthodes

interface ValidatorInterface
{
    // Valide une valeur ou un objet complet
    public function validate(
        mixed $value,
        Constraint|array|null $constraints = null,
        string|GroupSequence|array|null $groups = null
    ): ConstraintViolationListInterface;

    // Valide une seule propriété d'un objet existant
    public function validateProperty(
        object $object,
        string $propertyName,
        string|GroupSequence|array|null $groups = null
    ): ConstraintViolationListInterface;

    // Valide une valeur comme si elle était dans une propriété donnée
    public function validatePropertyValue(
        object|string $objectOrClass,
        string $propertyName,
        mixed $value,
        string|GroupSequence|array|null $groups = null
    ): ConstraintViolationListInterface;

    // Démarre une validation contextuelle enchaînée
    public function startContext(): ContextualValidatorInterface;
}
Pattern de base
$violations = $validator->validate($object);
if (count($violations) > 0) { throw new ValidationException($violations); }
Si count($violations) === 0, l'objet est valide. Ne jamais faire confiance à un objet non validé.

ConstraintViolation — anatomie d'une violation

Chaque violation dans la liste est un objet ConstraintViolation avec ces propriétés :

GetterTypeDescription
getMessage()stringMessage traduit, prêt à afficher
getMessageTemplate()stringMessage brut avec placeholders ({{ value }})
getParameters()arrayValeurs des placeholders
getPropertyPath()stringChemin vers la propriété violée (address.city)
getInvalidValue()mixedLa valeur qui a échoué
getCode()string|nullCode d'erreur UUID (ex. NotBlank::IS_BLANK_ERROR)
getRoot()mixedL'objet racine validé
getConstraint()Constraint|nullLa contrainte qui a généré la violation
getCause()mixedCause externe (ex. exception sous-jacente)

ConstraintViolationList — la liste de résultats

ConstraintViolationList implémente Traversable + Countable + ArrayAccess. Tu peux l'itérer, la compter, et accéder à ses éléments par index :

foreach ($violations as $violation) {
    echo $violation->getPropertyPath() . ' : ' . $violation->getMessage() . PHP_EOL;
}

// Filtrer par code d'erreur
$blankErrors = $violations->findByCodes(NotBlank::IS_BLANK_ERROR);
💡 startContext() : pour valider plusieurs valeurs et accumuler les violations dans une seule liste, utilise $validator->startContext()->validate($a)->validate($b)->getViolations(). C'est plus efficace que de créer plusieurs listes et de les fusionner avec addAll().

Essaie le validateur ci-dessous avec différentes valeurs :

Remplis les champs et clique sur Valider pour voir les violations.

01 — BASIC ET STRING

Contraintes built-in — Basic et String

Symfony livre une cinquantaine de contraintes prêtes à l'emploi — connaître les options avancées fait la différence.

Symfony fournit une cinquantaine de contraintes dans le namespace Symfony\Component\Validator\Constraints. Connaître leurs options avancées évite d'écrire des contraintes custom inutiles.

NotBlank — normaliser avant de valider

#[Assert\NotBlank(
    allowNull: false,                 // null est-il autorisé ? (défaut false)
    normalizer: 'trim',               // callable appliqué avant validation
    message: 'Le champ ne peut être vide.'
)]
public string $name;
normalizer
Tout callable PHP : 'trim', 'strtolower', ou une closure. Appliqué à la valeur locale avant la contrainte — ne modifie pas l'objet. Utile pour éviter les espaces parasites sans transformer la donnée.

Length — compter les bons caractères

#[Assert\Length(
    min:       2,
    max:       255,
    exactly:   null,            // longueur exacte (remplace min+max)
    countUnit: 'graphemes',     // 'bytes' | 'codepoints' | 'graphemes'
)]
public string $username;

La différence entre les trois modes de comptage est visible sur les caractères accentués et les emojis :

Valeurbytescodepointsgraphemes
"café"544
"👨‍👩‍👧"2551
"résumé"866
🔑 À retenir : graphemes compte ce que l'utilisateur perçoit comme un caractère. C'est le bon choix pour les champs texte visibles. bytes est utile pour les contraintes de stockage (taille d'un champ VARCHAR(255) en UTF-8).

Email — choisir le bon mode

#[Assert\Email(
    mode:    'html5',     // 'html5' | 'strict' | 'html5-allow-no-tld'
    message: '{{ value }} n\'est pas un email valide.'
)]
public string $email;
ModeAccepteUsage
html5Regex HTML5 (permissive)Formulaires web standard (recommandé)
strictRFC 5321/5322 completVérification stricte — peut rejeter des emails valides en pratique
html5-allow-no-tldhtml5 sans TLD obligatoireIntranet, emails locaux (user@localhost)

Regex — exclure un pattern

// Valide seulement si le pattern NE correspond PAS (match: false)
#[Assert\Regex(
    pattern: '/[<>\'"]/',
    match:   false,
    message: 'Les caractères spéciaux HTML ne sont pas autorisés.'
)]
public string $bio;
match: false
Par défaut (match: true), la valeur est valide si elle correspond au pattern. Avec match: false, la valeur est valide si elle ne correspond pas — c'est une liste noire. Utile pour exclure des caractères dangereux.

Uuid — filtrer par version

#[Assert\Uuid(
    versions: [4, 7],      // n'accepte que les UUID v4 et v7
    strict:   true,        // format strict avec tirets (défaut true)
)]
public string $id;

Ip — filtrer les plages d'adresses

#[Assert\Ip(
    version: '4',          // '4' | '6' | 'all' (défaut '4')
    groups:  'no_priv',    // exclure les IP privées (10.x, 192.168.x…)
)]
public string $clientIp;
💡 Groupes IP disponibles : no_priv (exclut les plages privées RFC 1918), no_public (n'accepte que les IP privées/locales), no_res (exclut les plages réservées). Ces options s'ajoutent à la version : '4_no_priv', '6_no_priv', 'all_no_priv', etc.
02 — COMPARAISON & COLLECTION

Contraintes de comparaison et de collection

Range, Choice, Collection — les contraintes qui s'appuient sur des valeurs externes ou d'autres contraintes.

Ces contraintes comparent la valeur à des valeurs externes — une liste de choix autorisés, des bornes min/max, ou d'autres propriétés du même objet — ou appliquent des contraintes récursivement sur chaque élément d'une collection.

Range — bornes dynamiques avec PropertyPath

#[Assert\Range(
    min:             0,
    max:             100,
    minMessage:      'Doit être au minimum {{ limit }}.',
    maxMessage:      'Doit être au maximum {{ limit }}.',
)]
public int $score;

Les options minPropertyPath et maxPropertyPath permettent des bornes dynamiques tirées d'une autre propriété de l'objet — utile pour les plages de dates :

// La date de fin doit être entre startDate et endDate de l'objet parent
#[Assert\Range(
    minPropertyPath: 'startDate',
    maxPropertyPath: 'endDate',
)]
public \DateTimeImmutable $checkInDate;

Choice — liste statique ou dynamique

// Liste statique
#[Assert\Choice(
    choices:  ['red', 'green', 'blue'],
    multiple: false,          // true = tableau de valeurs accepté
    strict:   true,           // comparaison stricte (===)
)]
public string $color;

// Liste dynamique via callback
#[Assert\Choice(
    callback: [ColorRepository::class, 'getValidColors'],
    multiple: true,
    min:      1,              // au moins 1 choix sélectionné
    max:      3,              // au plus 3 choix
)]
public array $tags;
callback
Un callable statique (tableau [Classe::class, 'méthode'] ou chaîne de fonction). Appelé sans argument, doit retourner le tableau des choix valides. Utile quand les choix viennent d'une source dynamique (base de données, configuration).

Count — valider la taille d'une collection

#[Assert\Count(
    min:         1,
    max:         10,
    divisibleBy: 2,   // le nombre d'éléments doit être pair
)]
public array $items;

Collection — valider un tableau associatif

Collection est différente de Count — elle valide chaque clé du tableau avec ses propres contraintes :

#[Assert\Collection(
    fields: [
        'name'  => [new Assert\NotBlank(), new Assert\Length(max: 100)],
        'email' => new Assert\Email(),
        'age'   => new Assert\Optional([new Assert\Range(min: 18)]),
    ],
    allowExtraFields:   false,    // rejeter les clés non déclarées
    allowMissingFields: false,    // rejeter les clés manquantes
)]
public array $data;
🔑 Optional vs Required : dans Collection, toutes les clés sont requises par défaut. Enveloppe une contrainte dans Assert\Optional([...]) pour la rendre optionnelle, ou dans Assert\Required([...]) pour être explicite.

All — appliquer des contraintes sur chaque élément

// Valider chaque email dans un tableau
#[Assert\All([
    new Assert\NotBlank(),
    new Assert\Email(),
])]
public array $recipients;

Les violations générées par All ont un propertyPath de la forme recipients[0], recipients[2], etc. — l'index de l'élément invalide est toujours visible.

⚠️ All vs Valid : utilise All pour des tableaux de valeurs scalaires ou des tableaux d'objets quand tu veux appliquer des contraintes directement sur les éléments. Utilise #[Valid] pour cascader la validation vers des objets imbriqués (section suivante).
03 — GROUPES DE VALIDATION

Groupes de validation

Les groupes permettent de déclencher des sous-ensembles de contraintes selon le contexte — inscription vs mise à jour de profil.

Par défaut, toutes les contraintes d'un objet sont évaluées en même temps. Les groupes permettent de séparer les contraintes en sous-ensembles activables selon le contexte — formulaire d'inscription, mise à jour du profil, validation stricte pour l'API.

Déclarer un groupe sur une contrainte

use Symfony\Component\Validator\Constraints as Assert;

final class User
{
    #[Assert\NotBlank]                          // groupe Default
    public string $name;

    #[Assert\Email(groups: ['registration'])]  // groupe registration seulement
    public string $email;

    #[Assert\Length(min: 12, groups: ['strict'])]  // groupe strict seulement
    public string $password;
}

Pour déclencher un groupe spécifique :

// Valide uniquement les contraintes du groupe 'registration'
$violations = $validator->validate($user, null, 'registration');

// Valide plusieurs groupes à la fois
$violations = $validator->validate($user, null, ['Default', 'registration']);
DEFAULT_GROUP
Toute contrainte sans groups appartient au groupe 'Default' (Constraint::DEFAULT_GROUP). Ce groupe appartient lui-même implicitement au groupe portant le nom de la classe ('User' pour la classe User). Valider avec le groupe 'User' inclut donc les contraintes 'Default'.

GroupSequence — validation séquentielle

Avec GroupSequence, les groupes sont évalués dans l'ordre. Si une violation est trouvée dans le premier groupe, les groupes suivants ne sont pas évalués :

use Symfony\Component\Validator\Constraints\GroupSequence;

// Passer en troisième argument de validate()
$sequence = new GroupSequence(['Default', 'registration', 'strict']);
$violations = $validator->validate($user, null, $sequence);

On peut aussi placer la séquence directement sur la classe avec #[GroupSequence] — elle s'applique alors à chaque appel validate($user) sans avoir à passer la séquence explicitement :

#[Assert\GroupSequence(['Default', 'strict'])]
final class User { ... }
🔑 Cas d'usage typique : vérifier d'abord que les champs ne sont pas vides (Default), puis vérifier les règles métier (registration), puis les règles strictes (strict). Évite des messages d'erreur redondants ("email invalide" alors qu'il est vide).
⚠️ Piège : quand on utilise #[GroupSequence] sur une classe, la classe n'appartient plus au groupe 'Default' — elle appartient au groupe portant son nom (ex. 'User'). Pour référencer ses contraintes par défaut dans la séquence, on utilise le nom de la classe, pas 'Default'.

Coche les groupes ci-dessous et observe quelles contraintes s'activent :

Active des groupes pour voir quelles contraintes s'exécutent.

04 — CASCADING ET VALID

Cascading et #[Valid]

#[Assert\Valid] propage la validation vers les objets imbriqués — sans lui, les sous-objets ne sont jamais validés.

Quand un objet contient des sous-objets, le Validator ne les explore pas automatiquement. Sans #[Assert\Valid], les contraintes sur l'adresse d'un utilisateur sont silencieusement ignorées — l'objet passe comme valide.

#[Assert\Valid] — déclencher le cascading

final class User
{
    #[Assert\NotBlank]
    public string $name;

    #[Assert\Valid]             // cascade la validation vers Address
    public Address $address;
}

final class Address
{
    #[Assert\NotBlank]
    public string $city;

    #[Assert\Length(min: 5, max: 5)]
    public string $zipCode;
}

Sans #[Assert\Valid], valider $user ne vérifie jamais $user->address->city. Avec, les violations de Address apparaissent dans la liste avec un propertyPath de la forme address.city.

Cascading et collections avec traverse

final class Order
{
    #[Assert\Valid]                  // traverse automatiquement les tableaux
    public array $items;              // chaque OrderItem est validé

    #[Assert\Valid(traverse: false)]  // ne pas traverser
    public \ArrayObject $history;
}
traverse: true (défaut)
Quand Valid cible un tableau ou un Traversable, il itère sur chaque élément et cascades la validation. Le propertyPath prend la forme items[0].price — l'index est inclus.

Cascading et groupes

La contrainte Valid hérite des groupes de l'appel de validation parent. Valider $user avec le groupe 'strict' cascade 'strict' vers $user->address :

// Address::$zipCode est dans le groupe 'strict'
final class Address
{
    #[Assert\Length(min: 5, max: 5, groups: ['strict'])]
    public string $zipCode;
}

// Cette contrainte n'est évaluée QUE si on valide avec le groupe 'strict'
$violations = $validator->validate($user, null, 'strict');
// → inclut les violations de address.zipCode
🔑 PropertyPath dans les violations imbriquées : quand la violation vient d'un sous-objet, le propertyPath est le chemin complet depuis la racine : address.city, items[2].price, shipping.address.zipCode. Cela permet d'associer chaque violation au bon champ dans un formulaire imbriqué.
⚠️ Piège classique : oublier #[Assert\Valid] et se demander pourquoi l'objet imbriqué n'est jamais validé. Le Validator ne signale aucune erreur dans ce cas — il ignore simplement les sous-objets sans Valid.
05 — COMPOSITES & CONDITIONNELLES

Contraintes composites et conditionnelles

Quatre contraintes changent la logique de combinaison : Sequentially, AtLeastOneOf, Compound, et When.

Ces quatre contraintes modifient la logique de combinaison plutôt que de valider une valeur directement. Elles s'imbriquent dans d'autres contraintes ou changent comment les violations sont collectées.

Sequentially — fail-fast dans une liste

Exécute une liste de contraintes dans l'ordre. Dès qu'une violation est trouvée, les contraintes suivantes ne sont pas évaluées. Évite les messages redondants :

#[Assert\Sequentially([
    new Assert\NotBlank(),          // 1. vérifier que ce n'est pas vide
    new Assert\Length(min: 3),     // 2. vérifier la longueur (seulement si non vide)
    new Assert\Regex('/^[a-z]+$/'), // 3. vérifier le format (seulement si assez long)
])]
public string $username;
Sans Sequentially, si $username est vide, on obtient 3 violations en même temps (NotBlank + Length + Regex). Avec Sequentially, on n'obtient que la première — l'utilisateur corrige une étape à la fois.

AtLeastOneOf — au moins une vraie

Valide si au moins une des contraintes de la liste est satisfaite. Utile pour les champs qui acceptent plusieurs formats :

#[Assert\AtLeastOneOf([
    new Assert\Email(),
    new Assert\Regex('/^\+\d{10,}$/'),   // ou un numéro de téléphone
], message: 'Doit être un email ou un téléphone valide.',
   includeInternalMessages: false)]
public string $contact;
includeInternalMessages
Par défaut (true), le message d'erreur liste les raisons d'échec de chaque contrainte interne. Mettre à false pour n'afficher que le message de AtLeastOneOf lui-même — plus lisible pour l'utilisateur final.

Compound — contrainte réutilisable composite

Crée une contrainte nommée qui regroupe plusieurs contraintes. Réutilisable partout comme n'importe quelle contrainte built-in :

use Symfony\Component\Validator\Constraints\Compound;

final class StrongPassword extends Compound
{
    protected function getConstraints(array $options): array
    {
        return [
            new Assert\NotBlank(),
            new Assert\Length(min: 12),
            new Assert\Regex('/[A-Z]/'),
            new Assert\Regex('/[0-9]/'),
        ];
    }
}

// Utilisation identique à n'importe quelle contrainte
#[StrongPassword]
public string $password;

When — validation conditionnelle

Applique des contraintes seulement si une expression est vraie. L'option otherwise permet d'appliquer d'autres contraintes dans le cas contraire :

#[Assert\When(
    expression: 'this.isCompany',
    constraints: [new Assert\NotBlank()],
    otherwise:   [new Assert\IsNull()],
)]
public ?string $vatNumber;

Variables disponibles dans expression : this (l'objet), value (la valeur du champ), context (ExecutionContext). Accepte aussi une \Closure qui reçoit les mêmes arguments.

ContrainteRésultat si toutes échouentUsage typique
Sequentially Première violation seulement Enchaîner des règles progressives
AtLeastOneOf Une violation si aucune ne passe Champ acceptant plusieurs formats
Compound Toutes les violations Règle métier réutilisable nommée
When Aucune violation si expression fausse Champ conditionnel selon l'état de l'objet
06 — CALLBACK ET EXPRESSION

Callback et Expression

Pour la logique métier non couverte par les contraintes standard, Callback et Expression prennent le relais.

Quand aucune contrainte built-in ne couvre ton cas, deux contraintes te donnent accès à du code PHP arbitraire : Callback pour la logique en méthode de classe, et Expression pour les règles inline via l'ExpressionLanguage de Symfony.

#[Assert\Callback] — méthode statique sur la classe

use Symfony\Component\Validator\Context\ExecutionContextInterface;

final class Reservation
{
    public \DateTimeImmutable $startDate;
    public \DateTimeImmutable $endDate;

    #[Assert\Callback]
    public static function validate(
        mixed                      $object,  // l'instance de Reservation
        ExecutionContextInterface  $context,
        mixed                      $payload
    ): void {
        if ($object->endDate <= $object->startDate) {
            $context
                ->buildViolation('La date de fin doit être après la date de début.')
                ->atPath('endDate')
                ->addViolation();
        }
    }
}

#[Assert\Callback] — méthode d'instance

La méthode peut aussi être d'instance (non statique). Dans ce cas, l'objet est $this et la signature change :

#[Assert\Callback]
public function validateDates(
    ExecutionContextInterface $context,
    mixed                     $payload
): void {
    if ($this->endDate <= $this->startDate) {
        $context->buildViolation('Date de fin invalide.')->atPath('endDate')->addViolation();
    }
}

buildViolation() — construire une violation

$context
    ->buildViolation('La valeur {{ val }} est interdite.')
    ->setParameter('{{ val }}', $value)          // injecter des variables dans le message
    ->atPath('fieldName')                        // chemin relatif à l'objet courant
    ->setCode(MyConstraint::MY_ERROR)           // code UUID pour filtrage programmatique
    ->setInvalidValue($value)                    // valeur invalide (pour getInvalidValue())
    ->addViolation();                              // finaliser et ajouter à la liste

#[Assert\Expression] — règle inline

// Valide si l'expression retourne true
#[Assert\Expression(
    expression: 'this.endDate > this.startDate',
    message:    'La date de fin doit être après la date de début.',
)]
class Reservation { ... }

// negate: true — violé si l'expression est vraie (liste noire)
#[Assert\Expression(
    expression: 'value in bannedWords',
    negate:     true,
    values:     ['bannedWords' => ['spam', 'admin']],
    message:    'Ce mot est interdit.',
)]
public string $username;
Expression
  • Règle simple en une ligne
  • Inline dans l'attribut
  • Pas d'accès aux services
  • Syntaxe ExpressionLanguage
Callback
  • Logique complexe multi-lignes
  • Méthode dédiée, testable unitairement
  • Accès aux services via le contexte
  • PHP pur, pas de syntaxe spéciale
07 — CONTRAINTE PERSONNALISÉE

Créer une contrainte personnalisée

Quand aucune contrainte built-in ne convient, on en crée une — c'est plus simple qu'il n'y paraît.

Créer une contrainte custom revient à écrire deux classes : la contrainte (les métadonnées) et le validateur (la logique). Symfony se charge du câblage — pas de configuration YAML nécessaire.

Structure — la contrainte

use Symfony\Component\Validator\Constraint;

#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class FrenchPhoneNumber extends Constraint
{
    // Code d'erreur : UUID v4 aléatoire, unique
    public const string INVALID_FORMAT_ERROR = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';

    // Déclaration obligatoire pour que getErrorName() fonctionne
    protected const array ERROR_NAMES = [
        self::INVALID_FORMAT_ERROR => 'INVALID_FORMAT_ERROR',
    ];

    public function __construct(
        public string $message = 'Le numéro "{{ value }}" n\'est pas un numéro français valide.',
        ?array $groups = null,
        mixed $payload = null,
    ) {
        parent::__construct([], $groups, $payload);
    }
}

Structure — le validateur

use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;

final class FrenchPhoneNumberValidator extends ConstraintValidator
{
    public function validate(mixed $value, Constraint $constraint): void
    {
        // 1. Vérifier que la contrainte est du bon type
        if (!$constraint instanceof FrenchPhoneNumber) {
            throw new UnexpectedTypeException($constraint, FrenchPhoneNumber::class);
        }

        // 2. Null et vide = valides (NotBlank s'en charge)
        if (null === $value || '' === $value) {
            return;
        }

        // 3. Valider le format
        if (!preg_match('/^(\+33|0)[1-9](\d{8})$/', $value)) {
            $this->context
                ->buildViolation($constraint->message)
                ->setParameter('{{ value }}', $this->formatValue($value))
                ->setCode(FrenchPhoneNumber::INVALID_FORMAT_ERROR)
                ->addViolation();
        }
    }
}
formatValue()
Méthode héritée de ConstraintValidator. Elle formate la valeur pour l'affichage dans le message d'erreur : les chaînes sont entourées de guillemets, les tableaux tronqués, les objets représentés par leur classe. Toujours utiliser formatValue() plutôt que de caster directement en string.

Contrainte ciblant la classe entière

Pour valider plusieurs propriétés ensemble (ex. startDate < endDate), la contrainte cible CLASS_CONSTRAINT — le validateur reçoit l'objet complet :

#[\Attribute(\Attribute::TARGET_CLASS)]
final class ValidDateRange extends Constraint
{
    public function getTargets(): string
    {
        return self::CLASS_CONSTRAINT; // reçoit l'objet dans validate()
    }
}
🔑 Auto-découverte : Symfony découvre automatiquement FrenchPhoneNumberValidator car son nom est {ConstraintName}Validator. Aucune configuration ni tag nécessaire si l'autoconfiguration est activée (c'est le cas par défaut). La classe est taguée validator.constraint_validator automatiquement.
💡 IS_REPEATABLE : ajoute \Attribute::IS_REPEATABLE dans le #[\Attribute(...)] de la contrainte et déclare IS_REPEATABLE = true dans la classe pour que Symfony autorise plusieurs instances de la même contrainte sur le même élément.
08 — UNIQUEENTITY

UniqueEntity et validation Doctrine

UniqueEntity délègue la validation à Doctrine — c'est une contrainte au niveau de la base de données.

UniqueEntity est une contrainte de niveau CLASS_CONSTRAINT qui interroge Doctrine pour vérifier qu'aucune autre entité ne possède déjà la valeur donnée. Elle vit dans symfony/doctrine-bridge, pas dans symfony/validator.

Configuration de base

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

#[UniqueEntity(
    fields:   'email',
    message:  'Cet email est déjà utilisé.',
)]
class User { ... }

Contrainte composite sur plusieurs champs

#[UniqueEntity(
    fields:    ['username', 'tenant'],    // unique ensemble (paire)
    message:   'Ce nom est déjà pris dans ce tenant.',
    errorPath: 'username',               // violation rattachée à username
)]
class User { ... }
errorPath
Par défaut, la violation est rattachée au premier champ de fields. errorPath permet de la rediriger vers un autre champ — utile quand la violation doit apparaître sur un champ précis dans un formulaire Symfony.

identifierFieldNames — gérer les updates

Lors d'une mise à jour, l'entité garde sa propre valeur. Sans identifierFieldNames, UniqueEntity trouverait la même entité en base et lancerait une violation à tort :

#[UniqueEntity(
    fields:               'email',
    identifierFieldNames: ['id'],   // ignorer l'entité avec cet id
)]
class User
{
    #[ORM\Id]
    private string $id;

    private string $email;
}
Fonctionnement interne
Symfony appelle repository->findBy(['email' => $value]) (ou la méthode définie dans repositoryMethod). Si un résultat est trouvé ET qu'il n'a pas le même identifiant que l'entité en cours, une violation est créée.

Options avancées

OptionDéfautDescription
em null Nom de l'EntityManager (multi-em). null = EntityManager par défaut.
entityClass null Classe Doctrine cible si différente de la classe portant la contrainte (ex. DTO).
repositoryMethod 'findBy' Méthode custom du repository — utile pour les requêtes filtrées (tenant, soft-delete).
ignoreNull true Ignorer la contrainte si la valeur est null.
⚠️ repositoryMethod : la méthode custom reçoit le tableau ['field' => $value] en argument et doit retourner un tableau ou une collection d'entités (comme findBy). Utile pour un repository qui applique un filtre global (ex. tenant courant, entités non supprimées).
🔑 UniqueEntity vs contrainte de base de données : UniqueEntity ne remplace pas l'index UNIQUE en base — elle le complète au niveau applicatif pour afficher un message d'erreur lisible. Toujours garder les deux.
09 — SYNTHÈSE

Synthèse — le guide de choix

Le guide de choix des contraintes et les pièges du Validator.

Le Validator Symfony est riche — voici le guide de choix et les pièges à éviter pour l'utiliser efficacement.

Guide de choix

BesoinOutil
Valider un champ texte simple (vide, longueur) NotBlank + Length
Valider un format complexe (regex) Regex
Valider un email ou URL Email / Url
Valider par rapport à d'autres valeurs de l'objet Expression ou Callback
Valider un sous-objet imbriqué #[Valid]
Valider selon le contexte (inscription vs update) Groupes de validation
Plusieurs contraintes en séquence (fail-fast) Sequentially
Validation conditionnelle When
Champ acceptant plusieurs formats AtLeastOneOf
Contrainte réutilisable composée Compound
Logique métier non couverte Contrainte custom ou Callback
Unicité en base UniqueEntity

Anti-patterns à éviter

🚫 Valider sans vérifier le résultat.
$validator->validate($obj) ne lance aucune exception. Si tu ne vérifies pas count($violations) > 0, les violations sont silencieusement ignorées et des données invalides entrent dans ton système.
🚫 Oublier #[Assert\Valid] sur les sous-objets.
Sans #[Valid], les contraintes sur un objet imbriqué (Address, OrderItem) ne sont jamais évaluées — aucune erreur, aucun avertissement. L'objet passe comme valide même si ses sous-objets sont invalides.
🚫 Utiliser Callback pour des règles simples de comparaison.
Expression couvre la plupart des règles cross-field en une ligne ('this.endDate > this.startDate'). Réserve Callback à la logique complexe qui nécessite du PHP pur ou l'accès à des services.
🚫 Contrainte custom sans UnexpectedTypeException.
Sans vérifier le type de $constraint dans validate(), ton validateur peut silencieusement s'exécuter avec la mauvaise contrainte en cas de mauvais câblage. UnexpectedTypeException fait échouer proprement à la compilation.

Récapitulatif des contraintes avancées

ContrainteOptions clésÀ noter
NotBlank allowNull, normalizer normalizer appliqué avant validation
Length exactly, countUnit graphemes pour les emojis
Email mode: html5|strict html5 recommandé pour les formulaires
Regex match: false Inverser le test pour une liste noire
Range minPropertyPath, maxPropertyPath Bornes dynamiques depuis l'objet
Choice callback, multiple, min/max callback pour liste dynamique
Collection fields, allowExtraFields Valider un tableau associatif complet
Sequentially liste de contraintes Stoppe à la première violation
AtLeastOneOf includeInternalMessages Au moins une doit être vraie
When expression, otherwise Variables : this, value, context
UniqueEntity identifierFieldNames, repositoryMethod Nécessaire pour les updates