
Symfony/Validator pour les règles fonctionnelles
Publié le 2025-04-08 par DarkChyper
Il est crucial de ne jamais se fier aux données provenant d'un utilisateur.
En tant que développeur web et en raison de la fréquence des interactions avec les formulaires et les API, la validation doit être une préoccupation constante dans nos développements. L'un des avantages d'un framework comme Symfony est la disponibilité d'outils pratiques, tels que symfony/validator, qui facilitent la validation des données provenant de sources externes.
Validator de Symfony
La génèse du module Validator remonte à la version 2.1 de Symfony (2012), où l'équipe de développement a décidé d'intégrer une solution de validation basée sur des standards reconnus. La spécification JSR-303 Bean Validation a été choisie pour sa robustesse et sa flexibilité. L'intégration de cette spécification dans Symfony a permis de créer un module de validation puissant et extensible, capable de répondre aux besoins variés des développeurs.
Le module permet de centraliser les règles de validation, ce qui facilite la maintenance et la réutilisabilité du code, il offre également une grande flexibilité en permettant de créer des contraintes personnalisées et de les appliquer à différents niveaux de l'application.
L'utilisation de ce module est particulièrement bien intégré au reste de l'écosystème Symfony, comme les formulaires et Doctrine, ce qui permet de bénéficier d'une cohérence de validation tout au long du cycle de vie des données.
https://github.com/symfony/validator
https://symfony.com/doc/current/validation.html
https://jcp.org/en/jsr/detail?id=303
Un exemple de validation simple sur une entité :
PHPuse Symfony\Component\Validator\Constraints as Assert; class User { #[Assert\NotBlank(message: "Le nom ne peut pas être vide.")] #[Assert\Length(min: 3, max: 50, minMessage: "Le nom doit contenir au moins {{ limit }} caractères.")] private string $name; [...] }
Un second exemple avec une contrainte personnalisée, la vérification d'un numéro de téléphone français. Nous avons besoins de 2 nouvelles classes :
PHPuse Symfony\Component\Validator\Constraint; #[\Attribute] class FrenchPhoneNumber extends Constraint { public string $message = "Le numéro de téléphone '{{ value }}' n'est pas un numéro français valide."; }
PHPuse Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; class FrenchPhoneNumberValidator extends ConstraintValidator { public function validate($value, Constraint $constraint) { if (null === $value || '' === $value) { return; } if (!preg_match('/^(0[67]\d{8}|\+33[67]\d{8})$/', $value)) { $this->context->buildViolation($constraint->message) ->setParameter('{{ value }}', $value) ->addViolation(); } } }
Validation que l'on peut ensuite utiliser sur une entité avec notre nouvel attribut.
PHPclass User { [...] #[FrenchPhoneNumber] private string $phone; [...] }
Dans Symfony, tout est un service. En partant de ce principe, il est possible d’injecter des dépendances directement dans une classe Validator pour effectuer des vérifications avancées via d’autres services ou repositories. Cependant, une fois le code écrit, la règle de validation restera fixe et s’appliquera de la même manière, quel que soit le contexte. Mais pourrait-on permettre aux utilisateurs de paramétrer eux-mêmes certaines contraintes tout en s’appuyant sur le Validator de Symfony ?
MusicBox
Imaginons MusicBox, une entreprise qui met à disposition des musiciens des salles de répétition, de mini-concert et d’enregistrement dans les grandes agglomérations. Ces espaces, situés à proximité de chez eux, offrent l’avantage d’être parfaitement insonorisés et équipés en fonction de l’abonnement choisi : micros, pianos, batteries, système de sonorisation ou d’enregistrement. Pour gérer son activité, MusicBox fournit à ses franchisés un logiciel en SaaS permettant d’administrer les salles, les abonnements, la facturation, les réservations et le matériel.
Bien que rattaché à la marque, chaque franchisé doit gérer son activité en tenant compte des contraintes spécifiques à ses locaux, des réglementations d’urbanisme locales et des besoins de ses abonnés. En se focalisant sur la réservation des salles, trois grandes catégories de règles se dégagent : le contrôle des délais, l’interdiction des enchaînements, et les limites périodiques. L'application de gestion doit intégrer ces règles tout en offrant aux gestionnaires la possibilité de les paramétrer finement afin d’adapter les conditions aux spécificités de chaque site.
Pour résumer, l’objectif est de lier des validateurs personnalisés, définis en dur dans le code, à des configurations spécifiques enregistrées en base de données. Nous allons mettre en place toute la mécanique nécessaire pour permettre l’ajout de nouveaux paramétrages simplement en intégrant un nouveau validateur dans le code.
Maintenant, on code
Je ne détaillerai pas la création d’un projet Symfony de base dans cet article. Je pars du principe que vous disposez déjà d’un projet fonctionnel avec une base de données et des entités. Tout le code présenté ici sera disponible dans un dépôt GitLab, accompagné d’une démo accessible pour tester les fonctionnalités.
https://gitlab.com/DarkChyper/music_box
Commençons par créer une interface et une classe abstraite qui serviront de base pour toutes les règles de validation des demandes de réservation de salle.
PHPnamespace App\Control\Booking; use App\Entity\BookingControl; use Symfony\Component\Form\FormBuilderInterface; interface BookingControlInterface { public function getLabel(): string; public function setBookingControl(EntityControlInterface $bookingControl): ?ControlInterface; public function getBookingControl(): ?EntityControlInterface; public function getFormFields(FormBuilderInterface $form): FormBuilderInterface; }
PHPnamespace App\Control\Booking; use App\Entity\BookingControl; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\Validator\Constraint; abstract class BookingControlAbstract extends Constraint implements BookingControlInterface { private EntityControlInterface $bookingControl; public function setBookingControl(EntityControlInterface $bookingControl): ?ControlInterface { $this->bookingControl = $bookingControl; return $this; } public function getBookingControl(): ?EntityControlInterface { return $this->bookingControl; } public function getFormFields(FormBuilderInterface $form): FormBuilderInterface { return $form; } }
Passer par l’abstraction pour la méthode getFormFields() permet de définir des champs communs à toutes les validations, sans nécessiter de données externes (repository ou services). Un autre avantage est de ne pas dépendre directement de l’interface, ce qui offre la possibilité d’avoir plusieurs abstractions définissant des formulaires différents.
L’utilisation du patron Bridge permet ici de séparer l’abstraction (BookingControlAbstract) de son implémentation réelle (BookingControl). Ainsi, il devient possible de modifier l’une sans impacter l’autre, ce qui améliore la flexibilité et la maintenabilité du code.
Toutes les classes qui étendront cette abstraction étendront également Symfony\Component\Validator\Constraint.
Il est également nécessaire de créer une interface dont dépendra les entités en base :
PHPnamespace App\Control; interface EntityControlInterface { public function getParameter(string $name): float|int|string|bool|null; }
Nous avons désormais besoin d’une classe capable de recenser les contraintes à partir des classes présentes dans notre projet. Cette classe jouera un rôle clé dans les deux contextes suivants :
- Côté administration : permettre la sélection d’une contrainte et la définition de ses paramètres spécifiques, qui seront ensuite enregistrés en base de données.
- Côté utilisateur : récupérer les contraintes configurées en base et les associer dynamiquement aux classes correspondantes dans le code. ceci se jouera au moment de la validation de la demande de réservation.
PHPnamespace App\Control\Booking; use App\Entity\BookingControl; class BookingControlCollection { public const REGEX_VALIDATOR = '/^.*Validator$/'; private static array $bookingControls; public function __construct(iterable $bookingControls) { if (self::$bookingControls === null) { self::$bookingControls = []; /** @var BookingControlInterface $bookingControl */ foreach ($bookingControls as $bookingControl) { $reflexion = new \ReflectionClass($bookingControl); if (!preg_match(self::REGEX_VALIDATOR, get_class($bookingControl))) { self::$bookingControls[$reflexion->getShortName()] = $bookingControl; } } } } public function getBookingControls(): array { return self::$bookingControls; } /** * @throws \Exception */ public function getBookingControlByClassName(string $bookingControlClassName): BookingControlInterface { if (isset(self::$bookingControls[$bookingControlClassName])) { return clone self::$bookingControls[$bookingControlClassName]; } throw new \Exception('Unable to find service tagging "musicbox.booking_control" named ' .$bookingControlClassName); } /** * @throws \Exception */ public function getBookingControlByBookingControlEntity(BookingControl $bookingControl): BookingControlInterface { if (isset(self::$bookingControls[$bookingControl->getControlClass()])) { return (clone self::$bookingControls[$bookingControl->getControlClass()])->setEventControl($bookingControl); } throw new \Exception('Unable to find service tagging "musicbox.booking_control" named ' .$bookingControl->getControlClass()); } }
Nous tirons parti de l'injection de dépendance de Symfony pour construire dynamiquement le tableau des contraintes disponibles, tout en utilisant le système de tags pour filtrer les éléments à injecter. Voici un exemple de configuration à ajouter dans le fichier config/service.yaml :
YAMLservices: # ici on vient indiquer quelles classes sont tagguées, # on récupère les 2 classes par règle, d'où le tri par regex dans la classe de collection App\Validator\Constraints\BookingControls\: resource: '../src/Validator/Constraints/BookingControls/*' tags: [ musicbox.booking_control ] # il est possible de tagguer les fichiers un à un. # L'avantage est de ne pas devoir filtrer dans la classe BookingControlModelCollection # ici on indique que le 1er argument doit être une collection de classes tagguées App\Control\Booking\BookingControlCollection: arguments: [ !tagged musicbox.booking_control ]
Pour pouvoir enregistrer les contrôles configurés par les administrateurs, nous avons besoin d'une entité qui implément l'interface EntityControlInterface.
PHPnamespace App\Entity; use Doctrine\ORM\Mapping as ORM; class BookingControl implements EntityControlInterface { #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $label = null; #[ORM\Column(length: 255)] private ?string $controlClass = null; #[ORM\ManyToOne] #[ORM\JoinColumn(nullable: false)] private ?Subscription $subscription = null; /** * @var array<string,int|string|bool|float> */ #[ORM\Column(type: Types::JSON)] private array $parameters = []; [...] public function getParameter(string $name): float|int|string|bool|null { if (isset($this->parameters[$name])) { return $this->parameters[$name]; } return null; } }
Et voici un exemple de formulaire permettant d'enregistrer les paramétrages spécifiques en se basant sur cette entité.
PHPclass BookingControlType extends AbstractType { public function __construct( private readonly BookingControlCollection $bookingControlCollection, ) { } /** * @throws \Exception */ public function buildForm(FormBuilderInterface $builder, array $options): void { /** @var BookingControl $bookingControl */ $bookingControl = $builder->getData(); $control = $this->bookingControlCollection->getBookingControlByBookingControlEntity($bookingControl); $builder ->add('label', TextType::class, [ 'label' => 'form.common.label', 'data' => $bookingControl->getLabel(), 'required' => true, ]) ->add('subscription', EntityType::class, [ 'label' => 'form.booking_control.label.subscription', 'class' => 'App\Entity\Subscription', 'choice_label' => 'label', 'choice_value' => 'id', 'multiple' => false, 'required' => true, ]) ->add('parameters', HiddenType::class, [ 'data' => json_encode($options['data']->getParameters()), ]); $builder ->get('parameters') ->addModelTransformer(new CallbackTransformer( function ($tagsAsArray): string { // transform the array to a string $jsonEncoded = json_encode($tagsAsArray); if (false === $jsonEncoded) { throw new \Exception('Error encoding json'); } return $jsonEncoded; }, function ($tagsAsString): array { return json_decode($tagsAsString, true); } )) ; // ici on appel les champs spécifiques de chaque contrainte $builder = $control->getFormFields($builder); $builder->add('save', SubmitType::class, [ 'label' => 'form.common.save', ]); } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => BookingControl::class, ]); }
Passons maintenant enfin à la création de nos contraintes. Par exemple la limite périodique.
PHPnamespace App\Validator\Constraints\BookingControls; [use ...] class BookingControlLimitPeriodic extends BookingControlAbstract { public const DEFAULT_HOURS = 1; public const LIMIT_PERIOD_WEEK = 0; public const LIMIT_PERIOD_MONTH = 1; public const FIELD_LIMIT_HOURS = 'limitHours'; public const FIELD_LIMIT_PERIOD = 'limitPeriod'; public function getLabel(): string { return 'constraint.control_periodic.label'; } public function getFormFields(FormBuilderInterface $form): FormBuilderInterface { $control = $this->getControl(); $form ->add('limitHours', IntegerType::class, [ 'label' => 'Nombre d\'heures de réservation', 'required' => true, 'data' => $control->getParameter('limitHours') ?? self::DEFAULT_HOURS, 'empty_data' => self::DEFAULT_HOURS, 'mapped' => false, 'constraints' => [ // a little meta-constraint moment :) new GreaterThan(['value' => 0]), ], ]) ->add(self::FIELD_LIMIT_PERIOD, ChoiceType::class, [ 'label' => 'Période', 'required' => true, 'data' => $control->getParameter('limitPeriod') ?? self::LIMIT_PERIOD_WEEK, 'empty_data' => self::LIMIT_PERIOD_WEEK, 'mapped' => false, 'choices' => [ 'Hebdomadaire' => self::LIMIT_PERIOD_WEEK, 'Mensuelle' => self::LIMIT_PERIOD_MONTH, ], ]); $form->addEventListener(FormEvents::PRE_SUBMIT, function ($event) { $data = $event->getData(); $parameters = [ 'limitHours' => $data['limitHours'], 'limitPeriod' => $data['limitPeriod'], ]; $data['parameters'] = json_encode($parameters); $event->setData($data); }); return $form; } }
PHPnamespace App\Validator\Constraints\BookingControls; class BookingControlLimitPeriodicValidator extends ConstraintValidator { [attributs] public function __construct( private readonly BookingRepository $bookingRepository, ) { } /** * @throws \DateMalformedStringException */ public function validate(mixed $value, Constraint $constraint): void { if (!$constraint instanceof BookingControlAbstract) { return; } /* @var Booking $booking */ $this->booking = $value; /** @var BookingControl $bookingControl */ $bookingControl = $constraint->getControl(); $this->hoursLimit = intval($bookingControl->getParameter(BookingControlLimitPeriodic::FIELD_LIMIT_HOURS)); $period = intval($bookingControl->getParameter(BookingControlLimitPeriodic::FIELD_LIMIT_PERIOD)); match ($period) { BookingControlLimitPeriodic::LIMIT_PERIOD_WEEK => $this->validateByWeek(), BookingControlLimitPeriodic::LIMIT_PERIOD_MONTH => $this->validateByMonth(), default => throw new \LogicException('La période de limitation est inconnue'), }; $this->validator(); } [la suite du code dans le repository] }
BookingControlLimitPeriodic contient la logique côté administration, notamment le libellé et les champs spécifiques à enregistrer. Elle hérite de notre abstraction BookingControlAbstract, elle-même basée sur la classe Constraint de Symfony.
La classe BookingControlLimitPeriodicValidator prend en charge la validation des données saisies dans le formulaire de réservation. Elle encapsule les règles métier à appliquer ainsi que les messages de retour destinés à l'utilisateur en cas d'erreur.
Comment déclencher la validation ?
Il existe plusieurs manières de déclencher la validation des réservations.
Une première approche consiste à créer une méthode validateBooking() dans un service dédié, appelée lors du traitement de la demande de réservation. Cette méthode pourrait récupérer dynamiquement la liste des contraintes depuis la base de données, puis faire appel au système de validation de Symfony pour les appliquer.
Une autre approche, celle que j’ai choisie dans mon exemple, consiste à créer une contrainte principale (BookingConstraint) de type CLASS_CONSTRAINT, appliquée directement sur l’entité de réservation. Dans ce cas, la méthode validate() de cette contrainte est responsable de charger dynamiquement les contraintes à appliquer, et de générer les messages d'erreur le cas échéant.
PHPnamespace App\Validator\Constraints; use Symfony\Component\Validator\Constraint; #[\Attribute] class BookingConstraint extends Constraint { public function validatedBy(): string { return get_class($this).'Validator'; } public function getTargets(): string { return self::CLASS_CONSTRAINT; } }
PHPnamespace App\Entity; use App\Repository\BookingRepository; use App\Validator\Constraints as AppAssert; use Doctrine\ORM\Mapping as ORM; #[ORM\Table(name: 'musicbox_booking')] #[ORM\Entity(repositoryClass: BookingRepository::class)] #[AppAssert\BookingConstraint] class Booking { [...] }
J’apprécie particulièrement cette méthode pour sa centralisation et son intégration directe dans le cycle de validation. Toutefois, elle présente une limite importante : il devient difficile de contourner la validation, par exemple dans des cas spécifiques où l’on souhaiterait enregistrer des réservations sans appliquer les contraintes métier.
Un cas concret en dehors de l'exemple de Music Box serait l’import de réservations depuis un système tiers, où les règles métier auraient déjà été validées en amont. Dans ce scénario, il peut être nécessaire d’enregistrer les données sans répéter la validation.
Pour répondre à ce besoin, une solution hybride est envisageable : ne pas appliquer la contrainte principale sur l’entité directement, mais la déclencher manuellement via un service de réservation dédié. Cela permet de garder le contrôle sur les cas où la validation doit être effectuée ou non.
Pour aller plus loin
Maintenant que tout le système est en place, l’ajout de nouvelles contraintes devient extrêmement simple : il suffit de créer deux classes dans le dossier des contraintes, et Symfony les détectera automatiquement. La nouvelle contrainte sera ainsi immédiatement disponible sans configuration supplémentaire.
Actuellement, notre entité BookingControl est liée aux abonnements, mais on pourrait facilement étendre le système pour lier un contrôle à une salle spécifique, ou à tout autre élément du domaine.
Il serait également possible d’introduire une gestion fine des droits utilisateurs. Par exemple, chaque contrainte pourrait définir les rôles ou privilèges nécessaires pour pouvoir être contournée. Ainsi, si un utilisateur tente de déposer une réservation qui dépasse son quota, celle-ci serait bloquée. En revanche, si un administrateur effectue la même réservation pour ce compte, il pourrait passer outre la contrainte grâce à ses droits élevés.
J’espère que cette plongée dans l’architecture des contrôles dynamiques vous aura donné des idées, que ce soit pour structurer vos propres règles métier ou pour mieux tirer parti de la puissance de Symfony.
N’hésitez pas à partager vos retours, vos questions ou même vos propres variantes de ce type de système. Dites moi également si ce genre d'article assez dense vous intéresse.
Codez bien !