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 einen skip-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_path sagt 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ür previous oder next zum Beispiel. Um diese Interaktionen zu steuern, kannst du die neuen Types nutzen:
    • ResetFlowType um das Formular zurückzusetzen
    • NextFlowType um zum nächsten Schritt zu gelangen
    • PreviousFlowType: um zum vorherigen Schritt zu gelangen
    • FinishFlowType beendet 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() und isValid() haben wir isFinished(), 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.)