Skip to Content

Chapitre 9.3 — La validation

Partie 9 : Formulaires — Chapitre 3/5 Version de référence : Symfony 7.4 (composant Validator).

⏱️ TL;DR

  • La validation se déclare par attributs #[Assert\...] sur les propriétés d’un DTO ou d’une entité : #[Assert\NotBlank], #[Assert\Email], #[Assert\Range(min: 1)]
  • Ces contraintes sont vérifiées automatiquement par les formulaires (isValid()) et par l’API (#[MapRequestPayload], API Platform). Une déclaration, deux portes protégées.
  • #[Assert\Valid] valide en cascade les sous-objets ; #[UniqueEntity] vérifie l’unicité en base (au niveau classe).
  • Les groupes de validation activent des règles selon le contexte (création vs édition).
  • Une contrainte personnalisée encapsule une règle métier (ex. « le créneau doit être disponible ») dans un validateur injectable.
  • Pour un dev Next.js : #[Assert\...] = votre schéma Zod, mais attaché à l’objet et partagé serveur/formulaire/API.

🎯 Objectifs

  • Déclarer des contraintes de validation par attributs.
  • Valider en cascade et garantir l’unicité en base.
  • Utiliser des groupes de validation.
  • Écrire une contrainte personnalisée pour une règle métier.

1. Les contraintes de base

On annote les propriétés de l’objet d’entrée (DTO recommandé, chapitre 9.2) :

use Symfony\Component\Validator\Constraints as Assert; final class CreateBookingInput { public function __construct( #[Assert\NotBlank(message: 'L’email est requis.')] #[Assert\Email(message: 'Email invalide.')] public string $customerEmail = '', #[Assert\NotNull] #[Assert\GreaterThan('now', message: 'La date doit être dans le futur.')] public ?\DateTimeImmutable $start = null, #[Assert\Positive] #[Assert\LessThanOrEqual(12)] public int $seats = 1, ) {} }

Contraintes fréquentes : NotBlank, NotNull, Email, Length (min/max), Range/GreaterThan/LessThan, Positive/PositiveOrZero, Choice, Regex, Type, Count (collections), Url, Ip, Json. Chaque contrainte accepte un message personnalisé.

Une fois ces attributs posés, rien d’autre à faire : un formulaire lié à ce DTO refusera une soumission invalide (isValid() renvoie false et les erreurs s’affichent), et l’API (#[MapRequestPayload]) renverra un 422 détaillé. Même validation, deux entrées.

2. Valider en cascade : #[Assert\Valid]

Quand un objet en contient d’autres (un DTO avec des sous-DTO, une entité avec une collection), #[Assert\Valid] descend valider les enfants :

final class CreateServiceInput { #[Assert\NotBlank] public string $name = ''; /** @var SlotInput[] */ #[Assert\Valid] // valide chaque SlotInput de la collection #[Assert\Count(min: 1, minMessage: 'Au moins un créneau.')] public array $slots = []; }

Sans #[Assert\Valid], les contraintes internes aux SlotInput ne seraient pas vérifiées.

3. Unicité en base : #[UniqueEntity]

Certaines règles nécessitent la base (« cet email n’est pas déjà pris »). La contrainte #[UniqueEntity] se pose au niveau classe de l’entité :

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; #[ORM\Entity] #[UniqueEntity(fields: ['email'], message: 'Cet email est déjà utilisé.')] class User { #[ORM\Column(unique: true)] #[Assert\Email] private string $email; }

#[UniqueEntity] fait une requête pour vérifier l’unicité avant l’insertion (message propre côté formulaire/API). On double toujours par une contrainte unique: true au niveau colonne (chapitre 6.1) : la contrainte applicative donne un beau message, la contrainte base garantit l’intégrité même en cas de concurrence.

⚠️ Piège concurrence : #[UniqueEntity] seul ne protège pas d’une course (deux requêtes simultanées passent la vérification puis insèrent). La contrainte unique en base (index unique) est le vrai garde-fou : elle lèvera une exception à l’insertion. On garde les deux : le message pour l’UX, l’index pour l’intégrité.

4. Groupes de validation

Parfois, les règles diffèrent selon le contexte (création vs édition, étape d’un tunnel). Les groupes activent des contraintes sélectivement :

#[Assert\NotBlank(groups: ['create'])] // requis seulement à la création public ?string $plainPassword = null;

Le formulaire/service précise le groupe à valider :

$form = $this->createForm(UserType::class, $input, ['validation_groups' => ['Default', 'create']]);

Pour de la validation conditionnelle (ex. valider un champ seulement si un autre a une certaine valeur), on utilise une GroupSequence ou la contrainte #[Assert\When] (expression).

5. Une contrainte personnalisée pour une règle métier

Les règles métier (pas juste « email valide ») méritent une contrainte personnalisée, dont le validateur est un service injectable (il peut donc interroger la base, un autre service…).

// src/Validator/SlotAvailable.php use Symfony\Component\Validator\Constraint; #[\Attribute] final class SlotAvailable extends Constraint { public string $message = 'Ce créneau n’est plus disponible.'; }
// src/Validator/SlotAvailableValidator.php use App\Service\SlotAvailabilityChecker; use Symfony\Component\Validator\{Constraint, ConstraintValidator}; final class SlotAvailableValidator extends ConstraintValidator { public function __construct(private readonly SlotAvailabilityChecker $checker) {} // injection ! public function validate(mixed $value, Constraint $constraint): void { if (!$value instanceof CreateBookingInput) { return; } if (!$this->checker->isAvailable($value->slotId, $value->seats)) { $this->context->buildViolation($constraint->message)->addViolation(); } } }

On l’applique au niveau classe du DTO :

#[SlotAvailable] final class CreateBookingInput { /* ... */ }

Désormais, formulaire et API refusent une réservation sur un créneau complet, avec un message clair — la règle métier vit une fois, dans un validateur testable.

💡 Pour un dev Next.js : une contrainte personnalisée avec validateur injectable, c’est un refine() Zod asynchrone qui pourrait interroger la base — mais réutilisable, testable et partagé entre le formulaire HTML et l’API. Vous centralisez « le créneau est-il disponible ? » à un seul endroit.

6. Valider manuellement

Hors formulaire/API, on peut valider un objet à la demande en injectant ValidatorInterface :

use Symfony\Component\Validator\Validator\ValidatorInterface; $violations = $validator->validate($input); if (count($violations) > 0) { // $violations : liste des erreurs (propriété, message) }

Utile dans un service, une commande, un import.

✏️ Exercices

Exercice 1. Ajoutez à un DTO RegisterInput les contraintes : email requis et valide, plainPassword requis avec au moins 8 caractères.

✅ Solution

final class RegisterInput { public function __construct( #[Assert\NotBlank] #[Assert\Email] public string $email = '', #[Assert\NotBlank] #[Assert\Length(min: 8, minMessage: 'Au moins {{ limit }} caractères.')] public string $plainPassword = '', ) {} }

Ces contraintes protègent et le formulaire d’inscription HTML et l’endpoint d’inscription de l’API.

Exercice 2. Pourquoi #[UniqueEntity] ne suffit-il pas, et que faut-il ajouter ?

✅ Solution

#[UniqueEntity] fait une vérification applicative (une requête) qui ne protège pas d’une course : deux requêtes simultanées peuvent passer la vérification puis insérer, créant un doublon. Il faut aussi une contrainte unique: true au niveau colonne (index unique en base), qui garantit l’intégrité même en concurrence (elle lèvera une exception). On combine les deux : message propre (applicatif) + intégrité (base).

Exercice 3. Vous devez interdire une réservation sur un créneau complet. Contrainte standard ou personnalisée ? Pourquoi, et quel avantage clé ?

✅ Solution

Une contrainte personnalisée (règle métier qui dépend de l’état de la base). Son validateur est un service injectable (il reçoit SlotAvailabilityChecker) et se pose sur le DTO. Avantage clé : la règle est centralisée, testable et partagée entre le formulaire HTML (back-office) et l’API (Next.js) — on ne la duplique pas.

🧠 Quiz de révision

1. Comment déclare-t-on la validation d’un champ ?

Par un attribut #[Assert\...] sur la propriété (ex. #[Assert\NotBlank], #[Assert\Email]), sur un DTO ou une entité.

2. Où ces contraintes sont-elles vérifiées ?

Automatiquement par les formulaires (isValid()) et par l’API (#[MapRequestPayload], API Platform) — une déclaration, deux portes.

3. À quoi sert #[Assert\Valid] ?

À valider en cascade les sous-objets (DTO imbriqués, collections) ; sans lui, leurs contraintes internes ne sont pas vérifiées.

4. Comment garantir l’unicité d’un email ?

#[UniqueEntity] (message applicatif) + unique: true sur la colonne (index unique, garde-fou d’intégrité contre les courses).

5. Comment implémenter une règle métier de validation ?

Par une contrainte personnalisée (Constraint + ConstraintValidator injectable), réutilisable et testable, partagée entre formulaire et API.


Prochain chapitre : 9.4 — Thématiser les formulaires — Bootstrap/Tailwind et personnalisation du rendu.