Skip to content
Bladewell
Guides

Livewire multi-step form with validation per step

A Livewire wizard with Bladewell's stepper: each step validated as you go, every step checked again before saving, and focus moved for screen readers.

php artisan bladewell:add stepper text-input select radio button toast

Uses Stepper, Text input, Select, Radio, Button and Toast. New to Bladewell? Install it first.

One component, one step at a time

A wizard splits a long form into steps: here, a workspace's details, its address, a plan, then a review before anything is saved. One Livewire component holds every answer and which step you're on, and shows that step's fields. Each Continue checks only the fields you can see; Back checks nothing.

Bladewell's stepper only shows where you are. It has no script and no state of its own: pass it the current step and it follows along on every render, and a step can be marked as needing attention.

The component

Three things keep it honest:

  • #[Locked] on $step: any other public property can be set from the browser, but this one only by the component's own methods. Without it, one request could jump to the last step.
  • Creating checks every step's rules again, all at once. Each earlier check ran in its own request, and the answers can be changed from the browser since. If any fail, the wizard goes back to the first step with a problem, and the stepper marks each step that has one.
  • Every step change moves focus to the new step's heading, so a screen reader announces it and the keyboard starts there, instead of on a button that has just disappeared.
app/Livewire/CreateWorkspace.php
namespace App\Livewire;

use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Locked;
use Livewire\Component;

class CreateWorkspace extends Component
{
    // The steps, and the fields each one asks for.
    private const STEPS = [
        1 => ['label' => 'Details', 'fields' => ['name', 'email']],
        2 => ['label' => 'Address', 'fields' => ['country', 'city', 'postcode']],
        3 => ['label' => 'Plan', 'fields' => ['plan']],
        4 => ['label' => 'Review', 'fields' => []],
    ];

    private const COUNTRIES = ['nl' => 'Netherlands', 'in' => 'India', 'us' => 'United States'];

    private const PLANS = [
        ['value' => 'starter', 'label' => 'Starter', 'description' => 'One person, three projects.'],
        ['value' => 'team', 'label' => 'Team', 'description' => 'Up to 10 people, unlimited projects.'],
        ['value' => 'business', 'label' => 'Business', 'description' => 'Everyone, with single sign-on.'],
    ];

    // Only the methods below move between steps: #[Locked] stops the browser from setting it.
    #[Locked]
    public int $step = 1;

    public string $name = '';

    public string $email = '';

    public ?string $country = null;

    public string $city = '';

    public string $postcode = '';

    public ?string $plan = null;

    /** One step's rules. */
    private function rulesFor(int $step): array
    {
        return match ($step) {
            1 => ['name' => ['required', 'string', 'max:80'], 'email' => ['required', 'email', 'max:255']],
            2 => [
                'country' => ['required', Rule::in(array_keys(self::COUNTRIES))],
                'city' => ['required', 'string', 'max:80'],
                'postcode' => ['required', 'string', 'max:12'],
            ],
            3 => ['plan' => ['required', Rule::in(array_column(self::PLANS, 'value'))]],
            default => [],
        };
    }

    // Enter in any field, or the main button: on to the next step, or create on the last.
    public function submit(): void
    {
        $this->step < count(self::STEPS) ? $this->next() : $this->create();
    }

    public function back(): void
    {
        $this->goTo($this->step - 1);
    }

    // From the review: back to an earlier step to change it.
    public function edit(int $step): void
    {
        if ($step < $this->step) {
            $this->goTo($step);
        }
    }

    private function next(): void
    {
        // This step's fields only: the later ones haven't been filled in yet. And only this step's messages change:
        // $this->validate() would clear them all, and a later step marked by a failed create() should keep its mark
        // until it's fixed.
        $fields = self::STEPS[$this->step]['fields'];
        $validator = Validator::make($this->only($fields), $this->rulesFor($this->step));
        $this->resetErrorBag($fields);
        if ($validator->fails()) {
            foreach ($validator->errors()->messages() as $field => $messages) {
                $this->addError($field, $messages[0]);
            }

            return;
        }

        $this->goTo($this->step + 1);
    }

