FormFlow : les formulaires multi étapes

FormFlow : les formulaires multi étapes

Développement

FormFlow est là !

Symfony 7.4 est arrivé fin novembre avec son lot de nouveautés. Parmi elles, une évolution très intéressante du composant Form : FormFlow. :br:br
FormFlow permet de découper de gros formulaires en plusieurs étapes, de gérer facilement la navigation entre elles et de contrôler la validation étape par étape. :br:br
Dans cet article, on va voir comment l’utiliser à travers un exemple concret : un parcours de souscription mobile.

Souscription à une offre de téléphonie

Reprenons notre exemple. :br:br
Pour souscrire à une offre de téléphonie, on retrouve généralement plusieurs étapes (on simplifie volontairement, c’est uniquement pour l’exemple) :

  • Choix de l'offre
    • Liste d'offres disponibles
    • Choix entre carte SIM ou eSIM
  • Coordonnées bancaires
    • Nom du titulaire
    • IBAN
    • Accord SEPA
  • Infos personnelles
    • Nom
    • Prénom
    • Adresse
    • Code postal
    • Ville

Chacune de ces étapes sera un FormType

1. Création de nos Models

On va avoir besoin de différents Model :

  • Un model pour l'offre
<?php

declare(strict_types=1);

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class Offer
{
    public function __construct(
        #[Assert\NotBlank(groups: ['offer'])]
        public ?string $name = null,
        public bool $eSim = false
    ) {
    }
}
  • Un pour les coordonnées bancaires
<?php

declare(strict_types=1);

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class BankingInformation
{
    public function __construct(
        #[Assert\NotBlank(groups: ['banking'])]
        public ?string $owner = null,
        #[Assert\NotBlank(groups: ['banking'])]
        public ?string $iban = null,
        public bool $sepaAgreement = false
    ) {
    }
}
  • Celui pour les infos personnelles
<?php

declare(strict_types=1);

namespace App\Form\Data\Step;

use Symfony\Component\Validator\Constraints as Assert;

class Personal
{
    public function __construct(
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $firstName = null,
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $lastName = null,
        #[Assert\Email(groups: ['personal'])]
        public ?string $email = null,
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $phone = null,
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $address = null,
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $zipCode = null,
        #[Assert\NotBlank(groups: ['personal'])]
        public ?string $city = null
    ) {
    }
}
  • Et enfin, notre model global
<?php

declare(strict_types=1);

namespace App\Form\Data;

use App\Form\Data\Step\BankingInformation;
use App\Form\Data\Step\Offer;
use App\Form\Data\Step\Personal;
use Symfony\Component\Validator\Constraints as Assert;

class Subscription
{
    public function __construct(
        #[Assert\Valid(groups: ['offer'])]
        public Offer $offer = new Offer(),
        #[Assert\Valid(groups: ['banking'])]
        public BankingInformation $banking = new BankingInformation(),
        #[Assert\Valid(groups: ['personal'])]
        public Personal $personal = new Personal(),
        public string $currentStep = 'offer'
    ) {
    }
}

Notez le public string $currentStep= 'offer' qui va nous permettre de connaître l'étape courante de notre formulaire, mettez la valeur de votre première étape par défaut.

2- Création des étapes

Maintenant que nos modèles sont prêts, on peut s’attaquer aux différentes étapes du formulaire. :br:br
Chaque étape sera représentée par un FormType classique : dans notre cas, OfferType, BankingType et PersonalType.

Exemple pour l'offre :

<?php

declare(strict_types=1);

namespace App\Form\Type\Step;

use App\Form\Data\Step\Offer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class OfferType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', ChoiceType::class, [
            'choices' => [
                'Tout illimité france et Europe - 25,99€/mois' => 'allin',
                'SMS appel illimité 100Go - 19,99€/mois' => 'smscall100go',
                'SMS appel illimité 50 - 10,99€/mois' => 'smscall50go',
                'SMS illimité 2h d\'appel - 2,99€/mois' => 'sms2call',
            ],
            'required' => true
        ]);
        $builder->add('eSim', CheckboxType::class, ['required' => false]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => false,
            'help' => 'Votre offre',
            'data_class' => Offer::class
        ]);
    }
}

On fera de même pour les autres types. :br:br
Enfin, notre Type "principal" qui va extends AbstractFlowType

<?php

declare(strict_types=1);

namespace App\Form\Type;

use App\Form\Data\Subscription;
use App\Form\Type\Step\BankingType;
use App\Form\Type\Step\OfferType;
use App\Form\Type\Step\PersonalType;
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SubscriptionType extends AbstractFlowType
{
    public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder
            ->addStep('offer', OfferType::class)
            ->addStep('banking', BankingType::class)
            ->addStep('personal', PersonalType::class)
            ->add('navigator', SubscriptionNavigatorType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Subscription::class,
            'step_property_path' => 'currentStep'
        ]);
    }
}

Quelques explications avant de poursuivre :

  • chaque étape est ajoutée au formulaire via addStep() qui prend le nom d'étape et le Type associé. Vous avez aussi la possibilité d'ajouter un paramètre skip permettant de définir une règle pour passer une étape, via une fonction anonyme. Par exemple, si on avait dans l'étape Offre une offre gratuite, alors on pourrait vouloir skip l'étape de l'IBAN. Ça nous donnerait quelque chose comme ça :
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
    {
        $builder
            ->addStep('offer', OfferType::class)
            ->addStep('banking', BankingType::class, skip: fn (Subscription $data) => $data->offer->name === 'free')
            ->addStep('personal', PersonalType::class)
            ->add('navigator', SubscriptionNavigatorTy::class);
    }

