Build a Multi-Step Wizard Form in Livewire 4 with Per-Step Validation

Build a Livewire multi-step form with per-step validation in one component. Scope rules to the active step, persist state between steps, re-validate on submit.

Steven Richardson
Steven Richardson
· 10 min read

A checkout with eight fields crammed onto one screen converts worse than the same eight fields split across three steps. So you reach for a wizard, and a Livewire multi-step form sounds simple until the details bite: validation fires for inputs that aren't on screen yet, clicking Back wipes what the user already typed, and the progress bar drifts out of sync with the real step.

None of that needs a package. A wizard is one Livewire 4 component with a step counter, a rules array per step, and public properties that survive between requests for free. If you're moving from Livewire 3 to 4, the pattern is unchanged — it just gets the new single-file component treatment. Here's the whole thing, end to end.

Scaffold the Livewire multi-step form component#

Generate the component with artisan. In Livewire 4 this writes a single-file component by default — PHP, Blade, and any scoped CSS together in one ⚡checkout-wizard.blade.php file under resources/views/components.

php artisan make:livewire CheckoutWizard

Class-based components are still fully supported in v4, and for a wizard with several methods I find a class easier to read than a long single-file component. The class body below is identical either way — it lives inside the single-file component's new class extends Component { } block or in app/Livewire/CheckoutWizard.php. Run php artisan livewire:convert CheckoutWizard to switch formats whenever you like. I'll show it as a class.

Define the step state and per-step rules#

The whole wizard is driven by one integer: $step. Everything else is just public properties, grouped mentally by the step they belong to. The move that keeps per-step validation clean is a rulesForStep() method that returns only the rules for the step you ask about, so you never validate fields the user hasn't reached.

<?php

namespace App\Livewire;

use App\Models\Registration;
use Livewire\Component;

class CheckoutWizard extends Component
{
    public int $step = 1;
    public int $totalSteps = 3;

    // Step 1 — account
    public string $name = '';
    public string $email = '';

    // Step 2 — address
    public string $line1 = '';
    public string $city = '';
    public string $postcode = '';

    // Step 3 — plan
    public string $plan = '';
    public bool $terms = false;

    /**
     * Validation rules for a single step.
     *
     * @return array<string, string>
     */
    protected function rulesForStep(int $step): array
    {
        return match ($step) {
            1 => [
                'name'  => 'required|string|min:2|max:80',
                'email' => 'required|email|max:120',
            ],
            2 => [
                'line1'    => 'required|string|max:120',
                'city'     => 'required|string|max:80',
                'postcode' => 'required|string|max:12',
            ],
            3 => [
                'plan'  => 'required|in:basic,pro,team',
                'terms' => 'accepted',
            ],
            default => [],
        };
    }

    public function render()
    {
        return view('livewire.checkout-wizard');
    }
}

Notice there are no #[Validate] attributes on these properties. That's deliberate. #[Validate] runs on every property update, and — more importantly — a bare $this->validate() would then check all of them at once, failing step 1 because step 3's fields are still empty. Passing an explicit subset to validate() is what scopes validation to the active step. If a single step's field list grows unwieldy, lift that step into a Livewire 4 Form Object and call its validate() the same way.

Validate the current step before advancing#

nextStep() validates the current step's subset, then increments $step. If validation throws, the increment never runs and Livewire renders the error messages, so the user stays put. previousStep() does no validation at all, which is what makes moving backward always free.

public function nextStep(): void
{
    $this->validate($this->rulesForStep($this->step));

    $this->step = min($this->step + 1, $this->totalSteps);
}

public function previousStep(): void
{
    // No validation going backward — let users move freely.
    $this->step = max($this->step - 1, 1);
}

Because wire:model is deferred by default, the field values ride along with the nextStep request, so validate() always sees the latest input — no .live modifier needed. Prove the behaviour with a quick Pest test rather than clicking through it by hand:

use App\Livewire\CheckoutWizard;
use Livewire\Livewire;

it('blocks advancing while step one is invalid', function () {
    Livewire::test(CheckoutWizard::class)
        ->call('nextStep')
        ->assertHasErrors(['name', 'email'])
        ->assertSet('step', 1);
});

