FormFlow ist da!
Symfony 7.4 ist Ende November mit einer Reihe von Neuerungen erschienen. Darunter eine wirklich interessante Weiterentwicklung der Form-Komponente: FormFlow.
FormFlow erlaubt es, große Formulare in mehrere Schritte aufzuteilen, die Navigation dazwischen einfach zu steuern und die Validierung Schritt für Schritt zu kontrollieren.
In diesem Artikel schauen wir uns anhand eines konkreten Beispiels an, wie man es nutzt: ein Mobilfunk-Abo-Prozess.
Abo eines Mobilfunktarifs
Nehmen wir unser Beispiel wieder auf.
Um einen Mobilfunktarif abzuschließen, durchläuft man in der Regel mehrere Schritte (wir vereinfachen bewusst, es dient nur dem Beispiel):
- Tarifwahl
- Liste der verfügbaren Tarife
- Wahl zwischen SIM-Karte oder eSIM
- Bankdaten
- Name des Kontoinhabers
- IBAN
- SEPA-Zustimmung
- Persönliche Angaben
- Nachname
- Vorname
- Adresse
- Postleitzahl
- Stadt
Jeder dieser Schritte wird ein FormType
1. Erstellung unserer Models
Wir brauchen verschiedene Models:
- Ein Model für den Tarif
<?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
) {
}
}
- Eins für die Bankdaten
<?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
) {
}
}
- Das für die persönlichen Angaben
<?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
) {
}
}
- Und schließlich unser globales Model
<?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'
) {
}
}
Beachte das public string $currentStep= 'offer', mit dem wir den aktuellen Schritt unseres Formulars kennen — setze standardmäßig den Wert deines ersten Schritts.
2- Erstellung der Schritte
Jetzt, wo unsere Models bereit sind, können wir uns an die verschiedenen Formularschritte machen.
Jeder Schritt wird durch einen klassischen FormType repräsentiert: in unserem Fall OfferType, BankingType und PersonalType.
Beispiel für den Tarif:
<?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' => [
'Alles unbegrenzt Frankreich und Europa - 25,99 €/Monat' => 'allin',
'SMS und Anrufe unbegrenzt 100 GB - 19,99 €/Monat' => 'smscall100go',
'SMS und Anrufe unbegrenzt 50 - 10,99 €/Monat' => 'smscall50go',
'SMS unbegrenzt 2 Std. Anrufe - 2,99 €/Monat' => 'sms2call',
],
'required' => true
]);
$builder->add('eSim', CheckboxType::class, ['required' => false]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'help' => 'Ihr Tarif',
'data_class' => Offer::class
]);
}
}
Das Gleiche machen wir für die anderen Types.
Schließlich unser "Haupt"-Type, der AbstractFlowType extends
<?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'
]);
}
}
Ein paar Erklärungen, bevor wir weitermachen:
- jeder Schritt wird dem Formular über
addStep()hinzugefügt, das den Schrittnamen und den zugehörigen Type entgegennimmt. Du kannst auch einenskip-Parameter hinzufügen, um über eine anonyme Funktion eine Regel zum Überspringen eines Schritts zu definieren. Zum Beispiel, wenn wir im Offer-Schritt einen kostenlosen Tarif hätten, könnten wir den IBAN-Schritt skippen wollen. Das würde dann so aussehen:
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 entspricht einem Subscription mit den bereits eingegebenen Daten.
step_property_pathsagt FormFlow, wo der aktuelle Schritt in unserem Subscription-Objekt gespeichert/gelesen wird. Hier zeigen wir auf die Property currentStep, die wir vorhin hinzugefügt haben.->add('navigator', NavigatorFlowType::class);fügt den Standard-Navigator hinzu, um zwischen den Schritten zu navigieren. Du kannst auch deinen eigenen erstellen, mit eigenen Buttons (wie einem Reset-Button für das Formular) oder eigenen Regeln fürpreviousodernextzum Beispiel. Um diese Interaktionen zu steuern, kannst du die neuen Types nutzen:ResetFlowTypeum das Formular zurückzusetzenNextFlowTypeum zum nächsten Schritt zu gelangenPreviousFlowType: um zum vorherigen Schritt zu gelangenFinishFlowTypebeendet und setzt das Formular zurück
Jeder dieser Types ist anpassbar, und du kannst ihre Sichtbarkeit mit include_if steuern.
$builder->add('back_to', PreviousFlowType::class, [
'validate' => false,
'validation_groups' => false,
'clear_submission' => false,
'include_if' => fn (FormFlowCursor $cursor) => !$cursor->isFirstStep(),
]);
Wir fügen unseren eigenen Navigator hinzu
<?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' => 'Zurück'
]);
$builder->add('next', NextFlowType::class, [
'include_if' => fn(FormFlowCursor $cursor) => !$cursor->isLastStep(),
'label' => 'Weiter'
]);
$builder->add('finish', FinishFlowType::class, ['label' => 'Abonnieren']);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'label' => false,
'mapped' => false,
'priority' => -100
]);
}
}
Hier deaktivieren wir die Schritt-Validierung beim Zurückgehen, und wir wollen kein Next beim letzten Schritt, noch Previous beim ersten (diese Fälle werden nativ gehandhabt, das ist nur fürs Beispiel)
3- Rendering unseres Formulars
a- Controller-Teil
Die Erstellung des Formulars im Controller ist ziemlich nah an dem, was wir schon kennen
<?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();
// Deine Verarbeitung (E-Mail versenden, in die DB speichern, usw...
$this->addFlash('success', 'Danke für Ihr Abo!');
return $this->redirectToRoute('subscription', [], Response::HTTP_SEE_OTHER);
}
return $this->render('subscription.html.twig', [
'form' => $flow->getStepForm(),
], new Response(status: 303));
}
}
- `createForm funktioniert wie bei jedem klassischen Formular
- zusätzlich zu
isSubmitted()undisValid()haben wirisFinished(), um zu wissen, ob der komplette Flow abgeschlossen ist (letzter Schritt erreicht) getStepForm()erlaubt, nur das Formular des aktuellen Schritts zu holen- der 303 ist nicht Pflicht, aber mit Turbo vermeidet er den Fehler
Form responses must redirect to another location, auf den man manchmal stößt.
b- Twig-Rendering
Auf der Twig-Seite ist das Rendering einfach
{% extends 'base.html.twig' %}
{% block body %}
<div class="subscription-container">
<h1>Ihr neuer Mobilfunktarif</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 %}Tarif{% elseif i == 2 %}Zahlung{% 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 %}
Das Rendering ist roh und basic, aber die Navigation funktioniert.
Ressourcen
Du hast jetzt einen Überblick darüber, was mit FormFlow möglich ist, aber das ist erst der Anfang:
- Möglichkeit, Unterschritte hinzuzufügen
- vollständige Anpassung des Renderings
- Nutzung des
FormFlowCursor, um bei jedem Schritt Aktionen auszulösen (progressives Speichern, bedingte Verarbeitung usw.)
