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 contrainteuniqueen 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] ?
#[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.