$data correspond à un Subscription avec les données déjà saisies.

  • step_property_path permet de dire à FormFlow où stocker/lire l’étape courante dans notre objet Subscription. Ici, on lui indique la propriété currentStep, que nous avons ajoutée plus tôt.
  • ->add('navigator', NavigatorFlowType::class); ajoute le navigator par défaut pour naviguer entre les étapes. Vous avez aussi la possibilité de créer le vôtre, en y ajoutant vos propres boutons (comme un bouton de reset de formulaire) ou d'ajouter vos propres règles pour les previous ou next par exemple. Pour gérer ces interactions, vous avez la possibilité d'utiliser les nouveaux Types :
    • ResetFlowType pour réinitialiser le formulaire
    • NextFlowType pour accéder à l'étape suivante
    • PreviousFlowType: pour accéder à l'étape précédente
    • FinishFlowType termine et réinitialise le formulaire

Chacun de ces types est personnalisable, et vous pouvez gérer leur visibilité avec include_if.

$builder->add('back_to', PreviousFlowType::class, [
            'validate' => false,
            'validation_groups' => false,
            'clear_submission' => false,
            'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isFirstStep(),
        ]);

On va ajouter notre propre Navigator

<?php

declare(strict_types=1);

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Flow\FormFlowCursor;
use Symfony\Component\Form\Flow\Type\FinishFlowType;
use Symfony\Component\Form\Flow\Type\NextFlowType;
use Symfony\Component\Form\Flow\Type\PreviousFlowType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class SubscriptionNavigatorType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('previous', PreviousFlowType::class, [
            'label' => 'Précédent'
        ]);
        $builder->add('next', NextFlowType::class, [
            'include_if' => fn(FormFlowCursor $cursor) => !$cursor->isLastStep(),
            'label' => 'Suivant'
        ]);
        $builder->add('finish', FinishFlowType::class, ['label' => 'Souscrire']);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'label' => false,
            'mapped' => false,
            'priority' => -100
        ]);
    }
}

Ici, on désactive la validation d'étape lors d'un retour arrière, et on ne veut pas du Next à la dernière étape, ni du Previous à la 1ère (ces cas sont gérés nativement, c'est pour l'exemple)

3- Rendu de notre formulaire

a- Partie Controller

La création du formulaire côté Controller est sensiblement proche de ce qu'on connaît déjà

<?php

declare(strict_types=1);

namespace App\Controller;

use App\Form\Data\Subscription;
use App\Form\Type\SubscriptionType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class SubscriptionController extends AbstractController
{
    #[Route(path: '/subscription', name: 'subscription')]
    public function __invoke(Request $request)
    {
        $flow = $this
            ->createForm(SubscriptionType::class, new Subscription())
            ->handleRequest($request);

        if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
            $data = $flow->getData();

            // Vos traitements (envoi de mail, enregistrement BDD, etc...

            $this->addFlash('success', 'Merci pour votre souscription !');

            return $this->redirectToRoute('subscription', [], Response::HTTP_SEE_OTHER);
        }

        return $this->render('subscription.html.twig', [
            'form' => $flow->getStepForm(),
        ], new Response(status: 303));
    }
}
  • `createForm fonctionne comme pour n’importe quel formulaire classique
  • en plus de isSubmitted() et isValid(), on dispose de isFinished() pour savoir si le flow complet est terminé (dernière étape atteinte)
  • getStepForm() permet de récupérer uniquement le formulaire de l’étape courante
  • le 303 n’est pas obligatoire, mais avec Turbo il évite l’erreur Form responses must redirect to another location qu'on rencontre parfois.

b- Rendu twig

Côté twig, le rendu est simple

{% extends 'base.html.twig' %}
{% block body %}
    <div class="subscription-container">
        <h1>Votre nouvelle offre de téléphonie mobile</h1>

        <div class="step-indicator">
            {% set total_steps = 3 %}
            {% set current_step = form.vars.cursor.currentstep %}
            {% for i in 1..total_steps %}
                <div class="step {{ i == current_step ? 'active' : (i < current_step ? 'completed' : '') }}">
                    <span class="step-number">{{ i }}</span>
                    <span class="step-label">
                        {% if i == 1 %}Offre{% elseif i == 2 %}Paiement{% elseif i == 3 %}Infos{% endif %}
                    </span>
                </div>
            {% endfor %}
        </div>

        <div class="form-wrapper">
            {{ form_start(form, {'attr': {'class': 'styled-form'}}) }}
                {{ form_errors(form) }}

                <div class="form-content">
                    {% for child in form.children %}
                        {% if child.vars.name != 'navigator' %}
                            <div class="form-step-fields">
                                {{ form_row(child) }}
                            </div>
                        {% endif %}
                    {% endfor %}
                </div>

                <div class="form-navigation">
                    {{ form_widget(form.navigator) }}
                </div>
            {{ form_end(form) }}
        </div>
    </div>
{% endblock %}

Le rendu est brut et basique, mais la navigation fonctionne.

Resources

Vous avez maintenant un aperçu de ce qu’il est possible de faire avec FormFlow, mais ce n’est qu’un début :

  • possibilité d’ajouter des sous-étapes
  • personnalisation complète du rendu
  • exploitation du FormFlowCursor pour déclencher des actions à chaque étape (enregistrement progressif, traitements conditionnels, etc.)

Pour aller plus loin :