Multi-Step Wizard Form

A session-based multi-step form with schema versioning, validation per step, and error indicators on tabs.

Navigation:

Create Your Account

Enter your email and create a secure password.

Tip: Your password should be at least 8 characters long.

Source Code View on GitHub
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Form\Wizard\RegistrationFormType;
use App\Form\Wizard\RegistrationWizardSchema;
use Mdxpl\HtmxBundle\Form\Wizard\NavigationStrategy;
use Mdxpl\HtmxBundle\Form\Wizard\WizardHelper;
use Mdxpl\HtmxBundle\Form\Wizard\WizardSchema;
use Mdxpl\HtmxBundle\Form\Wizard\WizardState;
use Mdxpl\HtmxBundle\Request\HtmxRequest;
use Mdxpl\HtmxBundle\Response\HtmxResponse;
use Mdxpl\HtmxBundle\Response\HtmxResponseBuilder;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/wizard')]
final class WizardDemoController extends AbstractController
{
    private const TEMPLATE = 'wizard_demo.html.twig';

    public function __construct(
        private readonly WizardHelper $wizard,
    ) {
    }

    #[Route('', name: 'app_wizard', methods: ['GET', 'POST'])]
    #[Route('/{step}', name: 'app_wizard_step', methods: ['GET', 'POST'], requirements: ['step' => 'account|profile|preferences|confirmation'])]
    public function index(HtmxRequest $htmx, Request $request, ?string $step = null): HtmxResponse
    {
        $schema = $this->createSchema($request);
        $state = $this->wizard->loadOrStart($schema);

        if ($step !== null && $request->isMethod('GET')) {
            $this->wizard->goToStep($schema, $state, $schema->getStepIndex($step));
        }

        $form = $this->createWizardForm($schema, $state);
        $form->handleRequest($request);

        if (!$form->isSubmitted()) {
            return $this->renderStep($htmx, $schema, $state, $form);
        }

        return $this->handleSubmission($htmx, $request, $schema, $state, $form);
    }

    #[Route('/reset', name: 'app_wizard_reset', methods: ['POST'])]
    public function reset(HtmxRequest $htmx): HtmxResponse
    {
        $this->wizard->clear(RegistrationWizardSchema::create());

        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->redirect($this->generateUrl('app_wizard'))
            ->build();
    }

    private function createSchema(Request $request): WizardSchema
    {
        $strategyValue = $request->query->getString('_nav', '');
        $strategy = NavigationStrategy::tryFrom($strategyValue) ?? NavigationStrategy::FREE;

        return RegistrationWizardSchema::create($strategy);
    }

    /**
     * @return FormInterface<array<string, mixed>>
     */
    private function createWizardForm(WizardSchema $schema, WizardState $state): FormInterface
    {
        return $this->createForm(RegistrationFormType::class, $state->getAllData(), [
            'wizard' => ['schema' => $schema, 'state' => $state],
        ]);
    }

    /**
     * @param FormInterface<array<string, mixed>> $form
     */
    private function handleSubmission(
        HtmxRequest $htmx,
        Request $request,
        WizardSchema $schema,
        WizardState $state,
        FormInterface $form,
    ): HtmxResponse {
        $action = $request->request->getString('_wizard_action', 'next');

        if ($action === 'back') {
            $this->wizard->previousStep($schema, $state);

            return $this->renderStep($htmx, $schema, $state, $form);
        }

        if (!$form->isValid()) {
            $this->wizard->setStepErrors($schema, $state, $form);

            return $this->renderStep($htmx, $schema, $state, $form, hasErrors: true);
        }

        $this->wizard->saveStepData($schema, $state, $form->getData());
        $this->wizard->markStepCompleted($schema, $state);

        if ($state->isLastStep($schema) && $action === 'submit') {
            return $this->handleFinalSubmission($htmx, $schema, $state, $form);
        }

        $this->wizard->nextStep($schema, $state);

        return $this->renderStep($htmx, $schema, $state, $form);
    }

    /**
     * @param FormInterface<array<string, mixed>> $form
     */
    private function handleFinalSubmission(
        HtmxRequest $htmx,
        WizardSchema $schema,
        WizardState $state,
        FormInterface $form,
    ): HtmxResponse {
        $incompleteSteps = $this->findIncompleteSteps($schema, $state);

        if ($incompleteSteps !== []) {
            $state->setCurrentStep($schema->getStepIndex($incompleteSteps[0]));
            $this->wizard->save($schema, $state);

            return $this->renderStep($htmx, $schema, $state, $form, hasErrors: true);
        }

        $allData = $state->getAllData();
        $this->wizard->clear($schema);

        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock(self::TEMPLATE, 'success', ['data' => $allData])
            ->build();
    }

