Skip to content

Forms — types, DTOs, events, uploads, theming

TL;DR — Le Form component = 3 couches : (1) FormType définit la structure (fields, options), (2) le data binding lie un objet métier ou DTO au form, (3) FormView est rendu via Twig avec un thème. FormEvents::PRE_SUBMIT (modify raw data), SUBMIT (after binding), POST_SUBMIT (after validation) sont les hooks pour la logique dynamique. Pour les APIs JSON, n'utilise PAS Form → préfère #[MapRequestPayload].

🧠 Mental model — ASCII diagram + analogie

   ┌─────────────────────────────────────────────────────────────┐
   │ Controller                                                   │
   │   $form = $this->createForm(UserType::class, $userDto);      │
   │   $form->handleRequest($request);                            │
   │                                                              │
   │   if ($form->isSubmitted() && $form->isValid()) {            │
   │       /** @var UserDto $data */                              │
   │       $data = $form->getData();                              │
   │       $service->process($data);                              │
   │   }                                                          │
   └─────────────────────────────────────────────────────────────┘

   Form lifecycle (handleRequest):
        Request POST data


        PRE_SUBMIT     ← FormEvent: raw scalar/array data (last chance to mutate)


        bind to data class via transformers


        SUBMIT         ← FormEvent: bound model data, before validation


        Validator runs (constraints on data class + form options)


        POST_SUBMIT    ← FormEvent: after validation, errors available


        $form->isValid() ? true / false

Analogie : Form = un traducteur diplomatique. HTTP parle "string scalars" (querystring), ton domaine parle "objects typés". Le Form intercepte, traduit (DataTransformer), valide, et te tend ton objet métier prêt à consommer. À chaque étape de la traduction, tu peux intervenir via events.

Les 4 espaces de données — le modèle mental qui débloque tout

90% des bugs Form viennent d'une confusion sur dans quel espace vit une donnée à un instant T. Le Form component gère quatre représentations de la même valeur, reliées par des transformers :

  view data            norm data              model data
  (string/array)       (intermédiaire)        (typé, ton domaine)
      │                     │                       │
      │  ViewTransformer    │  ModelTransformer     │
  "31/12/2024" ◄──────────► "2024-12-31" ◄────────► DateTimeImmutable
   (input HTML)              (canonique)             (data_class property)
EspaceTypeQui le produitExemple DateType
viewtoujours string/array (HTML ne connaît que ça)rendu Twig / $_POST"31/12/2024"
norm (normalized)format canonique pivotViewTransformer"2024-12-31"
modeltype métierModelTransformerDateTimeImmutable

Règle staff : un DataTransformer ne valide JAMAIS la logique métier — il ne fait que convertir. S'il ne peut pas convertir (input non parsable), il lève TransformationFailedException → le form devient !isSynchronized() (≠ invalide-par-contrainte). C'est pourquoi $form->isValid() couvre les deux : non-synchronisé et contraintes violées. Distinguer les deux dans les tests (isSynchronized() vs getErrors()) est un réflexe senior.

isValid() décomposé — ce que la méthode agrège réellement

php
$form->isValid()
//  ≡ $form->isSubmitted()
//    && $form->isSynchronized()          // tous les transformers ont réussi
//    && count($form->getErrors(true)) === 0  // aucune contrainte violée (deep=true)

Un piège classique : tester isSubmitted() && isValid() après handleRequest() sur une requête GET — isSubmitted() est false, le && court-circuite, OK. Mais si tu appelles $form->submit($data) manuellement, le form est toujours isSubmitted() === true même avec data partielle → tu dois passer $form->submit($data, clearMissing: false) pour un PATCH partiel, sinon les champs absents sont réinitialisés à empty_data.

Form vs alternatives — la décision en une table

BesoinOutilPourquoi
Page HTML, CSRF, theming, upload, collectionsForm componentÉcosystème complet, rendu Twig
API JSON, DTO direct, validation#[MapRequestPayload]0 boilerplate, pas de view layer inutile
API JSON + query string#[MapQueryString]Bind ?page=2&sort=name → DTO typé
Form HTML sans binding (filtres, recherche)Form data_class: nullRécupère un array via getData()
Form ultra-dynamique côté client (ajout lignes, dépendances)Symfony UX LiveComponentRe-render serveur sans écrire de JS, gère le state
Wizard multi-step statefulForm + Workflow + draft persistanceVoir scénario KYC plus bas

