Skip to content

Multi-Step Wizard

This recipe demonstrates building a multi-step form wizard with navigation, validation per step, progress indicator, and data persistence across steps.

What We're Building

A user onboarding wizard with:

  • Three steps (Personal Info, Account Setup, Preferences)
  • Step navigation (Next/Previous)
  • Progress indicator
  • Per-step validation
  • Final submission
  • Step persistence

Complete Implementation

Blade Template

Create resources/views/onboarding/wizard.blade.php:

blade
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Onboarding Wizard</title>
    @hyper
    <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50">
    <div class="max-w-2xl mx-auto px-4 py-12">
        <div @signals([
            'step' => 1,
            'name' => '',
            'email' => '',
            'phone' => '',
            'username' => '',
            'password' => '',
            'password_confirmation' => '',
            'notifications' => false,
            'newsletter' => false,
            'theme' => 'light',
            '_submitting' => false
        ])>
            <!-- Progress Indicator -->
            <div class="mb-8">
                <div class="flex items-center justify-between mb-2">
                    <template data-for="n in 3">
                        <div class="flex-1 flex items-center">
                            <div
                                data-class:bg-blue-600="$step >= n"
                                data-class:bg-gray-300="$step < n"
                                class="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold">
                                <span data-text="n"></span>
                            </div>
                            <div
                                data-show="n < 3"
                                data-class:bg-blue-600="$step > n"
                                data-class:bg-gray-300="$step <= n"
                                class="flex-1 h-1 mx-2">
                            </div>
                        </div>
                    </template>
                </div>
                <div class="flex justify-between text-sm text-gray-600">
                    <span data-class:font-bold="$step === 1">Personal Info</span>
                    <span data-class:font-bold="$step === 2">Account Setup</span>
                    <span data-class:font-bold="$step === 3">Preferences</span>
                </div>
            </div>

            <!-- Step 1: Personal Info -->
            <div data-show="$step === 1" class="bg-white rounded-lg shadow p-8">
                <h2 class="text-2xl font-bold mb-6">Personal Information</h2>

                <div class="space-y-4">
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Full Name</label>
                        <input
                            type="text"
                            data-bind="name"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                            placeholder="John Doe"
                        />
                        <div data-error="name" class="mt-1 text-sm text-red-600"></div>
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
                        <input
                            type="email"
                            data-bind="email"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                            placeholder="john@example.com"
                        />
                        <div data-error="email" class="mt-1 text-sm text-red-600"></div>
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Phone Number</label>
                        <input
                            type="tel"
                            data-bind="phone"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                            placeholder="+1 (555) 123-4567"
                        />
                        <div data-error="phone" class="mt-1 text-sm text-red-600"></div>
                    </div>
                </div>

                <div class="mt-6 flex justify-end">
                    <button
                        data-on:click="$_submitting = true; @postx('/onboarding/validate-step-1')"
                        class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                        Next
                    </button>
                </div>
            </div>

            <!-- Step 2: Account Setup -->
            <div data-show="$step === 2" class="bg-white rounded-lg shadow p-8">
                <h2 class="text-2xl font-bold mb-6">Account Setup</h2>

                <div class="space-y-4">
                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Username</label>
                        <input
                            type="text"
                            data-bind="username"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                            placeholder="johndoe"
                        />
                        <div data-error="username" class="mt-1 text-sm text-red-600"></div>
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Password</label>
                        <input
                            type="password"
                            data-bind="password"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                        />
                        <div data-error="password" class="mt-1 text-sm text-red-600"></div>
                    </div>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
                        <input
                            type="password"
                            data-bind="password_confirmation"
                            class="w-full px-3 py-2 border border-gray-300 rounded-md"
                        />
                        <div data-error="password_confirmation" class="mt-1 text-sm text-red-600"></div>
                    </div>
                </div>

                <div class="mt-6 flex justify-between">
                    <button
                        data-on:click="$step = 1"
                        class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50">
                        Previous
                    </button>
                    <button
                        data-on:click="$_submitting = true; @postx('/onboarding/validate-step-2')"
                        class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
                        Next
                    </button>
                </div>
            </div>

            <!-- Step 3: Preferences -->
            <div data-show="$step === 3" class="bg-white rounded-lg shadow p-8">
                <h2 class="text-2xl font-bold mb-6">Preferences</h2>

                <div class="space-y-4">
                    <label class="flex items-center">
                        <input type="checkbox" data-bind="notifications" class="rounded" />
                        <span class="ml-2 text-gray-700">Enable email notifications</span>
                    </label>

                    <label class="flex items-center">
                        <input type="checkbox" data-bind="newsletter" class="rounded" />
                        <span class="ml-2 text-gray-700">Subscribe to newsletter</span>
                    </label>

                    <div>
                        <label class="block text-sm font-medium text-gray-700 mb-2">Theme</label>
                        <select data-bind="theme" class="w-full px-3 py-2 border border-gray-300 rounded-md">
                            <option value="light">Light</option>
                            <option value="dark">Dark</option>
                            <option value="auto">Auto</option>
                        </select>
                    </div>
                </div>

                <div class="mt-6 flex justify-between">
                    <button
                        data-on:click="$step = 2"
                        class="px-6 py-2 border border-gray-300 rounded-md hover:bg-gray-50">
                        Previous
                    </button>
                    <button
                        data-on:click="$_submitting = true; @postx('/onboarding/complete')"
                        data-attr:disabled="$_submitting"
                        class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700">
                        <span data-show="!$_submitting">Complete</span>
                        <span data-show="$_submitting">Submitting...</span>
                    </button>
                </div>
            </div>
        </div>
    </div>