    private function create(): void
    {
        // Every step's rules again, all at once. The earlier checks ran in earlier requests, and any public property
        // can be changed from the browser since.
        $rules = array_merge(...array_map(fn (int $step) => $this->rulesFor($step), array_keys(self::STEPS)));
        $validator = Validator::make($this->all(), $rules);
        if ($validator->fails()) {
            $this->setErrorBag($validator->errors());
            // Back to the first step with a problem; the stepper marks every step that has one.
            $this->goTo(collect(self::STEPS)->search(fn (array $step) => $validator->errors()->hasAny($step['fields'])) ?: 1);

            return;
        }

        $workspace = auth()->user()->workspaces()->create($validator->validated());

        session()->flash('success', "{$workspace->name} is ready.");
        $this->redirectRoute('workspaces.show', $workspace, navigate: true);
    }

    private function goTo(int $step): void
    {
        $this->step = max(1, min($step, count(self::STEPS)));
        // Screen readers and the keyboard start at the new step's heading, not wherever focus was left.
        $this->js("document.getElementById('wizard-heading')?.focus()");
    }

    public function render()
    {
        $errors = $this->getErrorBag();

        return view('livewire.create-workspace', [
            'steps' => array_values(array_map(fn (array $step) => ['label' => $step['label'], 'error' => $errors->hasAny($step['fields'])], self::STEPS)),
            'countries' => self::COUNTRIES,
            'plans' => self::PLANS,
        ]);
    }
}

The view

One form for every step, so Enter in any field moves on: submit() decides whether that's the next step or creating the workspace. Each step's fields are the ordinary Bladewell controls with wire:model, so their errors show under them without any markup of your own. Back is a plain button, not a submit, so leaving a step half-done doesn't stop on its errors. The review lists the answers, with an Edit button that goes back to their step.

resources/views/livewire/create-workspace.blade.php
<div class="grid max-w-xl gap-8">
    {{-- Display only: it shows the step the component is on, and marks any step whose fields have an error. --}}
    <x-widget.stepper.labelled label="Create a workspace" :current="$step" :steps="$steps" />

    {{-- Focused on each step change (tabindex="-1": by script, not by Tab), so the new step is read out. --}}
    <h2 id="wizard-heading" tabindex="-1" class="text-xl font-semibold outline-none">{{ $steps[$step - 1]['label'] }}</h2>

    <form wire:submit="submit" class="grid gap-5">
        @if ($step === 1)
            <x-widget.text-input label="Workspace name" wire:model="name" maxlength="80" required />
            <x-widget.text-input label="Billing email" type="email" wire:model="email" autocomplete="email" required />
        @elseif ($step === 2)
            <x-widget.select label="Country" wire:model="country" :options="$countries" required />
            <x-widget.text-input label="City" wire:model="city" autocomplete="address-level2" maxlength="80" required />
            <x-widget.text-input label="Postcode" wire:model="postcode" autocomplete="postal-code" maxlength="12" required />
        @elseif ($step === 3)
            <x-widget.radio label="Plan" wire:model="plan" :options="$plans" required />
        @else
            <div class="divide-line grid divide-y">
                @foreach ([
                    1 => [$name, $email],
                    2 => [$city.' '.$postcode, $countries[$country] ?? ''],
                    3 => [collect($plans)->firstWhere('value', $plan)['label'] ?? ''],
                ] as $target => $lines)
                    <div class="flex items-start justify-between gap-4 py-3">
                        <div>
                            <h3 class="text-sm font-semibold">{{ $steps[$target - 1]['label'] }}</h3>
                            @foreach ($lines as $line)
                                <p class="text-foreground/75">{{ $line }}</p>
                            @endforeach
                        </div>
                        <x-widget.button variant="link" wire:click="edit({{ $target }})">Edit<span class="sr-only"> {{ $steps[$target - 1]['label'] }}</span></x-widget.button>
                    </div>
                @endforeach
            </div>
        @endif

        <div class="flex justify-between gap-3">
            {{-- type="button" by default: Back doesn't submit, so it doesn't validate the step you're leaving. --}}
            @if ($step > 1)
                <x-widget.button variant="neutral" wire:click="back">Back</x-widget.button>
            @else
                <span></span>
            @endif
            <x-widget.button type="submit">{{ $step < 4 ? 'Continue' : 'Create workspace' }}</x-widget.button>
        </div>
    </form>
</div>

After it's created

The component flashes a success message and moves to the new workspace with wire:navigate. With <x-widget.toast /> once in your layout, the message shows as a toast there. The route behind it, workspaces.show, should check the workspace belongs to the person, like any page that takes an id.

This guide's component and view were run in a fresh Laravel 13 app with Livewire 3.8 and 4.4: every step, Back, Edit, a step set from the browser being refused, and the final check catching an answer changed after its step.