Heuristique staff : si la réponse n'a pas de HTML (pas de form_start, pas de CSRF visuel, pas de re-render avec erreurs inline), tu n'as probablement pas besoin du Form component. Le coût caché du Form sur une API JSON, c'est la couche view/transformers que tu paies sans la consommer.

🛠️ Code minimal — Form + DTO + events + collection

php
// src/Dto/RegistrationDto.php
<?php
namespace App\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class RegistrationDto
{
    #[Assert\NotBlank]
    #[Assert\Email]
    public ?string $email = null;

    #[Assert\NotBlank]
    #[Assert\Length(min: 8, max: 72)]
    public ?string $plainPassword = null;

    #[Assert\IsTrue(message: 'You must accept terms')]
    public bool $acceptTerms = false;

    /** @var Address[] */
    #[Assert\Valid]
    #[Assert\Count(min: 1, max: 5)]
    public array $addresses = [];
}
php
// src/Form/RegistrationType.php
<?php
namespace App\Form;

use App\Dto\RegistrationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class RegistrationType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('email', EmailType::class, [
                'label' => 'form.email',
                'attr' => ['autocomplete' => 'email'],
            ])
            ->add('plainPassword', RepeatedType::class, [
                'type' => PasswordType::class,
                'mapped' => true,
                'first_options' => ['label' => 'form.password'],
                'second_options' => ['label' => 'form.password_repeat'],
                'invalid_message' => 'form.password_mismatch',
            ])
            ->add('addresses', CollectionType::class, [
                'entry_type' => AddressType::class,
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
                'prototype' => true,
            ])
            ->add('acceptTerms', CheckboxType::class, [
                'label' => 'form.accept_terms',
            ])
            ->add('save', SubmitType::class);

        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event) {
            $data = $event->getData();
            // Normalize email lowercase before binding
            if (is_array($data) && isset($data['email'])) {
                $data['email'] = strtolower(trim((string) $data['email']));
                $event->setData($data);
            }
        });

        $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
            // Last-mile checks (ex: rate limit IP)
        });
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => RegistrationDto::class,
            'validation_groups' => ['Default', 'registration'],
            'csrf_protection' => true,
            'csrf_field_name' => '_token',
            'csrf_token_id' => 'registration',
        ]);
    }
}
php
// src/Controller/RegistrationController.php
#[Route('/register', name: 'register', methods: ['GET', 'POST'])]
public function register(Request $request): Response
{
    $form = $this->createForm(RegistrationType::class, new RegistrationDto());
    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        $dto = $form->getData();
        $this->userService->register($dto);
        $this->addFlash('success', 'registration.success');
        return $this->redirectToRoute('login');
    }

    return $this->render('auth/register.html.twig', ['form' => $form]);
}
twig
{# templates/auth/register.html.twig — Bootstrap 5 theme #}
{% form_theme form 'bootstrap_5_layout.html.twig' %}

{{ form_start(form, { attr: { novalidate: 'novalidate' } }) }}
    {{ form_row(form.email) }}
    {{ form_row(form.plainPassword.first) }}
    {{ form_row(form.plainPassword.second) }}

    <fieldset>
        <legend>{{ 'form.addresses'|trans }}</legend>
        <ul data-prototype="{{ form_widget(form.addresses.vars.prototype)|e('html_attr') }}">
            {% for address in form.addresses %}
                <li>{{ form_row(address) }}</li>
            {% endfor %}
        </ul>
    </fieldset>

    {{ form_row(form.acceptTerms) }}
    {{ form_row(form.save) }}
{{ form_end(form) }}

File upload field :

php
->add('avatar', FileType::class, [
    'label' => 'Avatar',
    'mapped' => false,
    'required' => false,
    'constraints' => [
        new File(
            maxSize: '2M',
            mimeTypes: ['image/png', 'image/jpeg'],
            mimeTypesMessage: 'Only PNG/JPEG',
        ),
    ],
])
php
// In controller
/** @var UploadedFile|null $file */
$file = $form->get('avatar')->getData();
if ($file) {
    $name = uniqid('av_', true).'.'.$file->guessExtension();
    $file->move($this->getParameter('uploads_dir'), $name);
    $dto->avatarPath = $name;
}

🔧 DataTransformer & DataMapper — le niveau en dessous

Quand un field a un type métier qui ne correspond pas 1:1 au type HTML, tu écris un transformer. Exemple canonique : un champ texte où l'utilisateur tape un slug d'Issue (PROJ-123) mais le DTO veut l'objet Issue.

php
// src/Form/DataTransformer/IssueToNumberTransformer.php
<?php
declare(strict_types=1);

namespace App\Form\DataTransformer;

use App\Entity\Issue;
use App\Repository\IssueRepository;
use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

/** @implements DataTransformerInterface<Issue, string> */
final readonly class IssueToNumberTransformer implements DataTransformerInterface
{
    public function __construct(private IssueRepository $issues) {}

    // model -> view : ce que Twig affiche dans l'input
    public function transform(mixed $value): string
    {
        return $value instanceof Issue ? $value->getNumber() : '';
    }

    // view -> model : ce qu'on récupère du POST
    public function reverseTransform(mixed $value): ?Issue
    {
        if (null === $value || '' === $value) {
            return null;
        }
        $issue = $this->issues->findOneByNumber($value);
        if (null === $issue) {
            // PAS une exception métier : c'est une *désynchronisation*
            throw new TransformationFailedException(sprintf('Issue "%s" introuvable.', $value));
        }
        return $issue;
    }
}
php
// Dans un FormType : attache le transformer au field
$builder->add('issue', TextType::class)
    ->get('issue')
    ->addModelTransformer($issueTransformer); // injecté via le constructeur du Type

addModelTransformer vs addViewTransformer : le model transformer agit entre norm↔model (proche du domaine), le view transformer entre view↔norm (proche du HTML). Pour 95% des cas tu veux addModelTransformer. Ordre d'exécution au submit : view → (viewTransformers reverse) → norm → (modelTransformers reverse) → model.

DataMapperInterface — quand tu veux contrôler comment le form lit/écrit l'objet entier (et non field-par-field). Indispensable pour un DTO immutable à constructeur (les properties readonly ne peuvent pas être posées par PropertyAccess) :

php
// src/Form/DataMapper/MoneyMapper.php — mappe 2 fields (amount, currency) vers un VO Money immutable
<?php
declare(strict_types=1);

namespace App\Form\DataMapper;

use App\Domain\Money;
use Symfony\Component\Form\DataMapperInterface;
use Symfony\Component\Form\FormInterface;

final class MoneyMapper implements DataMapperInterface
{
    /** @param \Traversable<string, FormInterface> $forms */
    public function mapDataToForms(mixed $viewData, \Traversable $forms): void
    {
        if (!$viewData instanceof Money) {
            return;
        }
        $forms = iterator_to_array($forms);
        $forms['amount']->setData($viewData->amountInCents / 100);
        $forms['currency']->setData($viewData->currency);
    }

    /** @param \Traversable<string, FormInterface> $forms */
    public function mapFormsToData(\Traversable $forms, mixed &$viewData): void
    {
        $forms = iterator_to_array($forms);
        // Construit un nouvel objet immutable — pas de mutation in-place
        $viewData = new Money(
            amountInCents: (int) round(((float) $forms['amount']->getData()) * 100),
            currency: (string) $forms['currency']->getData(),
        );
    }
}
php
// Branchement dans le FormType parent
$builder->setDataMapper(new MoneyMapper());

Réflexe staff : empty_data (closure) gère l'instanciation initiale d'un objet immutable, DataMapperInterface gère la lecture/écriture des sous-champs. Les deux ensemble = support complet des VO/DTO readonly sans setters.

🎯 Patterns courants

  1. DTO > Entity binding — toujours bind à un DTO (validation isolée, pas de side effect ORM, plus de contrôle). Mapper DTO → Entity dans un service.
  2. mapped: false — pour un field qui ne correspond pas à une propriété du data class (ex: confirm-password, captcha, upload temp). Récupéré via $form->get('foo')->getData().
  3. CollectionType + JS prototype — pour formulaires dynamiques (ajout/suppression de sub-forms). data-prototype rendu côté Twig, JS clone/remove.
  4. PRE_SET_DATA event — modifier la structure du form en fonction des données initiales (ex: champs supplémentaires si user est admin). Différent de PRE_SUBMIT (runtime data).
  5. Form theming par défautframework.form.themes: ['bootstrap_5_layout.html.twig'] ou theme custom (en Symfony 7, legacy_error_messages a été supprimé — le nouveau comportement est le seul). form_theme Twig pour scope local.
  6. empty_data — initialiser un DTO immutable (constructor non-default). Soit closure empty_data: fn(FormInterface $f) => new MyDto(...), soit class string.

🔄 Versions — Symfony 5.4 / 6.4 / 7.x

  • 5.4 : Form component mature. EnumType arrive officiellement (5.4), bindable à un BackedEnum PHP 8.1.
  • 6.0 : suppression de plusieurs deprecated form types (legacy date types, RadioType natif). inherit_data: true toujours OK pour fieldsets virtuels.
  • 6.1 : amélioration de l'auto-extension (ExtensionInterface auto-tagged), form_themes overrides nested mieux.
  • 6.2 : PasswordType ajoute hash_property_path pour hash auto via PasswordHasher.
  • 6.3 : meilleur support MoneyType avec Intl, NumberType peut être strict (html5: true retire les ,/. confusions).
  • 6.4 LTS : DivisorType pour scaling, amélioration validation messages localization. Form component reste un choix légitime pour les UI HTML traditionnelles ; pour API JSON pure, #[MapRequestPayload] préféré.
  • 7.0 : suppression des transformers legacy. Removal de framework.form.legacy_error_messages (true par défaut, maintenant le default est false).
  • 7.1+ : amélioration FormFactoryInterface::createNamed() pour des forms nested génériques.

⚠️ Pitfalls

  1. by_reference: true (default) + immutable DTO — Symfony Form essaie de modifier l'objet via setters/reflection. Avec readonly, ça throw. Solution : by_reference: false + DTO mutable, ou setters.
  2. CSRF token expired — par défaut, le token est lié à la session ID. Session expiry = form invalid. Ajuster framework.csrf_protection.session_tokens_max: 100 ou strategy.
  3. handleRequest() + JSON bodyhandleRequest lit $_POST (form-urlencoded). Pour JSON, il faut populer manuellement ($form->submit($jsonData)).
  4. CollectionType allow_delete mais pas by_reference: false — la collection est dé-attachée mais pas re-attachée → modifications ignorées. Toujours mettre by_reference: false avec collections mutables.
  5. empty_data mal configuré — DTO avec constructor args required → form throws au boot quand il essaie d'instancier sans args. Toujours fournir empty_data callable.
  6. Validation groups confusionvalidation_groups: ['Default', 'registration'] sur le form parent, mais sub-form a son propre Default → règles dupliquées ou manquantes. Préférer des groupes explicites partout.
  7. error_bubbling — par défaut false sur les fields scalars. Erreur sur sub-field n'apparaît pas au niveau parent. À configurer si tu fais un global error display.
  8. label_attr + label — passer label: false désactive le label mais label_attr est toujours appliqué (parfois visible via CSS). Le supprimer aussi si label off.

🏭 Production — sécurité, perf, observabilité

Sécurité (le Form est une surface d'attaque)

MenaceDéfense FormNote
CSRFcsrf_protection: true (default) + csrf_token_id unique par formStateless API ? désactive et signe autrement (JWT/SameSite)
Mass assignmentBind un DTO, jamais l'Entity directementUn attaquant ne peut poster que les fields déclarés dans le Type
Champs injectés (isAdmin=1)Seuls les fields add()és sont mappés ; extra fields → allow_extra_fields: false (default) lève une erreurVérifie que tu n'as pas mis allow_extra_fields: true "pour debug"
Upload malveillantAssert\File(mimeTypes: ...) valide le vrai MIME (via fileinfo), pas l'extension cliente+ ne sers jamais l'upload depuis un dir exécutable ; renomme (uniqid), stocke hors web-root ou sur S3
Path traversal sur filenameNe JAMAIS utiliser $file->getClientOriginalName() comme nom de stockageToujours un nom généré côté serveur
Open redirect après submitredirectToRoute() avec route nommée, jamais redirect($request->get('next')) brutWhiteliste les targets
Timing/enumeration (login)Même message d'erreur, même temps, quel que soit le champ fautifLe Form ne le fait pas pour toi

Le DTO-binding est la défense mass-assignment la plus importante. Avec une Entity bindée + by_reference: true, un champ oublié dans le Type mais présent dans le POST n'est pas mappé — mais le jour où quelqu'un ajoute ->add('role') pour un usage admin sans validation_groups, tu as une élévation de privilège. Un DTO sépare ce que le form accepte de ce que l'entité contient.

Performance

  • Coût du build : un FormType complexe (collections, country/timezone/currency types qui chargent l'Intl ICU) coûte du CPU à chaque requête. ChoiceType avec 40k choices (ex: toutes les communes) → matérialise 40k ChoiceView. Pour ces cas, autocomplete AJAX (Symfony UX Autocomplete / EntityType avec query_builder + choice_loader lazy) au lieu de charger tout.
  • EntityType N+1 : chaque option déclenche potentiellement un load. Utilise query_builder pour précharger, et choice_label sur une propriété déjà hydratée.
  • CollectionType + prototype : le prototype est rendu une fois (template), le clonage est côté JS → coût serveur constant quel que soit le nombre de lignes. C'est le bon pattern.
  • Validation : les groupes évitent de tout re-valider à chaque step. Sur un wizard, valider ['step3'] seulement, pas ['Default'] complet.

Observabilité

  • Le profiler (/_profiler, panneau Form) montre l'arbre complet : valeurs view/norm/model, transformers appliqués, erreurs par field, options résolues. C'est ton premier réflexe de debug, avant tout dump().
  • En prod, logge les échecs de synchronisation (!isSynchronized()) séparément des erreurs de validation : un pic de désynchro = soit une attaque (payload malformé), soit un bug de transformer après un déploiement.
  • Audit-log des FormEvents sur les forms sensibles (KYC, paiement, RGPD) : qui a soumis quoi, quand, depuis quelle IP. Branche un listener global sur POST_SUBMIT.

🧪 Testing

php
// tests/Form/RegistrationTypeTest.php
<?php
namespace App\Tests\Form;

use App\Dto\RegistrationDto;
use App\Form\RegistrationType;
use Symfony\Component\Form\Test\TypeTestCase;
use Symfony\Component\Form\Extension\Validator\ValidatorExtension;
use Symfony\Component\Validator\Validation;

final class RegistrationTypeTest extends TypeTestCase
{
    protected function getExtensions(): array
    {
        $validator = Validation::createValidatorBuilder()
            ->enableAttributeMapping()
            ->getValidator();
        return [new ValidatorExtension($validator)];
    }

    public function testSubmitValidData(): void
    {
        $formData = [
            'email' => '[email protected]',
            'plainPassword' => ['first' => 'secret123', 'second' => 'secret123'],
            'acceptTerms' => true,
            'addresses' => [],
        ];

        $form = $this->factory->create(RegistrationType::class);
        $form->submit($formData);

        self::assertTrue($form->isSynchronized());
        // Note: validation needs at least 1 address — this would fail validation
    }

    public function testPreSubmitNormalizesEmail(): void
    {
        $form = $this->factory->create(RegistrationType::class);
        $form->submit([
            'email' => '  [email protected]  ',
            'plainPassword' => ['first' => 'secret123', 'second' => 'secret123'],
            'acceptTerms' => true,
            'addresses' => [['street' => '1 rue X', 'city' => 'Paris']],
        ]);

        /** @var RegistrationDto $dto */
        $dto = $form->getData();
        self::assertSame('[email protected]', $dto->email);
    }
}

Functional test :

php
public function testRegistrationFlow(): void
{
    $client = static::createClient();
    $crawler = $client->request('GET', '/register');
    self::assertResponseIsSuccessful();

    $form = $crawler->selectButton('Save')->form([
        'registration[email]' => '[email protected]',
        'registration[plainPassword][first]' => 'secret123',
        'registration[plainPassword][second]' => 'secret123',
        'registration[acceptTerms]' => '1',
    ]);

    $client->submit($form);
    self::assertResponseRedirects('/login');
}

🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique : formulaire d'intake client avec pièces justificatives

Contexte : cabinet d'avocats en droit des affaires accepte de nouveaux clients via un portail web. Le formulaire d'intake demande : (1) identité (particulier ou entreprise), (2) nature du litige (énumération de 20+ cas), (3) timeline du litige (collection de dates), (4) upload de 1 à 10 pièces (PDF, scans), (5) signature électronique d'un accord d'honoraires. La saisie peut prendre 25 min, l'utilisateur doit pouvoir sauvegarder et revenir.

Le ClientIntakeType Symfony Form est mappé sur un ClientIntakeDto. Les champs conditionnels (form events PRE_SET_DATA et PRE_SUBMIT) affichent le sous-formulaire entreprise si "particulier" est décoché. La CollectionType pieces permet d'ajouter dynamiquement des FileType (avec allow_add, JS minimal) — validation Assert\File(maxSize: '20M', mimeTypes: ['application/pdf', 'image/jpeg', 'image/png']). Un FormEvents::PRE_SUBMIT injecte la date du jour si absente, gère les valeurs vides → null pour les champs optionnels.

Pour la persistance partielle, à chaque sub-submit le DTO est sérialisé en JSON et stocké en session ; à la reprise, hydration depuis session. Soumission finale → entité Client + IntakeDossier, fichiers uploadés sur S3 via VichUploaderBundle.

Scénario 2 — FinTech KYC : formulaire multi-step Stripe-like

Contexte : néobanque pro qui doit collecter le KYC légal de chaque nouvelle entreprise cliente. Process en 5 étapes : (1) identité représentant légal, (2) identité entreprise (SIRET, SIREN, code APE), (3) bénéficiaires effectifs (collection), (4) upload Kbis + pièce d'identité, (5) validation et signature.

Chaque étape = un FormType distinct (KycStep1Type, KycStep2Type, etc.), mappé sur le même DTO KycApplicationDto partiellement rempli, persisté en base entre étapes. Validation par groupes : groups=['step1'], ['step2']. Une transition WorkflowInterface (Symfony Workflow) verrouille la progression : impossible de passer à step4 sans avoir validé step3. Le form de l'étape 4 utilise DataMapperInterface custom pour gérer l'upload simultané de plusieurs fichiers vers un service KycDocumentStore.

Conformité : tous les events forms sont audit-loggés (qui a modifié quoi quand), les fichiers sont chiffrés au repos avec clé KMS par tenant.

Scénario 3 — RH (Welcome to the Jungle-like) : ATS — formulaire de candidature

Contexte : ATS (Applicant Tracking System) qui reçoit ~30 000 candidatures/mois. Formulaire public : CV (PDF), lettre de motivation (texte), réponses à 5 questions custom par offre (chaque entreprise définit ses questions), info perso, consentement RGPD explicite.

Le JobApplicationType lit la config des questions custom depuis l'entité JobOffer (passée en option) et génère dynamiquement les fields via FormBuilderInterface::add() en boucle. Pour le CV, un FileType avec validation mimeTypes + un service CvParser (extraction OCR async via Messenger) qui pré-remplit les champs profil. Le consentement RGPD = CheckboxType requis (Assert\IsTrue) avec lien vers politique de confidentialité.

Anti-spam : un honeypot field (hidden que les bots remplissent), un Turnstile Cloudflare, et un rate limiter Symfony à 5 candidatures/h/IP. Une fois soumise, l'application déclenche un workflow recruteur : un ApplicationReceived event part en Messenger → notify Slack RH + email candidat de confirmation.

🛠️ Exemple end-to-end

Use case : formulaire KYC étape "bénéficiaires effectifs" avec collection de personnes, upload de pièces, validation groupée, persistance partielle.

php
// src/Kyc/Dto/KycApplicationDto.php
<?php
declare(strict_types=1);

namespace App\Kyc\Dto;

use Symfony\Component\Validator\Constraints as Assert;

final class KycApplicationDto
{
    #[Assert\NotBlank(groups: ['step1'])]
    public ?string $companyLegalName = null;

    #[Assert\NotBlank(groups: ['step2'])]
    #[Assert\Length(exactly: 14, groups: ['step2'])]
    public ?string $siret = null;

    /** @var list<UltimateBeneficialOwnerDto> */
    #[Assert\Valid(groups: ['step3'])]
    #[Assert\Count(min: 1, max: 10, groups: ['step3'])]
    public array $ubos = [];

    /** @var array<string, ?\Symfony\Component\HttpFoundation\File\UploadedFile> */
    public array $documents = [];
}

final class UltimateBeneficialOwnerDto
{
    #[Assert\NotBlank(groups: ['step3'])]
    public ?string $firstName = null;

    #[Assert\NotBlank(groups: ['step3'])]
    public ?string $lastName = null;

    #[Assert\NotNull(groups: ['step3'])]
    public ?\DateTimeImmutable $birthDate = null;

    #[Assert\NotBlank(groups: ['step3'])]
    #[Assert\Range(min: 25, max: 100, groups: ['step3'])]
    public ?int $ownershipPercentage = null;

    #[Assert\Country(groups: ['step3'])]
    public ?string $nationality = null;
}
php
// src/Kyc/Form/UboType.php
<?php
declare(strict_types=1);

namespace App\Kyc\Form;

use App\Kyc\Dto\UltimateBeneficialOwnerDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CountryType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class UboType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('firstName', TextType::class, ['label' => 'Prénom'])
            ->add('lastName', TextType::class, ['label' => 'Nom'])
            ->add('birthDate', DateType::class, [
                'widget' => 'single_text',
                'input' => 'datetime_immutable',
                'label' => 'Date de naissance',
            ])
            ->add('ownershipPercentage', IntegerType::class, [
                'label' => '% de détention',
                'attr' => ['min' => 25, 'max' => 100],
            ])
            ->add('nationality', CountryType::class, ['label' => 'Nationalité']);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => UltimateBeneficialOwnerDto::class,
        ]);
    }
}
php
// src/Kyc/Form/KycStep3Type.php
<?php
declare(strict_types=1);

