It is finally here. After years of relying on third-party bundles, battling with session storage and writing convoluted controller logic to handle “wizards,” Symfony 7.4 has delivered one of the most requested features in the framework’s history: Native Multi-Step Forms.
If you have been working with Symfony for a decade like I have, you know the struggle. You need a registration wizard, a complex checkout process, or a multi-stage survey. You had two choices: hacked-together controller logic with manual validation groups, or the legendary CraueFormFlowBundle. While Craue served us well, it was always an external dependency that felt slightly disconnected from the core Form component’s philosophy.
With the release of Symfony 7.4 in November 2025, the Core Team has introduced Form Flows. This isn’t just a helper class; it’s a complete paradigm shift that treats a multi-step flow as a first-class citizen, fully integrated with the Validator and Form components.
In this article, we are going to build a production-ready User Onboarding Wizard using Symfony 7.4. We will cover everything from installation to advanced state management, using only the native libraries provided by the framework.
The Evolution of Forms in Symfony 7.4
Before we write code, let’s understand why this update matters.
In previous versions, a form was a single unit of work. You submitted it, validated it and flushed it. To break this up, developers had to artificially slice the data model and manually manage the state between requests. You would often see controllers with switch statements handling step1, step2 and step3, manually saving progress to the session.
Symfony 7.4 introduces the AbstractFlowType. Think of this as a “Form of Forms.” It manages the lifecycle of the entire process, handling navigation (Next, Previous, Skip), validation groups and data persistence automatically.
Enterprise User Onboarding
We are building an onboarding wizard for a SaaS platform. It consists of three distinct steps:
- Identity: Basic user details (Email, Password).
- Profile: Professional details (Job Title, Company).
- Plan: Subscription selection (Free, Pro, Enterprise).
We will use a Data Transfer Object (DTO) to hold the state of the form across requests. This is a best practice in modern Symfony development, avoiding the “Anemic Domain Model” issue where entities are cluttered with temporary form data.
The Data Transfer Object (DTO)
The DTO acts as the backbone of our flow. Notice the use of Attributes for validation. In Symfony 7.4, the Form Flow component automatically uses the current step name as the validation group. This is a game-changer; we no longer need complex validation_groups closures.
//src/Form/Dto/UserOnboarding.php
namespace App\Form\Dto;
use Symfony\Component\Validator\Constraints as Assert;
class UserOnboarding
{
// We need a property to track the current step.
// The flow component will read/write to this.
public string $currentStep = 'identity';
// -- Step 1: Identity --
#[Assert\NotBlank(groups: ['identity'])]
#[Assert\Email(groups: ['identity'])]
public ?string $email = null;
#[Assert\NotBlank(groups: ['identity'])]
#[Assert\PasswordStrength(minScore: 2, groups: ['identity'])]
public ?string $password = null;
// -- Step 2: Profile --
#[Assert\NotBlank(groups: ['profile'])]
#[Assert\Length(min: 2, groups: ['profile'])]
public ?string $fullName = null;
#[Assert\NotBlank(groups: ['profile'])]
public ?string $jobTitle = null;
#[Assert\IsTrue(message: "You must agree to terms.", groups: ['profile'])]
public bool $termsAccepted = false;
// -- Step 3: Plan --
#[Assert\NotBlank(groups: ['plan'])]
#[Assert\Choice(choices: ['free', 'pro', 'enterprise'], groups: ['plan'])]
public string $plan = 'free';
}
Notice groups: [‘identity’], groups: [‘profile’]. These correspond exactly to the step names we will define later.
Building the Step Forms
We need standard AbstractType classes for each step. These are standard Symfony forms; there is nothing “special” about them, which makes them reusable.
Identity Step
//src/Form/Step/IdentityStepType.php
namespace App\Form\Step;
use App\Form\Dto\UserOnboarding;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class IdentityStepType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('email', EmailType::class, [
'label' => 'Work Email',
'attr' => ['placeholder' => 'you@company.com']
])
->add('password', PasswordType::class, [
'label' => 'Choose a Password',
'always_empty' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserOnboarding::class,
// We do NOT set validation_groups here. The Flow handles it.
]);
}
}
Profile Step
//src/Form/Step/ProfileStepType.php
namespace App\Form\Step;
use App\Form\Dto\UserOnboarding;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProfileStepType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('fullName', TextType::class, ['label' => 'Full Name'])
->add('jobTitle', TextType::class, ['label' => 'Job Title'])
->add('termsAccepted', CheckboxType::class, [
'label' => 'I agree to the Terms of Service',
'required' => false, // Handled by validator
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserOnboarding::class,
]);
}
}
Plan Step
//src/Form/Step/PlanStepType.php
namespace App\Form\Step;
use App\Form\Dto\UserOnboarding;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PlanStepType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('plan', ChoiceType::class, [
'choices' => [
'Free Tier' => 'free',
'Pro ($29/mo)' => 'pro',
'Enterprise' => 'enterprise',
],
'expanded' => true, // Radio buttons
'label' => 'Select your plan'
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserOnboarding::class,
]);
}
}
The Flow Type (The Magic)
This is where Symfony 7.4 shines. Instead of AbstractType, we extend AbstractFlowType. We also use the new FormFlowBuilderInterface.
//src/Form/Flow/UserOnboardingFlowType.php
namespace App\Form\Flow;
use App\Form\Dto\UserOnboarding;
use App\Form\Step\IdentityStepType;
use App\Form\Step\PlanStepType;
use App\Form\Step\ProfileStepType;
use Symfony\Component\Form\Flow\AbstractFlowType;
use Symfony\Component\Form\Flow\FormFlowBuilderInterface;
use Symfony\Component\Form\Flow\Type\NavigatorFlowType;
use Symfony\Component\OptionsResolver\OptionsResolver;
class UserOnboardingFlowType extends AbstractFlowType
{
public function buildFormFlow(FormFlowBuilderInterface $builder, array $options): void
{
// 1. Add Steps
// The first argument is the step name (used for validation groups)
$builder->addStep('identity', IdentityStepType::class);
$builder->addStep('profile', ProfileStepType::class);
$builder->addStep('plan', PlanStepType::class);
// 2. Add Navigation
// The NavigatorFlowType automatically adds Next, Previous and Finish buttons
$builder->add('navigator', NavigatorFlowType::class);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => UserOnboarding::class,
// Crucial: Tell Symfony which property holds the current step name
'step_property_path' => 'currentStep',
]);
}
}
The NavigatorFlowType is a new composite type in Symfony 7.4. It abstracts away the buttons. By default, it renders:
- Next (on all steps except the last)
- Previous (on all steps except the first)
- Finish (on the last step)
You can customize this! For example, if you want to allow skipping a step:
$builder->add('navigator', NavigatorFlowType::class, [
'buttons' => [
'next' => ['label' => 'Continue'],
'finish' => ['label' => 'Complete Setup'],
]
]);
The Controller
Gone are the days of manually checking $step query parameters or session keys. The controller looks shockingly similar to a standard CRUD controller.
//src/Controller/OnboardingController.php
namespace App\Controller;
use App\Form\Dto\UserOnboarding;
use App\Form\Flow\UserOnboardingFlowType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class OnboardingController extends AbstractController
{
#[Route('/onboarding', name: 'app_onboarding')]
public function __invoke(Request $request): Response
{
// 1. Initialize your DTO
$dto = new UserOnboarding();
// 2. Create the Flow Form
// This inspects the Request and automatically determines the current step
$flow = $this->createForm(UserOnboardingFlowType::class, $dto);
$flow->handleRequest($request);
// 3. Check for completion
// isFinished() checks if the 'finish' button was clicked AND the final step is valid
if ($flow->isSubmitted() && $flow->isValid() && $flow->isFinished()) {
// At this point, $dto is fully populated and validated across ALL steps
$this->saveUser($dto);
return $this->redirectToRoute('app_dashboard');
}
// 4. Render the specific step
// do NOT use createView(). Use getStepForm() to get the view for the current step.
return $this->render('onboarding/flow.html.twig', [
'form' => $flow->getStepForm(),
]);
}
private function saveUser(UserOnboarding $dto): void
{
// Logic to persist User entity, create Stripe customer, etc.
// dd($dto);
}
}
Wait, where is the Session? By default, the AbstractFlowType is stateless in the sense that it relies on the data being POSTed back and forth. However, for a better UX, Symfony 7.4 automatically integrates with the standard HTTP Session if your DTO is serializable.
If you are dealing with Entities that cannot be easily serialized, you might want to use a Data Transformer or rely on the step_property_path to keep the pointer valid.
The Template
This is where the rubber meets the road. We need to render the form. The variable form passed to the template is the current step’s form view.
{% extends 'base.html.twig' %}
{% block title %}Onboarding Step: {{ form.vars.name|capitalize }}{% endblock %}
{% block body %}
<div class="container mx-auto max-w-lg mt-10">
<h1 class="text-2xl font-bold mb-5">Welcome Aboard</h1>
{# Progress Indicator (Optional) #}
<div class="mb-6 flex justify-between text-sm text-gray-500">
<span class="{{ form.vars.name == 'identity' ? 'font-bold text-blue-600' : '' }}">Identity</span>
<span>→</span>
<span class="{{ form.vars.name == 'profile' ? 'font-bold text-blue-600' : '' }}">Profile</span>
<span>→</span>
<span class="{{ form.vars.name == 'plan' ? 'font-bold text-blue-600' : '' }}">Plan</span>
</div>
{{ form_start(form) }}
{# Render errors for the whole form #}
{{ form_errors(form) }}
{# Render the fields for the CURRENT step #}
{# The 'navigator' field is technically a nested form containing buttons #}
<div class="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
{% for child in form %}
{% if child.vars.name != 'navigator' %}
{{ form_row(child, {'attr': {'class': 'mb-4'}}) }}
{% endif %}
{% endfor %}
</div>
{# Render Navigation Buttons #}
<div class="flex justify-between items-center">
{# The navigator contains 'previous', 'next', 'finish' depending on the step #}
{{ form_widget(form.navigator) }}
</div>
{{ form_end(form) }}
</div>
{% endblock %}
The navigator field usually renders as a group of buttons. You might want to customize the rendering of NavigatorFlowType by creating a custom form theme if you want specific classes (like Tailwind utility classes) on the Previous vs Next buttons.
To style the buttons specifically without a theme, you can access them directly if you know they exist:
<div class="flex justify-between">
{% if form.navigator.previous is defined %}
{{ form_widget(form.navigator.previous, {'attr': {'class': 'btn-secondary'}}) }}
{% endif %}
{% if form.navigator.next is defined %}
{{ form_widget(form.navigator.next, {'attr': {'class': 'btn-primary'}}) }}
{% endif %}
{% if form.navigator.finish is defined %}
{{ form_widget(form.navigator.finish, {'attr': {'class': 'btn-success'}}) }}
{% endif %}
</div>
Advanced: Conditional Steps
What if we want to skip the “Profile” step if the user enters a corporate email address?
In UserOnboardingFlowType, we can use the include_if option on the step definition. However, addStep in Symfony 7.4 is robust.
A common pattern is checking data in the buildFormFlow? No, buildFormFlow is built once. Instead, we can use EventListeners inside the Flow, or simply logic within the controller. But the cleanest way in 7.4 is using the skip option in the addStep method which accepts a closure.
As of the initial 7.4 release, the dynamic skipping logic is often best handled by the NavigatorFlowType logic or by listening to FormEvents::POST_SUBMIT on the previous step to modify the currentStep property manually if needed. However, the addStep method supports a condition callable in some implementations.
Let’s verify the standard approach for conditional logic. The standard Symfony 7.4 implementation recommends using the transition logic. If you need highly dynamic flows (like a “Choose your own adventure”), you might override getStepForm logic, but for simple skipping, we can simply default fields to nullable and hide them.
However, a more robust way provided by the new component is utilizing the Transition System. The NavigatorFlowType calculates the next step based on the array order. If you need to jump, you can modify the data object’s currentStep in a POST_SUBMIT listener of the IdentityStepType.
// In IdentityStepType
$builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) {
$data = $event->getData();
$form = $event->getForm();
if (str_ends_with($data->email, '@corp.com')) {
// This requires the parent flow to recognize the jump,
// usually handled by the navigator looking at the set step.
// For simple implementations, just ensure your validation groups
// don't block empty fields in the skipped step.
}
});
The simplest way in 7.4 is to simply allow the user to click “Next” and ensuring the back-end validation groups for the skipped step are not triggered.
Why this is better than CraueFormFlowBundle
- Native Integration: No more fighting with the bundle’s session storage driver. It uses the standard Form data mappers.
- Attributes for Validation: The mapping of step name -> validation group reduces boilerplate configuration by 50%.
- Type Safety: Everything is strongly typed. AbstractFlowType ensures you are adhering to the contract.
- Twig Simplicity: No more flow.currentStepNumber logic in Twig. You just render the form you are given.
Does it work?
To ensure your implementation is robust, follow these verification steps:
- Step Isolation Test: Fill out Step 1, click Next. Reload the page. You should still be on Step 2 (if session persistence is active) or be able to handle the stateless nature.
- Validation Gate: Try to click “Next” on Step 1 with an empty email. The form should re-render Step 1 with errors.
- Group Isolation: Add a requirement to Step 2. Try to submit Step 1. The Step 2 error should not appear yet.
- Completion: Finish the flow. Check your database or dump the DTO in the controller. Ensure data from Step 1 (Email) persists through to the final submission.
Conclusions
Symfony 7.4’s Multi-Step Forms feature is a massive leap forward for Developer Experience (DX). It takes a complex, recurring problem and solves it with the elegance we have come to expect from the Symfony Core Team. By leveraging AbstractFlowType and the natural synergy with the Validator component, you can delete hundreds of lines of spaghetti code from your controllers.
Building complex wizards is no longer a chore — it is a standard, enjoyable part of the Symfony ecosystem.
Are you planning to refactor your legacy wizards to Symfony 7.4? I would love to hear about your experience with the migration.
Let’s be in touch on [LinkedIn:https://www.linkedin.com/in/matthew-mochalkin/].
Top comments (0)