← Tous les cours / Symfony Form avancé Cours
00 — ARCHITECTURE

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.

FormInterface — la hiérarchie
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éthodeRôleAppelée quand
buildForm()Ajouter des enfants, attacher des events/transformersConstruction de l'arbre
configureOptions()Déclarer les options acceptées via OptionsResolverRésolution des options
buildView()Passer des variables au template TwigRendu du formulaire
finishView()Accéder aux vues des enfants après leur constructionPost-rendu
🔑 getBlockPrefix : auto-généré via 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

setData($data)
PRE_SET_DATA
transform → view
mapDataToForms
POST_SET_DATA

À 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

submit($request)
PRE_SUBMIT
sync children
SUBMIT + reverse-transform
POST_SUBMIT

À 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.

Sélectionne un flux ci-dessus
01 — TYPES BUILT-IN AVANCÉS

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

always_empty (défaut : true)
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

OptionTypeDescription
scaleintNombre de décimales (défaut : selon locale)
groupingboolSéparateurs de milliers (défaut : false)
rounding_modeintConstante \NumberFormatter::ROUND_*
html5boolRender en <input type="number">
currencystringMoneyType : code ISO 4217 (EUR, USD…)
divisorintMoneyType : 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.

widgetRendu HTML
choice3 selects jour/mois/année (défaut)
single_text<input type="date">
text3 inputs texte séparés
inputType PHP attendu
datetime\DateTime
datetime_immutable\DateTimeImmutable
stringchaîne format Y-m-d
timestampentier Unix
arraytableau ['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 vs ChoiceType
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',
]);
02 — OPTIONS DE CONFIGURATION

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(),
            ),
    ]);
}
💡 empty_data en Closure : la Closure reçoit le formulaire après soumission — elle peut lire les valeurs des champs enfants. Parfait pour les objets immutables (constructeurs sans setters).

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
Différence avec data_class
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

⚠️ Comportement par défaut : pour les types composés (CompoundType), 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.',
]);
🔑 À retenir : 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.
03 — COLLECTIONTYPE

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
]);
🔑 by_reference: false est obligatoire pour les collections Doctrine. Sans cela, Symfony modifie la collection par référence et le UnitOfWork ne détecte pas les changements.

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);
});
prototype_name
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 : accepte un callable — 'delete_empty' => fn($value) => empty($value->getPhone()) — pour définir précisément ce qu'est une entrée "vide" à ignorer.
04 — DATATRANSFORMER

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.

DataTransformerInterface
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 : modelnormalizedview.

MéthodePosition dans la chaîneCas d'usage
addViewTransformer()normalized → viewLe plus courant. Format d'affichage.
addModelTransformer()model → normalizedNormalisation interne (ex : tableau → objet).
🔑 Ordre d'application : plusieurs transformers peuvent être chaînés. En 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)
);
Exemple 2 — Prix en centimes
transform(14990)'149.90'
reverseTransform('149.90')14990
Si reverseTransform('abc')TransformationFailedException
05 — DATAMAPPER

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.

DataMapperInterface
mapDataToForms(mixed $viewData, \Traversable $forms): void
Appelé lors de setData() — distribue les données aux champs enfants.

mapFormsToData(\Traversable $forms, mixed &$viewData): void
Appelé 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

MapperUtilisé parComportement
DataMapperFormulaires avec data_classPropertyAccessor (getters/setters)
PropertyPathMapperChamps simplesChemin de propriété configuré
CheckboxListMapperChoiceType multiple expandedTableau de valeurs → checkboxes
RadioListMapperChoiceType single expandedValeur 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),
    );
}
💡 DataMapper vs DataTransformer : le Transformer opère sur un champ unique, le Mapper opère sur la relation entre le formulaire parent et l'ensemble de ses enfants.
06 — EVENTS DE FORMULAIRE

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énementDéclenchementModifier ?Usage typique
PRE_SET_DATAAvant que les données soient appliquéesOuiAjouter/supprimer des champs selon les données
POST_SET_DATAAprès application des donnéesNonLecture des données initialisées
PRE_SUBMITAvant synchronisation des enfantsOuiModifier données brutes, adapter structure
SUBMITAprès synchronisation enfants, avant parentOui (limité)Accès aux données synchronisées
POST_SUBMITAprès tous les enfants + reverse-transformNonEnvoyer email, log, effets de bord
⚠️ PostSubmitEvent::setData() lance une BadMethodCallExceptionPOST_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 :

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);
        }
    );
}
🔑 Pourquoi les deux ? 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.

En attente…
07 — FORMTYPEEXTENSION

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.

FormTypeExtensionInterface
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 vs FormTypeExtensionInterface : préfère 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'usageExtension
Attribut HTML sur tous les TextTypegetExtendedTypes(): yield TextType::class
Option globale sur tous les formulairesgetExtendedTypes(): yield FormType::class
Comportement CSRF personnaliségetExtendedTypes(): yield FormType::class
Icône d'aide sur tous les champsgetExtendedTypes(): yield FormType::class
08 — VALIDATION ET ERREURS

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']),
    ],
]);
🔑 constraints vs annotations : les contraintes dans le formulaire s'appliquent à la vue — elles ne nécessitent pas de classe de modèle. Les contraintes sur le modèle (attributs PHP 8) s'appliquent lors de la validation du modèle. Les deux coexistent.

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

ValeurComportement
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 : par défaut, les champs supplémentaires ne génèrent pas d'erreur. Avec l'option 'allow_extra_fields' => false sur le type racine, une erreur de validation est générée si des champs inconnus sont soumis.
09 — SYNTHÈSE

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

BesoinOutilPourquoi
Format affiché ≠ format stockéDataTransformerTraduit sur un champ unique
Objet sans getters/setters standardDataMapperContrôle total distribution/collecte
Champs conditionnels selon les donnéesEvents (PRE_SET_DATA + PRE_SUBMIT)Intervention aux deux flux
Action après soumission valideEvents (POST_SUBMIT)Déclenchement post-validation
Option/comportement sur tous les XFormTypeExtensionAugmente sans modifier
Liste de sous-formulaires modifiableCollectionType + prototype JSPattern standard Symfony
DTO immuable (constructeur)empty_data en ClosureSimple et suffisant

Anti-patterns

⚠️ Anti-pattern 1 — Logique métier dans le formulaire
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.
⚠️ Anti-pattern 2 — Formulaire conditionnel sans double listener
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.
⚠️ Anti-pattern 3 — CollectionType sans by_reference: false
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.
⚠️ Anti-pattern 4 — setData() dans POST_SUBMIT
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

TypeOptions clés
PasswordTypealways_empty, trim
NumberTypescale, grouping, rounding_mode, html5
MoneyTypecurrency, divisor, scale
DateTypewidget (choice/single_text/text), input (datetime/string/timestamp/array)
ChoiceTypechoice_label callable, choice_value, choice_attr, group_by, preferred_choices
EnumTypeclass (PHP 8.1 enum), choice_label
EntityTypeclass, query_builder, choice_label, choice_value, em
CollectionTypeentry_type, allow_add, allow_delete, prototype, keep_as_list, delete_empty
RepeatedTypetype, first_options, second_options
FileTypemultiple, data_class