namespace App\Kyc\Form;

use App\Kyc\Dto\KycApplicationDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class KycStep3Type extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('ubos', CollectionType::class, [
                'entry_type' => UboType::class,
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
                'prototype' => true,
                'label' => 'Bénéficiaires effectifs (≥ 25%)',
            ])
            ->add('save', SubmitType::class, ['label' => 'Continuer'])
        ;

        $builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
            $data = $event->getData();
            $totalShare = array_sum(array_column($data['ubos'] ?? [], 'ownershipPercentage'));
            if ($totalShare > 100) {
                $event->getForm()->addError(new \Symfony\Component\Form\FormError(
                    'La somme des parts dépasse 100%',
                ));
            }
        });
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => KycApplicationDto::class,
            'validation_groups' => ['step3'],
        ]);
    }
}
php
// src/Controller/KycStep3Controller.php
<?php
declare(strict_types=1);

namespace App\Controller;

use App\Kyc\Form\KycStep3Type;
use App\Kyc\Persistence\KycDraftRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Workflow\WorkflowInterface;

#[IsGranted('ROLE_KYC_APPLICANT')]
final class KycStep3Controller extends AbstractController
{
    public function __construct(
        private readonly KycDraftRepository $drafts,
        private readonly WorkflowInterface $kycWorkflow,
    ) {}

