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:
<!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
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
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']);Navigation
Previous buttons simply update the step signal:
<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.

