Makina Blog

Le blog Makina-corpus

Repen­ser les formu­laires Symfony : une approche moderne


En s’ins­pi­rant des nouveaux MapQue­ryS­tring et MapRequest­Pay­load de Symfony et en s’ai­dant de compo­sants Twig, nous propo­sons une nouvelle manière de faire des formu­laires dans un projet Symfony.
Sommaire

Le compo­sant Form de Symfony est le compo­sant qu’on adore détes­ter : il couvre 75% des besoins sans effort, mais pour les 25% restants, ça peut vite deve­nir un enfer !

Parmi les points de fric­tion, on peut citer pêle-mêle :

  • le theming
  • l’uti­li­sa­tion des Data Trans­for­mers
  • la créa­tion de Form­Type qui peut être lourde
  • la créa­tion de widget/input complexe (qui implique presque tous les points précé­dents)
  • la gestion du multis­tep (mais ça semble mieux depuis la 7.4)

Après un rapide appel à la commu­nauté sur Masto­don, nous n’avons pas eu beau­coup de retours sur ce compo­sant, mais personne n’a cher­ché à le défendre. Globa­le­ment, on comprend en lisant les commen­taires que, comme on le disait en intro­duc­tion, dès qu’on s’écarte un peu du cas nomi­nal, ça devient compliqué.

Mais, on ne voudrait pas avoir l’air de cracher dans la soupe, le compo­sant Form nous a rendu de bons et loyaux services pendant des années. Seule­ment, nous avions envie d’es­sayer autre chose. Notam­ment, nous ne voulons plus avoir d’élé­ments d’in­ter­face (libel­lés, textes d’aide, etc) défi­nis dans les contrô­leurs, nous aime­rions les confi­ner aux templates Twig. Les contrô­leurs ne devraient être respon­sables que de la logique back-end.

L’objec­tif étant de retrou­ver ce qu’on peut avoir avec une appli­ca­tion à fort décou­plage back/front (type Symfony + Vue.Js par exemple) :

  • le contrô­leur se contente de la vali­da­tion de données et des actions à réali­ser en cas de succès,
  • les templates se chargent inté­gra­le­ment de ce qui relève de l’ap­pa­rence visuelle du formu­laire.

Cela faisait quelque temps qu’on y songeait et l’ar­ri­vée des nouveaux MapQue­ryS­tring et MapRequest­Pay­load de Symfony nous a apporté ce qui nous manquait. Nous avons alors adapté leur logique pour gérer nos formu­laires.

Avant-propos : Retrou­vez un exemple complet mettant en œuvre notre approche sur notre GitHub : https://github.com/maki­na­cor­pus/poc-symfony-form-alter­na­tive/

L’at­tri­but MapForm­State

Nous avons donc imaginé un nouvel attri­but MapForm­State fonc­tion­nant de la même manière que MapRequest­Pay­load. Excepté que sur une erreur de vali­da­tion, au lieu de retour­ner une réponse 422 conte­nant la liste des erreurs, le proces­sus conti­nue son chemin. 

Le contrô­leur reçoit alors un objet de type Form­State conte­nant 2 proprié­tés :

  • data : le DTO (Data Trans­fert Object) hydraté avec les données en prove­nance du formu­laire,
  • viola­tion­List : un objet conte­nant les éven­tuelles erreurs de vali­da­tion (basé sur la Constraint­Vio­la­tion­List renvoyé par le compo­sant Vali­da­tor de Symfony).

Le contrô­leur peut alors trai­ter les données si elles sont valides ou bien renvoyer le formu­laire avec les erreurs de vali­da­tion 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 Form­Sta­te­Va­lueResol­ver

Le para­mètre du contrô­leur de type Form­State est résolu par un Argu­ment Resov­ler : le Form­StateValue­Re­sol­ver. Celui-ci est large­ment inspiré du Request­Pay­load­Va­lue­Re­sol­ver.

Son but est donc de détec­ter un éven­tuel objet de type Form­State en para­mètre d’un contrô­leur et de l’hy­dra­ter.

Dans les grandes lignes, cela donne les étapes suivantes :

  1. On récu­père les données de la requête depuis l’objet Request
  2. On dénor­ma­lise les données dans un DTO en utili­sant le compo­sant Seria­li­zer de Symfony
  3. On valide l’objet avec les contraintes rensei­gnées direc­te­ment sur le DTO avec le compo­sant Vali­da­tor
  4. On renvoie un objet de type Form­State, consti­tué du DTO et de la liste d’er­reurs de vali­da­tion

Retrou­­vez le code complet de ce Value­Re­sol­ver sur le dépôt GitHub.

Première utili­sa­tion

Concrè­te­ment, voici un premier exemple, imagi­nons un formu­laire deman­dant :

  • Un nom d’uti­li­sa­teur : un string ne conte­nant que des carac­tères alpha­nu­mé­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 certai­ne­ment en train de vous dire :

Attends mais c’est ça leur solu­tion ? C’est quand même vache­ment labo­rieux d’écrire tous ces formu­laires à la main dans les templates Twig !! 

Et vous avez bien raison, d’au­tant plus que notre exemple n’est pas complet ! Il manque par exemple des éléments d’ac­ces­si­bi­lité (notam­ment les aria-descri­bedby pour les messages d’aide et les erreurs).

Voyons comment amélio­rer tout ça en utili­sant les Twig Compo­nents.

La puis­sance des compo­sants Twig

L’ar­ri­vée des compo­sants Twig, que l’on pour­rait vulgai­re­ment consi­dé­rer comme des macros Twig sous stéroïdes, a permis de flui­di­fier la réuti­li­sa­tion de code Twig, ouvrant la voie à de nombreuses possi­bi­li­tés.