    #[Route('/kyc/step3', name: 'kyc_step3', methods: ['GET', 'POST'])]
    public function __invoke(Request $request): Response
    {
        $draft = $this->drafts->findOrCreateForCurrentUser();
        if (!$this->kycWorkflow->can($draft, 'go_step3')) {
            return $this->redirectToRoute('kyc_step2');
        }

        $form = $this->createForm(KycStep3Type::class, $draft->getDto());
        $form->handleRequest($request);

        if ($form->isSubmitted() && $form->isValid()) {
            $this->drafts->save($draft);
            $this->kycWorkflow->apply($draft, 'go_step4');

            return $this->redirectToRoute('kyc_step4');
        }

        return $this->render('kyc/step3.html.twig', ['form' => $form]);
    }
}

L'utilisateur saisit ses UBOs, peut ajouter/supprimer des lignes (JS minimal via Symfony UX LiveComponent), la validation step3 ne bloque que ce qui concerne cette étape, et le passage à step4 transite seulement si workflow l'autorise. Reprise possible 7 jours après abandon via lien email.


🔁 Quand utiliser / éviter

  • Form component : pages HTML, formulaires de signup/edit profile, admin CRUD. Plein écosystème (CSRF, theming, file upload, collections).
  • #[MapRequestPayload] (pas Form) : APIs JSON. Plus simple, moins de code, intégration DTO directe.
  • CollectionType : sub-forms répétés (addresses, items). Évite pour > 50 items (UX lourde, préfère un sub-page CRUD).
  • mapped: false : champs metadata (captcha, conditions). Évite si tu peux mettre dans le DTO (plus propre).
  • FormEvents : logique conditionnelle (afficher un field selon une valeur). Évite pour validation pure → utilise Validator constraints + groups.