it('advances once the current step validates', function () {
    Livewire::test(CheckoutWizard::class)
        ->set('name', 'Ada Lovelace')
        ->set('email', '[email protected]')
        ->call('nextStep')
        ->assertHasNoErrors()
        ->assertSet('step', 2);
});

Want errors to appear the moment a user leaves a field rather than when they hit Next? Switch those inputs to wire:model.live.blur, or reach for Laravel Precognition to run your real validation rules live against the server before the form is ever submitted.

Render the stepper and step navigation#

The Blade side is a progress bar, one panel per step gated behind @if ($step === n), and a footer that swaps a Next button for Finish on the final step. A computed property keeps the percentage logic in one place.

use Livewire\Attributes\Computed;

#[Computed]
public function progress(): int
{
    return (int) round(($this->step / $this->totalSteps) * 100);
}
<div class="mx-auto max-w-lg">
    {{-- Progress bar --}}
    <div class="mb-6">
        <div class="flex justify-between text-sm font-medium text-gray-500">
            <span>Step {{ $step }} of {{ $totalSteps }}</span>
            <span>{{ $this->progress }}%</span>
        </div>
        <div class="mt-2 h-2 w-full rounded-full bg-gray-200">
            <div class="h-2 rounded-full bg-indigo-600 transition-all"
                 style="width: {{ $this->progress }}%"></div>
        </div>
    </div>

    <form wire:submit="submit">
        @if ($step === 1)
            <div class="space-y-4">
                <input type="text" wire:model="name" placeholder="Full name" class="w-full">
                @error('name') <span class="text-sm text-red-600">{{ $message }}</span> @enderror

                <input type="email" wire:model="email" placeholder="Email" class="w-full">
                @error('email') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
            </div>
        @endif

        @if ($step === 2)
            <div class="space-y-4">
                <input type="text" wire:model="line1" placeholder="Address" class="w-full">
                @error('line1') <span class="text-sm text-red-600">{{ $message }}</span> @enderror

                <input type="text" wire:model="city" placeholder="City" class="w-full">
                @error('city') <span class="text-sm text-red-600">{{ $message }}</span> @enderror

                <input type="text" wire:model="postcode" placeholder="Postcode" class="w-full">
                @error('postcode') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
            </div>
        @endif

        @if ($step === 3)
            <div class="space-y-4">
                <select wire:model="plan" class="w-full">
                    <option value="">Choose a plan…</option>
                    <option value="basic">Basic</option>
                    <option value="pro">Pro</option>
                    <option value="team">Team</option>
                </select>
                @error('plan') <span class="text-sm text-red-600">{{ $message }}</span> @enderror

                <label class="flex items-center gap-2">
                    <input type="checkbox" wire:model="terms">
                    <span>I accept the terms</span>
                </label>
                @error('terms') <span class="text-sm text-red-600">{{ $message }}</span> @enderror
            </div>
        @endif

        {{-- Navigation --}}
        <div class="mt-6 flex justify-between">
            <button type="button" wire:click="previousStep" @disabled($step === 1)
                    class="rounded px-4 py-2 disabled:opacity-40">Back</button>

            @if ($step < $totalSteps)
                <button type="button" wire:click="nextStep"
                        class="rounded bg-indigo-600 px-4 py-2 text-white">Next</button>
            @else
                <button type="submit"
                        class="rounded bg-emerald-600 px-4 py-2 text-white">Finish</button>
            @endif
        </div>
    </form>
</div>

Each panel is plain wire:model bound to the component's properties, so the values are already on the server by the time any action fires. Keep purely cosmetic state — a collapsed order summary, an open tooltip — in Alpine so toggling it doesn't cost a round trip; wire:entangle bridges Alpine and Livewire state for the bits that do need to reach PHP. For animated transitions between steps, Livewire 4's #[Transition(type: 'forward')] and #[Transition(type: 'backward')] attributes on your navigation methods drive the browser's View Transitions API with no custom JavaScript.

Persist the multi-step form on submit#

