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ètreskippermettant 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_pathpermet 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 lespreviousounextpar exemple. Pour gérer ces interactions, vous avez la possibilité d'utiliser les nouveaux Types :ResetFlowTypepour réinitialiser le formulaireNextFlowTypepour accéder à l'étape suivantePreviousFlowType: pour accéder à l'étape précédenteFinishFlowTypetermine 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()etisValid(), on dispose deisFinished()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 locationqu'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
FormFlowCursorpour déclencher des actions à chaque étape (enregistrement progressif, traitements conditionnels, etc.)
Pour aller plus loin :
- La documentation officielle
- Le repo du créateur avec plein d'autres exemples