🏋️ Exercices

Progression : implémenter → production-grade → casser puis réparer. Crée un petit projet Symfony 7.x (symfony new --webapp) et fais tourner php bin/phpunit à chaque étape.

1. Form de filtre sans data_class (échauffement)

Objectif : construire un ProductFilterType non mappé (data_class: null, csrf_protection: false, method: GET) qui produit un array de filtres exploité par un repository. Indice/Solution : getData() rend l'array brut ; bind à un RequestStack/query string via handleRequest. Pas de DTO. Vérifie que ?category=foo&min_price=10 se mappe bien et que les champs vides ne polluent pas la query (PRE_SUBMIT qui unset les '').

2. Champ custom via DataTransformer

Objectif : un TagsType où l'utilisateur tape php, symfony, forms (texte) et le DTO reçoit un list<Tag> (entités, créées si absentes). Indice/Solution : addModelTransformer ; transform joint les tags par , ; reverseTransform split + findOrCreate par nom. Lève TransformationFailedException si un tag dépasse 50 car. Teste isSynchronized() sur input invalide.

3. DTO immutable + empty_data + DataMapper (production-grade)

Objectif : binder un final readonly class CheckoutDto (constructeur, propriétés readonly) sans aucun setter. Le form a amount + currency mappés vers un VO Money. Indice/Solution : empty_data: fn(FormInterface $f) => new CheckoutDto(...) pour l'instanciation, setDataMapper(new MoneyMapper()) (cf. section DataMapper) pour la lecture/écriture des sous-champs. Pas de by_reference. Test : soumets, vérifie que getData() est une nouvelle instance immutable.