    /**
     * @return string[] Names of incomplete steps
     */
    private function findIncompleteSteps(WizardSchema $schema, WizardState $state): array
    {
        $incomplete = [];

        foreach ($schema->getSteps() as $index => $step) {
            if ($index >= $state->getCurrentStep()) {
                break;
            }

            if (!$state->isStepCompleted($step->name)) {
                $incomplete[] = $step->name;
                $state->setStepErrors($step->name, ['_form' => ['Please complete this step']]);
            }
        }

        return $incomplete;
    }

    /**
     * @param FormInterface<array<string, mixed>> $form
     */
    private function renderStep(
        HtmxRequest $htmx,
        WizardSchema $schema,
        WizardState $state,
        FormInterface $form,
        bool $hasErrors = false,
    ): HtmxResponse {
        $currentStep = $schema->getStep($state->getCurrentStep());

        $formToRender = $hasErrors ? $form : $this->createWizardForm($schema, $state);

        $viewData = [
            'form' => $formToRender->createView(),
            'wizard_schema' => $schema,
            'wizard_state' => $state,
            'wizard_current_step' => $currentStep,
            'navigation_strategies' => NavigationStrategy::cases(),
            'current_navigation_strategy' => $schema->getNavigationStrategy(),
        ];

        $builder = HtmxResponseBuilder::create($htmx->isHtmx);

        if (!$htmx->isHtmx) {
            return $builder->success()->view(self::TEMPLATE, $viewData)->build();
        }

        $stepUrl = $this->generateStepUrl($currentStep->name, $schema->getNavigationStrategy());
        $builder->pushUrl($stepUrl);

        if ($hasErrors) {
            return $builder
                ->failure()
                ->triggerAfterSwap(['scrollTo' => '#wizard-tabs'])
                ->viewBlock(self::TEMPLATE, 'wizard', $viewData)
                ->build();
        }

        return $builder->success()->viewBlock(self::TEMPLATE, 'wizard', $viewData)->build();
    }

    private function generateStepUrl(string $stepName, NavigationStrategy $strategy): string
    {
        $params = ['step' => $stepName];

        if ($strategy !== NavigationStrategy::FREE) {
            $params['_nav'] = $strategy->value;
        }

        return $this->generateUrl('app_wizard_step', $params);
    }
}
{% block wizard %}
{% import "@MdxplHtmx/Form/wizard_tabs.html.twig" as wizardMacros %}
{% set navStrategy = current_navigation_strategy.value|default('free') %}

<div id="wizard-container">
    <article>
        <header>
            <div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem;">
                <div style="display: flex; align-items: center; gap: 0.5rem;">
                    <small>Navigation:</small>
                    {% set currentStepNameForUrl = wizard_current_step.name|default('account') %}
                    <select style="width: auto; padding: 0.25rem 0.5rem;"
                            hx-get="{{ path('app_wizard_step', {step: currentStepNameForUrl}) }}"
                            hx-target="#wizard-container"
                            hx-swap="innerHTML swap:150ms"
                            hx-push-url="true"
                            name="_nav">
                        {% for strategy in navigation_strategies|default([]) %}
                            <option value="{{ strategy.value }}" {{ navStrategy == strategy.value ? 'selected' : '' }}>
                                {{ strategy.label }}
                            </option>
                        {% endfor %}
                    </select>
                </div>
                <button type="button" class="outline btn-sm"
                        style="color: var(--pico-del-color);"
                        hx-post="{{ path('app_wizard_reset') }}"
                        hx-target="#wizard-container"
                        hx-swap="innerHTML swap:150ms"
                        hx-confirm="Are you sure you want to reset the wizard? All data will be lost.">
                    Reset
                </button>
            </div>
        </header>

        {% set urlParams = {} %}
        {% if navStrategy != 'free' %}
            {% set urlParams = urlParams|merge({_nav: navStrategy}) %}
        {% endif %}

        {% if form.vars.wizard is defined %}
            {% set wizard = form.vars.wizard %}
            <div class="wizard-tabs">
                {% for step in wizard.steps %}
                    <a class="wizard-tab {{ step.is_current ? 'active' : '' }} {{ step.has_errors ? 'has-error' : (step.is_completed ? 'completed' : '') }}"
                       {% if step.can_navigate and not step.is_current %}
                           hx-get="{{ path('app_wizard_step', {step: step.name}|merge(urlParams)) }}"
                           hx-target="#wizard-container"
                           hx-swap="innerHTML swap:150ms"
                           hx-push-url="true"
                       {% endif %}>
                        {% if step.has_errors %}!{% elseif step.is_completed %}✓{% endif %}
                        {{ step.label }}
                    </a>
                {% endfor %}
                <small style="margin-left: auto;">Step {{ wizard.current_step + 1 }} of {{ wizard.steps|length }}</small>
            </div>
        {% endif %}

        {% set currentStepName = form.vars.wizard.steps[form.vars.wizard.current_step].name|default('account') %}
        {{ form_start(form, {
            attr: {
                'hx-post': path('app_wizard_step', {step: currentStepName}|merge(urlParams)),
                'hx-target': '#wizard-container',
                'hx-swap': 'innerHTML swap:150ms',
                'novalidate': 'novalidate'
            }
        }) }}

        {{ form_widget(form._submitted) }}

        <div id="wizard-content">
            {% if form.vars.wizard is defined %}
                {% set currentStepName = form.vars.wizard.steps[form.vars.wizard.current_step].name %}

                {% if currentStepName == 'account' %}
                    {{ block('step_account') }}
                {% elseif currentStepName == 'profile' %}
                    {{ block('step_profile') }}
                {% elseif currentStepName == 'preferences' %}
                    {{ block('step_preferences') }}
                {% elseif currentStepName == 'confirmation' %}
                    {{ block('step_confirmation') }}
                {% endif %}
            {% endif %}
        </div>

        <div class="wizard-nav">
            {{ wizardMacros.navigation(form, {
                backLabel: 'Previous',
                nextLabel: 'Continue',
                submitLabel: 'Complete Registration',
                containerClass: '',
                backClass: 'outline',
                nextClass: '',
                submitClass: ''
            }) }}
        </div>

        {{ form_end(form, {render_rest: false}) }}
    </article>
