Skip to content
HTMX Laravel Tips and Tricks Laravel

Laravel Multi-Step Wizard Form Without Livewire

Raheel Shan

Raheel Shan

November 13th, 2026 · 2 min read

Laravel Multi-Step Wizard Form Without Livewire

Yes, you can build a wizard form in Laravel without Livewire. The simplest version uses one route per step, validates each step as it is submitted, keeps the answers in the session, and saves everything in one place at the end. It works without JavaScript, survives a page refresh, and the back button behaves. This post builds a three-step sign-up wizard (account, profile, confirm) and then shows a shorter client-side variant with Alpine.js.

Quick answer

  1. Make one route and one Blade view per step.
  2. Validate the current step on POST, then store its fields in the session.
  3. Redirect to the next step. Refuse a step if the earlier ones are missing.
  4. On the last step, read everything from the session, save it, and clear the session.

Step 1: Routes

Each step has a GET (show the form) and a POST (validate and move on).

use App\Http\Controllers\WizardController;

Route::prefix('signup')->name('signup.')->group(function () {
    Route::get('/account', [WizardController::class, 'account'])->name('account');
    Route::post('/account', [WizardController::class, 'storeAccount']);

    Route::get('/profile', [WizardController::class, 'profile'])->name('profile');
    Route::post('/profile', [WizardController::class, 'storeProfile']);

    Route::get('/confirm', [WizardController::class, 'confirm'])->name('confirm');
    Route::post('/confirm', [WizardController::class, 'finish']);
});

Step 2: The controller

The controller keeps the answers under one session key. Each step validates only its own fields.

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;

class WizardController extends Controller
{
    private const KEY = 'signup';

    public function account()
    {
        return view('signup.account', ['data' => session(self::KEY, [])]);
    }

    public function storeAccount(Request $request)
    {
        $data = $request->validate([
            'name'     => ['required', 'string', 'max:100'],
            'email'    => ['required', 'email', 'unique:users,email'],
            'password' => ['required', 'min:8', 'confirmed'],
        ]);

        $this->remember(['name' => $data['name'], 'email' => $data['email'], 'password' => $data['password']]);

        return redirect()->route('signup.profile');
    }

    public function profile()
    {
        if (! $this->has('email')) {
            return redirect()->route('signup.account');
        }

        return view('signup.profile', ['data' => session(self::KEY, [])]);
    }

    public function storeProfile(Request $request)
    {
        $data = $request->validate([
            'company' => ['nullable', 'string', 'max:100'],
            'country' => ['required', 'string', 'size:2'],
        ]);

        $this->remember($data);

        return redirect()->route('signup.confirm');
    }

    public function confirm()
    {
        if (! $this->has('email') || ! $this->has('country')) {
            return redirect()->route('signup.account');
        }

        return view('signup.confirm', ['data' => session(self::KEY)]);
    }

    public function finish()
    {
        $data = session(self::KEY);

        abort_unless(isset($data['email'], $data['country']), 403);

        User::create([
            'name'     => $data['name'],
            'email'    => $data['email'],
            'password' => Hash::make($data['password']),
            'company'  => $data['company'] ?? null,
            'country'  => $data['country'],
        ]);

        session()->forget(self::KEY);

        return redirect('/welcome');
    }

    private function remember(array $values): void
    {
        session([self::KEY => array_merge(session(self::KEY, []), $values)]);
    }

    private function has(string $key): bool
    {
        return isset(session(self::KEY, [])[$key]);
    }
}

The guards in profile() and confirm() stop someone from jumping straight to step 3 by typing the URL. Because each step is a normal request, validation errors and old input work as they always do.

Step 3: A step view

Each view is an ordinary Blade form. Showing the saved value lets people go back and keep their answers.

<form method="POST" action="{{ route('signup.profile') }}">
    @csrf

    <label>Company</label>
    <input type="text" name="company" value="{{ old('company', $data['company'] ?? '') }}">

    <label>Country (2-letter code)</label>
    <input type="text" name="country" value="{{ old('country', $data['country'] ?? '') }}">
    @error('country') <p>{{ $message }}</p> @enderror

    <a href="{{ route('signup.account') }}">Back</a>
    <button type="submit">Next</button>
</form>

Security notes

  • The password sits in the session between steps. The session is server-side, but if your session driver is cookie, switch to file, database or redis for this flow. Better still, ask for the password on the last step, so it never waits in the session.
  • Validate again on the last step, or re-check anything that can change, such as a unique email, before creating the record.
  • Clear the session key when the wizard is done and when someone abandons it.

A shorter version: one form with Alpine.js

If the wizard has little server logic, keep all steps in one page and show one at a time. One POST sends everything, and the server validates it all.

<form method="POST" action="/signup" x-data="{ step: 1 }">
    @csrf

    <div x-show="step === 1">
        <input type="text" name="name" placeholder="Name">
        <input type="email" name="email" placeholder="Email">
        <button type="button" @click="step = 2">Next</button>
    </div>

    <div x-show="step === 2">
        <input type="text" name="country" placeholder="Country code">
        <button type="button" @click="step = 1">Back</button>
        <button type="submit">Create account</button>
    </div>
</form>

Hidden steps are still in the page, so their fields are submitted. There is one catch with validation. If a field in a hidden step has the required attribute, the browser cannot focus it, and it blocks the submit without showing a message (the console says "An invalid form control is not focusable"). Either leave required off and validate on the server, or check each step in JavaScript before moving on. The server must validate everything anyway. Another form problem with hidden fields is covered in Form Fields Not Submitting? Use the HTML form Attribute.

When to use which

  • Session wizard: long or sensitive flows, steps that depend on earlier answers, and anything that must work without JavaScript.
  • Alpine single form: short flows where all fields can be checked at once.
  • Livewire: worth considering when steps share a lot of live state or you need real-time validation. See Skip Livewire? Why Laravel Blade + AQC + Alpine Might Be All You Need for the case against, and HTMX vs Livewire for the comparison.

You can also swap steps without a full page load by returning each step as a Blade partial with HTMX, see Laravel with HTMX: Use Blade Partials as Your API. If you reuse one form for create and edit, Laravel findOrNew() shows the pattern.

FAQ

How do I make a multi-step form in Laravel? Use one route and view per step, validate each step on POST, store the answers in the session, and save once on the last step.

Can I build a wizard in Laravel without Livewire? Yes. Plain Blade forms with session storage work well, and Alpine.js can handle a short single-page version.

Where should I store data between wizard steps? In the server-side session under one key. For large data or multiple devices, store a draft row in the database instead.

How do I stop users skipping steps? Check in each step's GET method that the earlier answers exist, and redirect to the first missing step if not.

How do I validate each step separately? Call $request->validate() with only that step's rules in its POST method, or move them into one form request class per step.

Found this useful?

Support the work that goes into these posts.

Support
Comments


Comment created and will be displayed once approved.