4. Wizard multi-step stateful avec validation par groupes

Objectif : 3 étapes mappées sur un seul DTO persisté en draft (DB ou session), validation ['step1']/['step2']/['step3'], impossible de sauter une étape. Indice/Solution : un WorkflowInterface (Symfony Workflow) garde la progression (can($draft, 'go_step2')). Chaque step submit clearMissing: false pour ne pas écraser les champs des autres steps. Reprise par lien email = retrouver le draft par token.

5. CollectionType dynamique + invariant cross-field (production-grade)

Objectif : un BudgetType avec une CollectionType de lignes (label, amount), allow_add/allow_delete, et un invariant : la somme des amount doit égaler un total saisi ailleurs dans le form. Indice/Solution : by_reference: false obligatoire. Invariant cross-field → Assert\Callback sur le DTO ou un POST_SUBMIT qui addError sur le form racine. Côté JS : data-prototype + clonage. Bonus : remplace le JS par un Symfony UX LiveComponent.

6. Casser puis réparer — la collection fantôme

Objectif : reproduire le bug "les suppressions de la collection sont ignorées", puis le corriger et écrire le test de non-régression. Indice/Solution : pars d'une collection avec allow_delete: true mais sans by_reference: false et bindée à une Entity Doctrine avec addX()/removeX(). Les removeX() ne sont jamais appelés → orphelins en DB. Fix : by_reference: false (+ orphanRemoval: true côté mapping si tu veux la suppression physique). Test : crée 2 enfants, supprime-en 1 via submit, assert que la collection model a bien 1 élément et que removeX() a été appelé (spy/mock).

