Architecture du Form Component
Un formulaire Symfony est une hiérarchie d'objets — chaque champ est lui-même un formulaire.
Le Form Component Symfony repose sur un principe simple mais puissant : chaque champ est lui-même un formulaire. Un formulaire racine contient des enfants, qui peuvent à leur tour contenir des enfants. Tout objet de cette hiérarchie implémente FormInterface.
Chaque nœud de l'arbre expose
add(), get(), has(), remove(), all() pour naviguer entre les enfants, et getParent(), getRoot(), isRoot() pour remonter vers la racine.
AbstractType — la brique de base
Chaque type de champ étend AbstractType et peut surcharger quatre méthodes :
| Méthode | Rôle | Appelée quand |
|---|---|---|
buildForm() | Ajouter des enfants, attacher des events/transformers | Construction de l'arbre |
configureOptions() | Déclarer les options acceptées via OptionsResolver | Résolution des options |
buildView() | Passer des variables au template Twig | Rendu du formulaire |
finishView() | Accéder aux vues des enfants après leur construction | Post-rendu |
StringUtil::fqcnToBlockPrefix(). ProductType → préfixe product. Il détermine les noms des blocs Twig utilisés pour le rendu.
FormFactory
// Créer un formulaire depuis un type $form = $factory->create(ProductType::class, $product); // Créer avec un nom explicite (pour les APIs sans <form>) $form = $factory->createNamed('product', ProductType::class, $product); // Obtenir le builder pour personnaliser avant construction $builder = $factory->createBuilder(ProductType::class, $product);
Les deux flux fondamentaux
Le Form Component opère selon deux flux distincts. Le flux setData prépare l'affichage, le flux submit traite la soumission.
Flux setData — de la DB vers la vue
À l'étape PRE_SET_DATA, il est encore possible d'ajouter ou de supprimer des champs — c'est là qu'on adapte le formulaire selon les données existantes. Après POST_SET_DATA, le formulaire est prêt pour l'affichage.
Flux submit — de la requête vers le modèle
À PRE_SUBMIT, les données sont encore brutes (chaînes de la requête HTTP) — on peut les modifier ou adapter la structure du formulaire. Après POST_SUBMIT, le modèle est reconstruit et isValid() peut être appelé.
Simulateur — Les deux flux
Clique sur un bouton pour visualiser les étapes séquentielles du flux correspondant.
Types built-in avancés
Chaque type built-in a des options qui le distinguent — connaître les options avancées évite de recréer des types existants.
Symfony livre une trentaine de types prêts à l'emploi. Chacun a des options spécifiques qui le distinguent — les connaître évite d'écrire un type custom inutile.
PasswordType
Le champ est rendu vide à chaque affichage même si une valeur existe — comportement attendu pour la sécurité. Mettre à
false pour pré-remplir (rarement souhaitable).
$builder->add('password', PasswordType::class, [ 'always_empty' => false, // pré-remplir si données existantes 'trim' => false, // ne pas supprimer les espaces (défaut) ]);
NumberType et MoneyType
| Option | Type | Description |
|---|---|---|
scale | int | Nombre de décimales (défaut : selon locale) |
grouping | bool | Séparateurs de milliers (défaut : false) |
rounding_mode | int | Constante \NumberFormatter::ROUND_* |
html5 | bool | Render en <input type="number"> |
currency | string | MoneyType : code ISO 4217 (EUR, USD…) |
divisor | int | MoneyType : divise/multiplie pour convertir centimes↔euros |
// Prix stocké en centimes, affiché en euros $builder->add('price', MoneyType::class, [ 'currency' => 'EUR', 'divisor' => 100, // stocké en centimes → affiché en euros 'scale' => 2, ]);
DateType
L'option widget contrôle le rendu HTML, l'option input contrôle le type PHP attendu en entrée/sortie.
| widget | Rendu HTML |
|---|---|
choice | 3 selects jour/mois/année (défaut) |
single_text | <input type="date"> |
text | 3 inputs texte séparés |
| input | Type PHP attendu |
|---|---|
datetime | \DateTime |
datetime_immutable | \DateTimeImmutable |
string | chaîne format Y-m-d |
timestamp | entier Unix |
array | tableau ['year'=>…, 'month'=>…, 'day'=>…] |
ChoiceType — options avancées
$builder->add('status', ChoiceType::class, [ 'choices' => ['Actif' => 'active', 'Inactif' => 'inactive'], 'expanded' => false, // false = <select>, true = radio/checkbox 'multiple' => false, // true = checkboxes ou <select multiple> 'placeholder' => 'Choisir…', 'choice_label' => fn($choice, $key, $value) => $key, 'choice_value' => fn($choice) => $choice->getId(), 'choice_attr' => fn($choice) => ['data-icon' => $choice->getIcon()], 'group_by' => fn($choice) => $choice->getCategory(), 'preferred_choices' => ['active'], ]);
EnumType (PHP 8.1+)
Depuis Symfony 6.1, EnumType sait gérer les enums PHP natifs directement :
$builder->add('level', EnumType::class, [ 'class' => CourseLevel::class, 'choice_label' => fn(CourseLevel $level) => $level->getLabel(), ]);
EntityType
EntityType étend ChoiceType. Il charge les entités Doctrine automatiquement. Option query_builder pour filtrer, choice_label pour le texte affiché, choice_value pour la valeur soumise (défaut : id).
$builder->add('category', EntityType::class, [ 'class' => Category::class, 'query_builder' => fn(CategoryRepository $repo) => $repo->createQueryBuilder('c') ->where('c.active = true') ->orderBy('c.name', 'ASC'), 'choice_label' => 'name', ]);
Options de configuration
Les options sont le langage de configuration des types — comprendre data_class, mapped, inherit_data et by_reference évite les bugs silencieux.
Les options sont déclarées dans configureOptions() via OptionsResolver. Certaines ont un impact fort sur le mapping des données — les mal comprendre génère des bugs silencieux.
data_class et empty_data
data_class lie le formulaire à une classe PHP. Sans elle, le formulaire retourne un tableau associatif.
public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults([ 'data_class' => Product::class, 'empty_data' => fn(FormInterface $form) => new Product( $form->get('name')->getData(), $form->get('price')->getData(), ), ]); }
mapped: false
Un champ avec mapped: false n'est pas lié au modèle. Sa valeur n'est ni lue depuis les données ni écrite dedans. Utile pour les champs d'interface (checkbox CGV, captcha).
$builder->add('acceptTerms', CheckboxType::class, [ 'mapped' => false, 'constraints' => [new IsTrue()], ]); // Récupérer la valeur manuellement : $accepted = $form->get('acceptTerms')->getData();
inherit_data: true
Un formulaire avec inherit_data: true n'a pas de mapping propre — il hérite les données du formulaire parent. Pratique pour factoriser des groupes de champs réutilisables (ex : champs adresse).
// AddressType.php public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefaults(['inherit_data' => true]); } // Dans le formulaire parent : $builder->add('shippingAddress', AddressType::class); // Les champs de AddressType lisent/écrivent directement dans $order
data_class mappe vers un sous-objet. inherit_data mappe vers le même objet que le parent. Avec inherit_data, il n'y a pas de sous-objet intermédiaire.
by_reference: false
Par défaut, si data_class est défini, Symfony modifie l'objet par référence (appel des setters sur l'objet existant). Avec by_reference: false, il remplace l'objet entier via le setter du champ parent.
$builder->add('tags', CollectionType::class, [ 'entry_type' => TagType::class, 'by_reference' => false, // appelle $product->setTags($newCollection) ]);
getter et setter (Symfony 6.3+)
Pour un champ dont le nom ne correspond pas à un accesseur standard, on peut fournir des callables :
$builder->add('fullName', TextType::class, [ 'getter' => fn(User $user, FormInterface $form) => $user->getFirstName() . ' ' . $user->getLastName(), 'setter' => fn(User &$user, $value, FormInterface $form) => [ $user->setFirstName(explode(' ', $value)[0]), $user->setLastName(explode(' ', $value)[1] ?? ''), ], ]);
error_bubbling
error_bubbling est false — les erreurs restent sur le champ. Pour les types simples non-compound, il est true — les erreurs remontent vers le parent. Surcharger explicitement pour contrôler le comportement.
invalid_message
Affiché quand un DataTransformer lance une TransformationFailedException. Remplace le message générique "Cette valeur n'est pas valide." par un message spécifique au champ.
$builder->add('birthDate', DateType::class, [ 'widget' => 'single_text', 'invalid_message' => 'Format attendu : AAAA-MM-JJ.', ]);
invalid_message n'est utilisée que quand la transformation échoue (valeur non convertible). Pour les violations de contrainte normale (NotBlank, Length…), c'est le message de la contrainte qui s'affiche.
CollectionType et formulaires imbriqués
CollectionType gère les listes dynamiques de sous-formulaires — prototype, allow_add et allow_delete permettent les listes modifiables côté JS.
CollectionType gère une liste de sous-formulaires du même type. Chaque élément de la liste est un formulaire enfant. C'est le mécanisme standard pour les relations one-to-many dans Symfony.
Configuration de base
$builder->add('phones', CollectionType::class, [ 'entry_type' => PhoneType::class, 'entry_options' => ['label' => false], 'allow_add' => true, 'allow_delete' => true, 'delete_empty' => true, // supprime les entrées vides à la soumission 'keep_as_list' => true, // re-indexe les clés après suppression 'by_reference' => false, // obligatoire pour les collections Doctrine ]);
Le prototype — ajout dynamique côté JS
Avec allow_add: true, Symfony génère un attribut data-prototype sur le conteneur. Ce prototype contient le HTML d'une nouvelle entrée avec __name__ comme placeholder d'index.
{# Twig #} <div id="phones-container" data-prototype="{{ form_widget(form.phones.vars.prototype)|e('html_attr') }}"> {% for phoneForm in form.phones %} {{ form_row(phoneForm) }} {% endfor %} </div> <button type="button" id="add-phone">Ajouter</button>
Pattern JS pour ajouter/supprimer
const container = document.getElementById('phones-container'); let index = container.children.length; // Ajouter une entrée document.getElementById('add-phone').addEventListener('click', () => { const prototype = container.dataset.prototype; const newEntry = prototype.replace(/__name__/g, index++); const div = document.createElement('div'); div.innerHTML = newEntry; // Bouton supprimer const del = document.createElement('button'); del.textContent = 'Supprimer'; del.addEventListener('click', () => container.removeChild(div)); div.appendChild(del); container.appendChild(div); });
Par défaut
__name__. Peut être changé via l'option prototype_name si le placeholder par défaut entre en conflit avec le HTML du sous-formulaire.
'delete_empty' => fn($value) => empty($value->getPhone()) — pour définir précisément ce qu'est une entrée "vide" à ignorer.
DataTransformer
Un DataTransformer traduit les données entre la couche modèle et la couche vue — nécessaire quand le format affiché diffère du format stocké.
Imagine un champ de prix : l'entité stocke 14990 (en centimes), mais l'utilisateur voit 149.90 et tape un nombre décimal. Quelque chose doit faire cette traduction dans les deux sens — c'est le rôle du DataTransformer.
Deux méthodes symétriques :
transform(mixed $value): mixed — model data → view data (affichage)reverseTransform(mixed $value): mixed — view data → model data (soumission)
Exemple 1 — Entité Tag vers chaîne
final class TagToStringTransformer implements DataTransformerInterface { public function __construct(private readonly TagRepository $tags) {} /** @param Tag|null $value */ public function transform(mixed $value): mixed { return $value?->getName() ?? ''; } /** @return Tag|null */ public function reverseTransform(mixed $value): mixed { if (empty($value)) { return null; } $tag = $this->tags->findOneByName($value); if (!$tag) { throw new TransformationFailedException( "Tag \"$value\" introuvable" ); } return $tag; } } // Dans buildForm : $builder->get('tag')->addViewTransformer(new TagToStringTransformer($this->tags));
addViewTransformer vs addModelTransformer
Il existe une chaîne de trois représentations : model → normalized → view.
| Méthode | Position dans la chaîne | Cas d'usage |
|---|---|---|
addViewTransformer() | normalized → view | Le plus courant. Format d'affichage. |
addModelTransformer() | model → normalized | Normalisation interne (ex : tableau → objet). |
transform() ils s'appliquent dans l'ordre d'ajout, en reverseTransform() dans l'ordre inverse.
TransformationFailedException
Si reverseTransform() ne peut pas convertir la valeur soumise, il doit lancer TransformationFailedException. Le formulaire passe alors en état isSynchronized() === false et l'invalid_message du champ est affichée à l'utilisateur.
throw new TransformationFailedException( 'Valeur invalide', 0, // code $previous // cause (utile pour le débogage) );
transform(14990) → '149.90'reverseTransform('149.90') → 14990Si
reverseTransform('abc') → TransformationFailedException
DataMapper
Le DataMapper contrôle comment les données du formulaire parent arrivent dans les champs enfants — et comment ils remontent.
Quand un formulaire possède un data_class, Symfony utilise un DataMapper pour distribuer les données du formulaire parent vers ses enfants, et les récupérer après soumission. Par défaut, c'est PropertyAccessor qui s'en charge.
mapDataToForms(mixed $viewData, \Traversable $forms): voidAppelé lors de
setData() — distribue les données aux champs enfants.mapFormsToData(\Traversable $forms, mixed &$viewData): voidAppelé lors de
submit() — reconstruit les données depuis les champs.
Cas d'usage — formulaire sur un tableau
Le DataMapper par défaut utilise PropertyAccess et nécessite des getters/setters. Pour un tableau associatif, il faut un mapper custom :
final class ArrayDataMapper implements DataMapperInterface { public function mapDataToForms(mixed $viewData, \Traversable $forms): void { foreach ($forms as $name => $form) { $form->setData($viewData[$name] ?? null); } } public function mapFormsToData(\Traversable $forms, mixed &$viewData): void { foreach ($forms as $name => $form) { $viewData[$name] = $form->getData(); } } }
// Dans buildForm() : public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->setDataMapper(new ArrayDataMapper()); $builder->add('name', TextType::class); $builder->add('email', EmailType::class); }
Mappers built-in
| Mapper | Utilisé par | Comportement |
|---|---|---|
DataMapper | Formulaires avec data_class | PropertyAccessor (getters/setters) |
PropertyPathMapper | Champs simples | Chemin de propriété configuré |
CheckboxListMapper | ChoiceType multiple expanded | Tableau de valeurs → checkboxes |
RadioListMapper | ChoiceType single expanded | Valeur unique → radio sélectionné |
Cas d'usage — construction complexe
Pour un DTO immuable (toutes les valeurs passées au constructeur), empty_data en Closure est souvent suffisant. Mais pour une logique plus élaborée (calculs, sous-objets), un DataMapper custom offre plus de contrôle :
public function mapFormsToData(\Traversable $forms, mixed &$viewData): void { $forms = iterator_to_array($forms); $viewData = new CreateProductCommand( name: $forms['name']->getData(), price: (int)($forms['price']->getData() * 100), ); }
Events de formulaire
Les événements de formulaire permettent de modifier les champs et les données dynamiquement — c'est le mécanisme des formulaires conditionnels.
Les événements sont le mécanisme qui permet aux formulaires d'être dynamiques : ajouter ou supprimer des champs selon les données existantes, modifier les données brutes avant leur traitement, ou déclencher une action après validation.
Les cinq événements
| Événement | Déclenchement | Modifier ? | Usage typique |
|---|---|---|---|
PRE_SET_DATA | Avant que les données soient appliquées | Oui | Ajouter/supprimer des champs selon les données |
POST_SET_DATA | Après application des données | Non | Lecture des données initialisées |
PRE_SUBMIT | Avant synchronisation des enfants | Oui | Modifier données brutes, adapter structure |
SUBMIT | Après synchronisation enfants, avant parent | Oui (limité) | Accès aux données synchronisées |
POST_SUBMIT | Après tous les enfants + reverse-transform | Non | Envoyer email, log, effets de bord |
BadMethodCallException — POST_SUBMIT est en lecture seule.
Cas d'usage — formulaire Pays → Régions
Le cas le plus courant : un champ "Région" dont les options dépendent du champ "Pays". Il faut intervenir à deux moments :
- PRE_SET_DATA : au chargement, adapter le champ Région selon le pays de l'entité
- PRE_SUBMIT : à la soumission, adapter le champ Région selon le pays soumis (données brutes)
public function buildForm(FormBuilderInterface $builder, array $options): void { $builder->add('country', ChoiceType::class, [ 'choices' => ['France' => 'FR', 'Allemagne' => 'DE', 'Espagne' => 'ES'], ]); // Closure qui ajoute le champ "region" selon le pays $addRegionField = function (FormInterface $form, ?string $country) { $regions = match ($country) { 'FR' => ['Île-de-France' => 'idf', 'Bretagne' => 'bzh', 'Occitanie' => 'occ'], 'DE' => ['Bavière' => 'by', 'Berlin' => 'be', 'Rhénanie' => 'nrw'], 'ES' => ['Catalogne' => 'cat', 'Madrid' => 'mad', 'Andalousie' => 'and'], default => [], }; $form->add('region', ChoiceType::class, ['choices' => $regions]); }; // Au chargement : PRE_SET_DATA reçoit l'entité $builder->addEventListener( FormEvents::PRE_SET_DATA, function (PreSetDataEvent $event) use ($addRegionField) { $data = $event->getData(); $country = $data?->getCountry(); $addRegionField($event->getForm(), $country); } ); // À la soumission : PRE_SUBMIT reçoit les données brutes (tableau) $builder->addEventListener( FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event) use ($addRegionField) { $data = $event->getData(); $country = $data['country'] ?? null; $addRegionField($event->getForm(), $country); } ); }
PRE_SET_DATA seul ne suffit pas : lors de la soumission, le formulaire repart de zéro avec les données brutes. Si on n'ajoute pas le champ region à PRE_SUBMIT, Symfony ne sait pas qu'il existe et ignore la valeur soumise.
Simulateur — Formulaire dynamique
Change le pays pour voir les régions se mettre à jour — comme le ferait un listener PRE_SET_DATA + PRE_SUBMIT.
FormTypeExtension
Une extension de type ajoute des options ou des comportements à tous les formulaires d'un type donné — sans modifier les types eux-mêmes.
Une FormTypeExtension augmente un type existant sans le modifier. Elle s'applique à toutes les instances du type ciblé dans l'application — c'est le pattern ouvert/fermé appliqué aux formulaires.
Quatre méthodes :
buildForm(), configureOptions(), buildView(), finishView() — les mêmes qu'AbstractType.Une méthode statique supplémentaire :
getExtendedTypes(): iterable — déclare les types à étendre.
Exemple 1 — Attribut data-* automatique sur tous les TextType
final class AutocompleteExtension extends AbstractTypeExtension { public static function getExtendedTypes(): iterable { yield TextType::class; } public function configureOptions(OptionsResolver $resolver): void { $resolver->setDefined('autocomplete_url'); $resolver->setAllowedTypes('autocomplete_url', ['string', 'null']); $resolver->setDefault('autocomplete_url', null); } public function buildView(FormView $view, FormInterface $form, array $options): void { if ($options['autocomplete_url'] !== null) { $view->vars['attr']['data-autocomplete'] = $options['autocomplete_url']; } } }
Désormais, tout TextType peut accepter l'option autocomplete_url :
$builder->add('city', TextType::class, [ 'autocomplete_url' => '/api/cities', ]);
Exemple 2 — Extension qui cible tous les types
public static function getExtendedTypes(): iterable { yield FormType::class; // s'applique à TOUS les types }
AbstractTypeExtension — elle implémente l'interface avec des méthodes vides, tu n'overrides que ce dont tu as besoin.
Enregistrement (Symfony autowire)
Avec autoconfigure: true dans les services, Symfony détecte automatiquement les extensions via l'interface. Aucune config supplémentaire n'est nécessaire.
| Cas d'usage | Extension |
|---|---|
| Attribut HTML sur tous les TextType | getExtendedTypes(): yield TextType::class |
| Option globale sur tous les formulaires | getExtendedTypes(): yield FormType::class |
| Comportement CSRF personnalisé | getExtendedTypes(): yield FormType::class |
| Icône d'aide sur tous les champs | getExtendedTypes(): yield FormType::class |
Validation et gestion des erreurs
La validation Symfony se branche sur le Form Component via l'option constraints — les erreurs suivent la hiérarchie des formulaires.
Le Form Component ne valide pas lui-même — il délègue au composant Validator via le DataProcessor intégré. La validation est déclenchée lors du POST_SUBMIT automatiquement si la configuration du formulaire l'active.
Déclencher la validation
// Dans le contrôleur $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { // données validées }
L'option constraints permet d'ajouter des contraintes de validation directement dans le formulaire :
$builder->add('email', EmailType::class, [ 'constraints' => [ new NotBlank(), new Email(['mode' => 'html5']), ], ]);
Lire les erreurs
// Erreurs de la racine seulement $errors = $form->getErrors(); // Toutes les erreurs (récursif) dans une liste plate $allErrors = $form->getErrors(deep: true, flatten: true); foreach ($allErrors as $error) { // $error est un FormError echo $error->getMessage(); echo $error->getOrigin()->getName(); // champ source }
error_bubbling
| Valeur | Comportement |
|---|---|
false (défaut compound) | L'erreur reste sur le champ concerné |
true (défaut non-compound) | L'erreur remonte vers le formulaire parent |
$builder->add('tags', CollectionType::class, [ 'error_bubbling' => false, // les erreurs sur les tags restent sur ce champ ]);
isSynchronized et TransformationFailedException
Quand un DataTransformer lance TransformationFailedException, le champ passe en état "non synchronisé". isValid() retourne false même si aucune contrainte Validator n'est violée.
if (!$form->get('date')->isSynchronized()) { $failure = $form->get('date')->getTransformationFailure(); // $failure est une TransformationFailedException }
getExtraData
Les champs soumis qui n'existent pas dans le formulaire ne sont ni traités ni signalés par défaut. getExtraData() retourne ces données supplémentaires :
$extra = $form->getExtraData(); // tableau des champs soumis non déclarés dans le formulaire
'allow_extra_fields' => false sur le type racine, une erreur de validation est générée si des champs inconnus sont soumis.
Synthèse
Le guide de choix des patterns avancés et les anti-patterns du Form Component.
Le Form Component Symfony est un système extensible à plusieurs couches. Maîtriser le bon outil pour chaque besoin évite la sur-ingénierie et les bugs silencieux.
Guide de choix
| Besoin | Outil | Pourquoi |
|---|---|---|
| Format affiché ≠ format stocké | DataTransformer | Traduit sur un champ unique |
| Objet sans getters/setters standard | DataMapper | Contrôle total distribution/collecte |
| Champs conditionnels selon les données | Events (PRE_SET_DATA + PRE_SUBMIT) | Intervention aux deux flux |
| Action après soumission valide | Events (POST_SUBMIT) | Déclenchement post-validation |
| Option/comportement sur tous les X | FormTypeExtension | Augmente sans modifier |
| Liste de sous-formulaires modifiable | CollectionType + prototype JS | Pattern standard Symfony |
| DTO immuable (constructeur) | empty_data en Closure | Simple et suffisant |
Anti-patterns
Un formulaire gère le format des données, pas les règles métier. La validation des règles métier appartient au domaine ou aux handlers, pas aux contraintes de formulaire.
Ajouter un champ conditionnel uniquement dans PRE_SET_DATA est insuffisant — à la soumission, le formulaire est reconstruit et le champ manquera. Il faut toujours le pair PRE_SET_DATA + PRE_SUBMIT.
Sans
by_reference: false, Symfony modifie la collection par référence. Le UnitOfWork Doctrine ne détecte pas les suppressions d'éléments — des données persistent silencieusement.
POST_SUBMIT est en lecture seule. Appeler setData() sur l'événement lance une BadMethodCallException. Pour modifier les données après soumission, utiliser SUBMIT (avec parcimonie).
Récapitulatif des types built-in avancés
| Type | Options clés |
|---|---|
PasswordType | always_empty, trim |
NumberType | scale, grouping, rounding_mode, html5 |
MoneyType | currency, divisor, scale |
DateType | widget (choice/single_text/text), input (datetime/string/timestamp/array) |
ChoiceType | choice_label callable, choice_value, choice_attr, group_by, preferred_choices |
EnumType | class (PHP 8.1 enum), choice_label |
EntityType | class, query_builder, choice_label, choice_value, em |
CollectionType | entry_type, allow_add, allow_delete, prototype, keep_as_list, delete_empty |
RepeatedType | type, first_options, second_options |
FileType | multiple, data_class |