</body>
</html>

Controller

Create app/Http/Controllers/OnboardingController.php:

php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Dancycodes\Hyper\Routing\Attributes\Route;

class OnboardingController extends Controller
{
    public function show()
    {
        return view('onboarding.wizard');
    }

    public function validateStep1()
    {
        signals()->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'phone' => 'required|string|max:20',
        ]);

        return hyper()->signals([
            'step' => 2,
            '_submitting' => false
        ]);
    }

    public function validateStep2()
    {
        signals()->validate([
            'username' => 'required|string|min:3|max:50|unique:users,username',
            'password' => 'required|string|min:8|confirmed',
        ]);

        return hyper()->signals([
            'step' => 3,
            'errors' => [],
            '_submitting' => false
        ]);
    }

    public function complete()
    {
        $validated = signals()->validate([
            'name' => 'required|string|max:255',
            'email' => 'required|email|unique:users,email',
            'phone' => 'required|string|max:20',
            'username' => 'required|string|min:3|max:50|unique:users,username',
            'password' => 'required|string|min:8',
            'notifications' => 'boolean',
            'newsletter' => 'boolean',
            'theme' => 'required|in:light,dark,auto',
        ]);

        $user = User::create([
            'name' => $validated['name'],
            'email' => $validated['email'],
            'phone' => $validated['phone'],
            'username' => $validated['username'],
            'password' => Hash::make($validated['password']),
            'settings' => [
                'notifications' => $validated['notifications'],
                'newsletter' => $validated['newsletter'],
                'theme' => $validated['theme'],
            ],
        ]);

        return hyper()->redirect('dashboard.index');
    }
}

Routing

In the web.php

php
use App\Http\Controllers\OnboardingController;

Route::get('/onboarding', [OnboardingController::class, 'show']);
Route::post('/onboarding/validate-step-1', [OnboardingController::class, 'validateStep1']);
Route::post('/onboarding/validate-step-2', [OnboardingController::class, 'validateStep2']);
Route::post('/onboarding/complete', [OnboardingController::class, 'complete']);

Previous buttons simply update the step signal:

blade
<button data-on:click="$step = 2">Previous</button>

This is instant since no server request is needed - all data persists in signals.

Key Takeaways

1. State Preservation: All form data stays in signals throughout the wizard, no need for complex state management.

2. Conditional Rendering:data-show (Datastar) provides clean step switching without page reloads.

3. Progressive Validation: Validating per step provides better UX than validating everything at the end.