La construc­tion de notre inter­face graphique, par compo­si­tion, se rapproche dans une certaine mesure de ce qu’on pour­rait faire avec des outils comme Vue.js. 

Dans cette logique, nous avons créé des compo­sants pour nos éléments de formu­laire :

  • Un compo­sant Form pour gérer l’en­ve­loppe du formu­laire ainsi que le jeton CSRF
  • Un compo­sant Input pour gérer les éléments de formu­laire clas­siques (type text, number, email)
  • Un compo­sant TextA­rea
  • Un Input­Pass­word
  • etc.

Ces compo­sants permettent de gérer :

  • Le libellé du champ
  • La valeur
  • Les erreurs
  • Un texte d’aide
  • L’ac­ces­si­bi­lité
  • etc.

À partir de là, on peut consti­tuer notre formu­laire avec nos compo­sants, agré­men­tant notre biblio­thèque au fur et à mesure que l’on rencontre de nouveaux besoins.

Un exemple complet

Voici à quoi ressem­ble­rait main­te­nant 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éuti­li­sa­bi­lité

Grâce à la souplesse des compo­sants Http­Ker­nel et Seria­li­zer, on peut faci­le­ment créer des morceaux de formu­laire réuti­li­sables.

Prenons le cas d’un macro-champ repré­sen­tant une adresse. On souhaite pouvoir réuti­li­ser ce type de données pour plusieurs formu­laires de notre appli­ca­tion.

Pour ce faire, on crée le DTO asso­cié 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 compo­sant Twig asso­cié, 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’uti­li­ser dans nos formu­laires :

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 asso­cié :

{% 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 tech­nique fonc­tionne égale­ment avec les attri­buts MapQue­ryS­tring et MapRequest­Pay­load.

Conclu­sion

À la vue des exemples de cet article, on pour­rait se dire que notre approche apporte beau­coup de verbo­sité : cela semble cher payé. C’est vrai, pour des formu­laires simples, les avan­tages 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 formu­laires plus complexes que l’on se rend compte avec quelle simpli­cité on gère des cas tordus. C’est au moment de déve­lop­per un nouvel input spéci­fique qu’on appré­cie le décou­plage fort entre le front-end et le back-end.

Si vous êtes inté­res­sé·e·s par notre approche, n’hé­si­tez pas à venir en discu­ter avec nous sur l’espace Discus­sions du dépôt GitHub.

En résumé, les avan­tages de cette manière de gérer les formu­laires sont :

  • Décou­plage fort entre les logiques back-end et front-end
  • Gestion de la vali­da­tion via des DTOs à la manière d’une appli­ca­tion décou­plée (comme MapQue­ryS­tring et MapRequest­Pay­load)
  • Possi­bi­lité de person­na­li­ser faci­le­ment le rendu front via sa biblio­thèque de compo­sants de formu­laire
  • Gestion simpli­fiée des compo­sants complexes

Et ses limi­ta­tions :

  • Peu ou pas adap­tée à de la géné­ra­tion de formu­laire (formu­laires dyna­miques créés program­ma­tique­ment)
  • Approche un peu plus verbeuse (les templates Twig sont plus char­gés qu’avec le compo­sant Form, mais les contrô­leurs sont soula­gés !)
  • Le MapForm­S­tatereste à peau­fi­ner pour le rendre complè­te­ment géné­rique (notam­ment 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 Initiation

Actualités en lien

Geotrek et OpenS­treet­Map : Mise en place d’une passe­relle pour une connais­sance du terri­toire enri­chie

08/09/2025

Dans l’uni­vers des logi­ciels open-source, les plus belles inno­va­tions naissent souvent de la rencontre entre des commu­nau­tés qui partagent les mêmes valeurs. Aujour­d’hui, nous célé­brons une avan­cée majeure pour Geotrek : la créa­tion d’une passe­relle avec OpenS­treet­Map (OSM), la plus grande base de données carto­gra­phique colla­bo­ra­tive au monde. Plus qu’une simple fonc­tion­na­lité, ce projet est le fruit d’un travail d’in­gé­nie­rie et de recherche appro­fondi.
Voir l'article
Image
Logo d'illustration pour la passerelle entre OSM et Geotrek

Instal­ler Geotrek : avec ou sans segmen­ta­tion dyna­mique ?

08/09/2025

Geotrek-admin propose deux modes de fonc­tion­ne­ment pour gérer les objets liés aux tronçons : avec ou sans segmen­ta­tion dyna­mique. Ce choix a un impact impor­tant sur la manière dont sont stockées et gérées les données, et sur les possi­bi­li­tés d’édi­tion, de cohé­rence topo­lo­gique et d’in­ter­opé­ra­bi­lité avec d’autres systèmes. Dans cet article, on vous explique ce qu’est la segmen­ta­tion dyna­mique ainsi que le réfé­ren­ce­ment linéaire, ses avan­tages, ses limites, et dans quels cas il est perti­nent (ou non) de les utili­ser.
Voir l'article
Image
Réseau de tronçons dans Geotrek

Geotrek-Mobile évolue avec les conte­nus outdoor

09/07/2025

Le Dépar­te­ment du Doubs a financé une évolu­tion majeure de son appli­ca­tion mobile Explore Doubs en y inté­grant l’af­fi­chage des pratiques outdoor issues de Geotrek-Admin.  
Voir l'article
Image
Encart Geotrek-Mobile Doubs : contenu outdoor

Inscription à la newsletter

Nous vous avons convaincus