The Finish button submits the form, which calls submit(). This is the one place you must re-validate every step, not just the last one. $step is client state — nothing stops a determined user from calling submit while $step still says 1 — so trusting that the earlier steps already passed is a security hole.

use Illuminate\Support\Arr;

public function submit(): void
{
    // Re-validate all steps. Never trust the client's $step.
    $validated = $this->validate($this->allRules());

    Registration::create(Arr::except($validated, ['terms']));

    session()->flash('status', "You're all signed up.");

    $this->reset();
}

/**
 * Every step's rules merged into one ruleset.
 *
 * @return array<string, string>
 */
protected function allRules(): array
{
    return collect(range(1, $this->totalSteps))
        ->flatMap(fn (int $step) => $this->rulesForStep($step))
        ->all();
}

allRules() merges every step's rules into one array, validate() checks the lot and returns the clean data, and Arr::except() drops the terms checkbox before handing the rest to the model. reset() returns all public properties to their declared defaults — including $step, which drops back to 1 ready for the next run.

Gotchas and Edge Cases#

Never trust $step on the server. Re-validating only the final step lets someone submit a half-filled form by replaying the request with a bumped step number. Validating the merged allRules() on submit closes that gap — the per-step validation is a UX nicety, the full validation is the real gate.

Use wire:key on repeated step content. If a step renders a loop — line items, dynamically added rows — give each row a stable wire:key. Without it, Livewire's morphing reuses DOM nodes across step changes and you'll see stale values bleed from one step into another.

reset() leaves the error bag behind. It clears property values, not validation errors. When you send a user back to step 1 after submit, call $this->resetValidation() alongside $this->reset() so old messages don't linger on a fresh form.

Conditional steps break a hard-coded total. If step 2 only appears for business accounts, compute $totalSteps from the data instead of hard-coding 3, and make rulesForStep() return [] for any skipped step so allRules() stays accurate and the progress bar still adds up to 100%.

Sometimes a route-per-step wizard is the better tool. For very long flows where users bookmark or share their progress, a page per step backed by wire:navigate for that SPA feel beats one component holding all the state in memory.

Wrapping Up#

Keep the wizard as one component for as long as the state fits comfortably in a single class: a $step integer, public properties per step, a rulesForStep() subset for forward validation, and a full re-validate on submit. That covers the vast majority of onboarding and checkout flows without a single extra dependency.

When a step gets heavy — a pricing table that hits the database, a slow address lookup — don't make the whole component pay for it on mount. Livewire 4 Islands let you lazy-load just that step while the rest of the wizard stays untouched.

FAQ#

How do I validate each step in a Livewire multi-step form?

Define a method that returns the validation rules for a given step — a match($step) returning a small rules array works well — and call $this->validate($rulesForStep) inside your nextStep() action. Passing an explicit subset means Livewire only checks the fields on the current screen, so empty fields from later steps don't trigger errors. On final submit, validate the merged rules from every step so nothing slips through behind a tampered step counter.

How do I track the current step in Livewire?

Hold the step in a single public integer property, usually $step, initialised to 1. Public properties persist between requests automatically, so the value survives every network round trip without sessions or hidden inputs. Your nextStep() and previousStep() actions just increment and decrement it, clamped between 1 and the total number of steps.

How do I keep state between steps in a Livewire wizard?

You don't have to do anything — that's the point. Every public property on a Livewire component is serialised and restored on each request, so data the user entered on step 1 is still there on step 3. Bind each input with wire:model and the values are on the server by the time an action runs. The only state you manage by hand is the $step counter itself.

Can I go back a step without losing data in Livewire?

Yes. Because public properties persist across requests, moving backward keeps every value the user has entered. Make your previousStep() method decrement $step without calling validate(), so going back is never blocked by an incomplete field further along. Only forward navigation and the final submission should run validation.

How do I show a progress bar in a Livewire wizard?

Expose a computed property that returns round($step / $totalSteps * 100) and bind its value to the width of a filled <div> in your Blade template. Because it's derived from $step, the bar updates automatically every time the step changes — no extra wiring. A Tailwind transition-all class on the inner bar gives you a smooth animated fill for free.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.