Makina Blog
Repenser les formulaires Symfony : une approche moderne
Le composant Form de Symfony est le composant qu’on adore détester : il couvre 75% des besoins sans effort, mais pour les 25% restants, ça peut vite devenir un enfer !
Parmi les points de friction, on peut citer pêle-mêle :
- le theming
- l’utilisation des Data Transformers
- la création de FormType qui peut être lourde
- la création de widget/input complexe (qui implique presque tous les points précédents)
- la gestion du multistep (mais ça semble mieux depuis la 7.4)
Après un rapide appel à la communauté sur Mastodon, nous n’avons pas eu beaucoup de retours sur ce composant, mais personne n’a cherché à le défendre. Globalement, on comprend en lisant les commentaires que, comme on le disait en introduction, dès qu’on s’écarte un peu du cas nominal, ça devient compliqué.
Mais, on ne voudrait pas avoir l’air de cracher dans la soupe, le composant Form nous a rendu de bons et loyaux services pendant des années. Seulement, nous avions envie d’essayer autre chose. Notamment, nous ne voulons plus avoir d’éléments d’interface (libellés, textes d’aide, etc) définis dans les contrôleurs, nous aimerions les confiner aux templates Twig. Les contrôleurs ne devraient être responsables que de la logique back-end.
L’objectif étant de retrouver ce qu’on peut avoir avec une application à fort découplage back/front (type Symfony + Vue.Js par exemple) :
- le contrôleur se contente de la validation de données et des actions à réaliser en cas de succès,
- les templates se chargent intégralement de ce qui relève de l’apparence visuelle du formulaire.
Cela faisait quelque temps qu’on y songeait et l’arrivée des nouveaux MapQueryString et MapRequestPayload de Symfony nous a apporté ce qui nous manquait. Nous avons alors adapté leur logique pour gérer nos formulaires.
Avant-propos : Retrouvez un exemple complet mettant en œuvre notre approche sur notre GitHub : https://github.com/makinacorpus/poc-symfony-form-alternative/
L’attribut MapFormState
Nous avons donc imaginé un nouvel attribut MapFormState fonctionnant de la même manière que MapRequestPayload. Excepté que sur une erreur de validation, au lieu de retourner une réponse 422 contenant la liste des erreurs, le processus continue son chemin.
Le contrôleur reçoit alors un objet de type FormState contenant 2 propriétés :
- data : le DTO (Data Transfert Object) hydraté avec les données en provenance du formulaire,
- violationList : un objet contenant les éventuelles erreurs de validation (basé sur la ConstraintViolationList renvoyé par le composant Validator de Symfony).
Le contrôleur peut alors traiter les données si elles sont valides ou bien renvoyer le formulaire avec les erreurs de validation sinon.
#[Route('/my-form')]
public function myForm(
#[MapFormState(MyDto::class)] FormState $formState,
): Response {
if ($formState->isValid()) {
// Do your things here getting your submitted
// data with: $formState->data
}
return $this->render('my_form.html.twig', [
'values' => $formState->data,
'errors' => $formState->violationList,
]);
}
Le FormStateValueResolver
Le paramètre du contrôleur de type FormState est résolu par un Argument Resovler : le FormStateValueResolver. Celui-ci est largement inspiré du RequestPayloadValueResolver.
Son but est donc de détecter un éventuel objet de type FormState en paramètre d’un contrôleur et de l’hydrater.
Dans les grandes lignes, cela donne les étapes suivantes :
- On récupère les données de la requête depuis l’objet Request
- On dénormalise les données dans un DTO en utilisant le composant Serializer de Symfony
- On valide l’objet avec les contraintes renseignées directement sur le DTO avec le composant Validator
- On renvoie un objet de type FormState, constitué du DTO et de la liste d’erreurs de validation
Retrouvez le code complet de ce ValueResolver sur le dépôt GitHub.
Première utilisation
Concrètement, voici un premier exemple, imaginons un formulaire demandant :
- Un nom d’utilisateur : un string ne contenant que des caractères alphanumériques et des tirets
- Un âge : un entier de 7 à 77 ans
- Une adresse e-mail
- Un message : un texte brut
Le DTO :
readonly class MyDto {
public function __construct(
#[Assert\NotBlank(normalizer: 'trim')]
#[Assert\Regex('/^[a-z]+(?:-[a-z]+)*$/')]
public string $name,
#[Assert\Email]
public string $email,
#[Assert\GreaterThanOrEqual(7)]
#[Assert\LessThanOrEqual(77)]
public int $age,
public ?string $message = null,
) { }
}
Le contrôleur :
use App\FormState\FormState;
use App\FormState\MapFormState;
use App\Dto\MyDto;
// ...
class IndexController extends AbstractController
{
#[Route('/simple-form')]
public function simpleForm(
#[MapFormState(MyDto::class)] FormState $formState,
): Response {
if ($formState->isValid()) {
// Do your thing here !
$submitted = print_r($formState->data, true);
$this->addFlash('succes', "
Submission ok!
Submitted data:
$submitted
");
return $this->redirect('/');
}
return $this->render('simple_form.html.twig', [
'values' => $formState->data,
'errors' => $formState->violationList,
]);
}
}
Le template Twig :
{% extends './base.html.twig' %}
{% block title %}An alternative to the Symfony Form Component{% endblock %}
{% block body %}
<h1>An example of a simple form</h1>
<form method="POST">
<div>
<label for="name" style="{{ errors.name ? 'color: red;' : '' }}">Name</label>
<input
id="name"
name="name"
required
value="{{ values ? values.name }}"
/>
{% for error in errors.name %}
<p style="color: red">{{ error }}</p>
{% endfor %}
<div id="help-name">
<p>Only letters and '-' accpeted.</p>
</div>
</div>
<div>
<label for="email" style="{{ errors.email ? 'color: red;' : '' }}">Email</label>
<input
id="email"
name="email"
required
type="email"
value="{{ values ? values.email }}"
/>
{% for error in errors.email %}
<p style="color: red">{{ error }}</p>
{% endfor %}
</div>
<div>
<label for="age" style="{{ errors.age ? 'color: red;' : '' }}">Age</label>
<input
id="age"
name="age"
type="number"
value="{{ values ? values.age }}"
/>
{% for error in errors.age %}
<p style="color: red">{{ error }}</p>
{% endfor %}
</div>
<div>
<label for="message" style="{{ errors.message ? 'color: red;' : '' }}">Message</label>
<textarea
id="message"
name="message"
value="{{ values ? values.age }}"
></textarea>
{% for error in errors.message %}
<p style="color: red">{{ error }}</p>
{% endfor %}
</div>
<button type="submit">Submit</button>
</twig:Form>
{% endblock %}
Bon, vous êtes certainement en train de vous dire :
Attends mais c’est ça leur solution ? C’est quand même vachement laborieux d’écrire tous ces formulaires à la main dans les templates Twig !!
Et vous avez bien raison, d’autant plus que notre exemple n’est pas complet ! Il manque par exemple des éléments d’accessibilité (notamment les aria-describedby pour les messages d’aide et les erreurs).
Voyons comment améliorer tout ça en utilisant les Twig Components.
La puissance des composants Twig
L’arrivée des composants Twig, que l’on pourrait vulgairement considérer comme des macros Twig sous stéroïdes, a permis de fluidifier la réutilisation de code Twig, ouvrant la voie à de nombreuses possibilités.
La construction de notre interface graphique, par composition, se rapproche dans une certaine mesure de ce qu’on pourrait faire avec des outils comme Vue.js.
Dans cette logique, nous avons créé des composants pour nos éléments de formulaire :
- Un composant Form pour gérer l’enveloppe du formulaire ainsi que le jeton CSRF
- Un composant Input pour gérer les éléments de formulaire classiques (type text, number, email)
- Un composant TextArea
- Un InputPassword
- etc.
Ces composants permettent de gérer :
- Le libellé du champ
- La valeur
- Les erreurs
- Un texte d’aide
- L’accessibilité
- etc.
À partir de là, on peut constituer notre formulaire avec nos composants, agrémentant notre bibliothèque au fur et à mesure que l’on rencontre de nouveaux besoins.
Un exemple complet
Voici à quoi ressemblerait maintenant le template Twig pour l’exemple précédent :
{% extends './base.html.twig' %}
{% block title %}An alternative to the Symfony Form Component{% endblock %}
{% block body %}
<h1>An example of a simple form</h1>
<twig:Form method="POST">
<twig:Input
name="name"
label="Name"
required
help="Only letters and '-' accepted."
:value="values ? values.name"
:errors="errors.name"
/>
<twig:Input
name="email"
label="Email"
required
type="email"
:value="values ? values.email"
:errors="errors.email"
/>
<twig:Input
name="age"
label="Age"
type="number"
:value="values ? values.age"
:errors="errors.age"
/>
<twig:TextArea
name="message"
label="Message"
:value="values ? values.age"
:errors="errors.message"
/>
<button type="submit">Submit</button>
</twig:Form>
{% endblock %}
C’est mieux, n’est-ce pas ?
Réutilisabilité
Grâce à la souplesse des composants HttpKernel et Serializer, on peut facilement créer des morceaux de formulaire réutilisables.
Prenons le cas d’un macro-champ représentant une adresse. On souhaite pouvoir réutiliser ce type de données pour plusieurs formulaires de notre application.
Pour ce faire, on crée le DTO associé aux données de notre adresse :
readonly class AddressDto {
public function __construct(
public string $street,
public string $postalCode,
public string $locality,
public string $country,
) { }
}
Le composant Twig associé, comme suit :
<fieldset id="{{ id }}">
<legend>{{ label }}</legend>
<twig:Input
:name="name ~ '[street]'"
label="Street"
required
:value="value ? value.street"
/>
<twig:Input
:name="name ~ '[postalCode]'"
label="Postal code"
required
:value="value ? value.postalCode"
/>
<twig:Input
:name="name ~ '[locality]'"
label="Locality"
required
:value="value ? value.locality"
/>
<twig:Input
:name="name ~ '[country]'"
label="Country"
required
:value="value ? value.country"
/>
</fieldset>
On peut ensuite l’utiliser dans nos formulaires :
Le DTO :
readonly class CompositeFormDto {
public function __construct(
#[Assert\NotBlank(normalizer: 'trim')]
#[Assert\Regex('/^[a-z]+(?:-[a-z]+)*$/')]
public string $name,
#[Assert\Email]
public string $email,
public AddressDto $address,
) { }
}
Et le template Twig associé :
{% extends './base.html.twig' %}
{% block title %}An alternative to the Symfony Form Component{% endblock %}
{% block body %}
<h1>An example of composite form</h1>
<twig:Form method="POST">
<twig:Input
name="email"
label="Email"
required
type="email"
:value="values ? values.email"
:errors="errors.email"
/>
<twig:AddressInput
name="address"
label="Address"
:value="values ? values.address"
:errors="errors.address"
/>
<button type="submit">Submit</button>
</twig:Form>
{% endblock %}
Et voilà, il n’y a rien de plus à faire !
À noter : cette technique fonctionne également avec les attributs MapQueryString et MapRequestPayload.
Conclusion
À la vue des exemples de cet article, on pourrait se dire que notre approche apporte beaucoup de verbosité : cela semble cher payé. C’est vrai, pour des formulaires simples, les avantages ne sont pas immédiats.
Cette approche est (peut-être) un peu plus complexe à prendre en main et à mettre en œuvre.
Mais c’est lorsque l’on arrive sur des formulaires plus complexes que l’on se rend compte avec quelle simplicité on gère des cas tordus. C’est au moment de développer un nouvel input spécifique qu’on apprécie le découplage fort entre le front-end et le back-end.
Si vous êtes intéressé·e·s par notre approche, n’hésitez pas à venir en discuter avec nous sur l’espace Discussions du dépôt GitHub.
En résumé, les avantages de cette manière de gérer les formulaires sont :
- Découplage fort entre les logiques back-end et front-end
- Gestion de la validation via des DTOs à la manière d’une application découplée (comme MapQueryString et MapRequestPayload)
- Possibilité de personnaliser facilement le rendu front via sa bibliothèque de composants de formulaire
- Gestion simplifiée des composants complexes
Et ses limitations :
- Peu ou pas adaptée à de la génération de formulaire (formulaires dynamiques créés programmatiquement)
- Approche un peu plus verbeuse (les templates Twig sont plus chargés qu’avec le composant Form, mais les contrôleurs sont soulagés !)
- Le MapFormStatereste à peaufiner pour le rendre complètement générique (notamment pour la gestion des fichiers)
Formations associées
Formation Symfony
Formation Symfony Initiation
Nantes - Toulouse - Paris ou distanciel A la demande
Voir la Formation Symfony InitiationActualités en lien
Geotrek et OpenStreetMap : Mise en place d’une passerelle pour une connaissance du territoire enrichie
Logiciel libre
08/09/2025
Installer Geotrek : avec ou sans segmentation dynamique ?
Logiciel libre
08/09/2025
Geotrek-Mobile évolue avec les contenus outdoor
Application Web & Mobile
09/07/2025