🎤 En entretien

Q : Pourquoi binder un DTO plutôt que l'Entity Doctrine directement dans un Form ? R : Isolation de la validation (groupes propres au use-case sans polluer l'entité), pas de side-effect ORM si la validation échoue (une Entity bindée + mappée reste modifiée en mémoire même si isValid() est faux → risque de flush accidentel), et surtout défense mass-assignment : le form n'accepte que ce que le Type déclare, séparé de ce que l'entité contient.

Q : Différence entre PRE_SUBMIT, SUBMIT et POST_SUBMIT ? R : PRE_SUBMIT = data brute (string/array du POST), dernière chance de la muter/ajouter des champs dynamiques avant binding. SUBMIT = data déjà transformée en model, avant validation, pour ajuster l'objet. POST_SUBMIT = après validation, erreurs disponibles, read-only sur la data — c'est là qu'on fait l'audit-log ou un invariant cross-field. Ordre du parent vers les enfants pour PRE_SUBMIT, des enfants vers le parent pour POST_SUBMIT.

Q : $form->isSynchronized() est false — que s'est-il passé, et est-ce la même chose qu'une erreur de validation ? R : Non, ce sont deux échecs distincts qu'isValid() agrège. !isSynchronized() signifie qu'un DataTransformer a levé TransformationFailedException (l'input ne se convertit pas dans le type cible — ex: "abc" dans un IntegerType, ou un slug inexistant dans un transformer custom). Une erreur de validation, elle, vient du Validator sur une data correctement transformée. Conséquence : un transformer ne doit porter que de la conversion, jamais une règle métier (ça, c'est une contrainte).

Q : Comment gérer un form sur une API où le client envoie du JSON ? R : Le Form component n'est pas le bon outil — handleRequest() lit le body form-urlencoded, pas le JSON. Soit on decode le JSON et on appelle $form->submit($data, clearMissing: false) manuellement (pour supporter le PATCH partiel), soit — bien mieux en Symfony 6.3+ — on abandonne le Form au profit de #[MapRequestPayload] qui désérialise + valide directement dans un DTO typé, sans la couche view/transformers qu'on ne consomme pas.

🔗 Liens

Bibliothèque tech perso — Achref