</div>
{% endblock %}

{% block step_account %}
<h3>Create Your Account</h3>
<p><small>Enter your email and create a secure password.</small></p>

{{ form_row(form.account.email) }}

<div class="grid">
    <div>{{ form_row(form.account.password) }}</div>
    <div>{{ form_row(form.account.confirmPassword) }}</div>
</div>

<p><small><ins>Tip:</ins> Your password should be at least 8 characters long.</small></p>
{% endblock %}

{% block step_profile %}
<h3>Tell Us About Yourself</h3>
<p><small>Help us personalize your experience.</small></p>

<div class="grid">
    <div>{{ form_row(form.profile.firstName) }}</div>
    <div>{{ form_row(form.profile.lastName) }}</div>
</div>

{{ form_row(form.profile.bio) }}
{% endblock %}

{% block step_preferences %}
<h3>Set Your Preferences</h3>
<p><small>Customize how you want to use the application.</small></p>

{{ form_row(form.preferences.newsletter) }}

<hr><p><small>Appearance</small></p>
{{ form_row(form.preferences.theme) }}

<hr><p><small>Notifications</small></p>
{{ form_row(form.preferences.notifications) }}
{% endblock %}

{% block step_confirmation %}
<h3>Review Your Information</h3>
<p><small>Please review your registration details before completing.</small></p>

{% set data = wizard_state.allData %}

<figure>
    <table>
        <tbody>
            <tr><th>Email</th><td>{{ data.account.email|default('—') }}</td></tr>
            <tr><th>Name</th><td>{{ data.profile.firstName|default('—') }} {{ data.profile.lastName|default('') }}</td></tr>
            <tr><th>Bio</th><td>{{ (data.profile.bio|default('—'))|slice(0, 100) }}{% if (data.profile.bio|default(''))|length > 100 %}...{% endif %}</td></tr>
            <tr><th>Newsletter</th><td>{{ data.preferences.newsletter|default(false) ? 'Yes' : 'No' }}</td></tr>
            <tr><th>Theme</th><td>{{ data.preferences.theme|default('system')|capitalize }}</td></tr>
            <tr><th>Notifications</th><td>{{ (data.preferences.notifications|default('—'))|replace({'_': ' '})|capitalize }}</td></tr>
        </tbody>
    </table>
</figure>

{{ form_row(form.confirmation.confirm) }}
{% endblock %}

{% block success %}
<article>
    <header>
        <h2 style="color: var(--pico-ins-color);">Registration Complete!</h2>
    </header>

    <p>Welcome, {{ data.profile.firstName|default('') }} {{ data.profile.lastName|default('') }}! Your account has been created successfully.</p>

    <figure>
        <table>
            <tbody>
                <tr><th>Email</th><td>{{ data.account.email|default('—') }}</td></tr>
                <tr><th>Name</th><td>{{ data.profile.firstName|default('—') }} {{ data.profile.lastName|default('') }}</td></tr>
                <tr><th>Newsletter</th><td>{{ data.preferences.newsletter|default(false) ? 'Subscribed' : 'Not subscribed' }}</td></tr>
                <tr><th>Theme</th><td>{{ data.preferences.theme|default('system')|capitalize }}</td></tr>
            </tbody>
        </table>
    </figure>

    <footer>
        <a href="{{ path('app_wizard') }}" role="button">Start New Registration</a>
        <a href="{{ path('app_home') }}" role="button" class="outline">Back to Home</a>
    </footer>
</article>
{% endblock %}