State persistence across steps, validation per step without losing previous data, progress indicators, back navigation that doesn’t clear fields, file uploads mid-flow, and the session strategy that survives browser refreshes — the complete implementation with real code.
Multi-step forms are the feature that looks simple and isn’t. The basic implementation — a Livewire component with a $step counter and a nextStep() method — takes an afternoon. The implementation that actually feels good to use takes longer, because “feels good to use” has specific requirements: going back doesn’t clear what you already filled in, a browser refresh doesn’t lose your progress, validation gives you feedback at the right moment rather than all at once, and the progress indicator tells you where you are without being distracting. This post is the complete implementation: all of those requirements, with the specific code that makes each one work.
The Architecture Decision: One Component or Many?
Before any code: the choice between one Livewire component that manages all steps and separate Livewire components per step determines how you handle shared state, validation, and navigation.
Option A: Single component, all steps
→ One PHP class manages all state
→ Simple to navigate between steps
→ All step data is in one Livewire component's properties
→ Gets large for forms with many fields
Option B: Parent component + child components per step
→ Each step is a separate Livewire component
→ Step state is isolated per component
→ Parent handles navigation and shared state
→ More files, more flexible for complex steps
For most multi-step forms (under 30 total fields, under 6 steps), Option A produces simpler, more maintainable code. Option B is right when individual steps have complex, self-contained logic that benefits from isolation — a step that’s itself an embedded wizard, or a step with heavy real-time validation that would clutter the parent.
This post uses Option A.
The Component Structure
<?php
namespace App\Livewire;
use App\Models\Project;
use Illuminate\Support\Facades\Storage;
use Livewire\Attributes\{Computed, Validate, On};
use Livewire\Component;
use Livewire\WithFileUploads;
class CreateProjectWizard extends Component
{
use WithFileUploads;
// Navigation
public int $currentStep = 1;
public int $totalSteps = 4;
public array $completedSteps = [];
// Step 1: Project basics
public string $name = '';
public string $description = '';
public string $type = '';
// Step 2: Team and ownership
public ?int $ownerId = null;
public array $memberIds = [];
public string $startDate = '';
public string $dueDate = '';
// Step 3: Configuration
public string $visibility = 'private';
public array $features = [];
public string $budgetAmount = '';
public string $budgetCurrency = 'USD';
// Step 4: Assets (file uploads)
public $briefDocument = null;
public $coverImage = null;
// UI state
public bool $isSubmitting = false;
public ?string $submissionError = null;
// Validation rules per step
protected array $stepRules = [
1 => [
'name' => ['required', 'string', 'min:3', 'max:255'],
'description' => ['required', 'string', 'min:10', 'max:5000'],
'type' => ['required', 'in:software,marketing,design,research,other'],
],
2 => [
'ownerId' => ['required', 'exists:users,id'],
'memberIds' => ['nullable', 'array'],
'memberIds.*' => ['exists:users,id'],
'startDate' => ['required', 'date', 'after_or_equal:today'],
'dueDate' => ['required', 'date', 'after:startDate'],
],
3 => [
'visibility' => ['required', 'in:private,team,public'],
'features' => ['nullable', 'array'],
'budgetAmount' => ['nullable', 'numeric', 'min:0'],
'budgetCurrency' => ['required', 'in:USD,EUR,GBP,INR'],
],
4 => [
'briefDocument' => ['nullable', 'file', 'mimes:pdf,doc,docx', 'max:10240'],
'coverImage' => ['nullable', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
],
];
// Validation messages
protected function messages(): array
{
return [
'name.required' => 'Your project needs a name.',
'name.min' => 'The name must be at least 3 characters.',
'dueDate.after' => 'The due date must be after the start date.',
'ownerId.required' => 'Please assign a project owner.',
'ownerId.exists' => 'The selected owner does not exist.',
'briefDocument.max' => 'The brief document must be under 10MB.',
'coverImage.max' => 'The cover image must be under 2MB.',
];
}
}
Mounting With Session Restoration
The mount method restores state from the session when available — enabling the form to survive browser refreshes.
public function mount(): void
{
$this->restoreFromSession();
}
private function restoreFromSession(): void
{
$saved = session()->get($this->sessionKey());
if (!$saved) return;
// Restore only the properties that can be safely hydrated from session
// File uploads are NOT restored — they require re-upload
$restorable = [
'currentStep', 'completedSteps',
'name', 'description', 'type',
'ownerId', 'memberIds', 'startDate', 'dueDate',
'visibility', 'features', 'budgetAmount', 'budgetCurrency',
];
foreach ($restorable as $property) {
if (isset($saved[$property])) {
$this->$property = $saved[$property];
}
}
}
private function sessionKey(): string
{
// Unique per user so sessions don't bleed between users
return 'wizard:create-project:' . auth()->id();
}
private function saveToSession(): void
{
session()->put($this->sessionKey(), [
'currentStep' => $this->currentStep,
'completedSteps' => $this->completedSteps,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'ownerId' => $this->ownerId,
'memberIds' => $this->memberIds,
'startDate' => $this->startDate,
'dueDate' => $this->dueDate,
'visibility' => $this->visibility,
'features' => $this->features,
'budgetAmount' => $this->budgetAmount,
'budgetCurrency' => $this->budgetCurrency,
]);
}
private function clearSession(): void
{
session()->forget($this->sessionKey());
}
Step Navigation — The Core Logic
public function nextStep(): void
{
// Validate only the current step's fields
$this->validateCurrentStep();
// Mark the current step as completed
if (!in_array($this->currentStep, $this->completedSteps)) {
$this->completedSteps[] = $this->currentStep;
}
// Advance
if ($this->currentStep < $this->totalSteps) {
$this->currentStep++;
}
// Save state after each step advance
$this->saveToSession();
// Scroll to top of form
$this->dispatch('step-changed');
}
public function previousStep(): void
{
if ($this->currentStep > 1) {
$this->currentStep--;
$this->saveToSession();
$this->dispatch('step-changed');
}
}
public function goToStep(int $step): void
{
// Only allow navigating to completed steps or the current step
// Prevent skipping ahead to steps that haven't been validated
if ($step <= $this->currentStep || in_array($step, $this->completedSteps)) {
$this->currentStep = $step;
$this->dispatch('step-changed');
}
}
private function validateCurrentStep(): void
{
$rules = $this->stepRules[$this->currentStep] ?? [];
if (empty($rules)) return;
$this->validate($rules, $this->messages());
}
The validateCurrentStep() method validates only the fields relevant to the current step. Moving forward validates the current step’s fields. Moving backward doesn’t validate anything — the user can always go back without losing their data or triggering validation errors.
Per-Field Real-Time Validation
Real-time validation (validating as the user types) should only validate the field being edited, not all fields on the step. Validating all fields on every keystroke shows errors for fields the user hasn’t touched yet.
// Validate a specific field when it changes
// Called from the template with wire:change or wire:blur
public function validateField(string $field): void
{
$allRules = array_merge(...array_values($this->stepRules));
if (!isset($allRules[$field])) return;
$this->validateOnly($field, $allRules, $this->messages());
}
In the template:
<input
type="text"
wire:model="name"
wire:blur="validateField('name')"
class="..."
>
@error('name')
<span class="text-red-500 text-sm">{{ $message }}</span>
@enderror
wire:blur="validateField('name')" triggers validation when the user leaves the field — not on every keystroke. This is the right moment: the user has finished typing and is moving on, so showing an error now is helpful rather than interrupting.
The Progress Indicator
#[Computed]
public function progressPercentage(): int
{
return (int) (($this->currentStep - 1) / ($this->totalSteps - 1) * 100);
}
#[Computed]
public function stepLabels(): array
{
return [
1 => ['label' => 'Basics', 'icon' => 'folder'],
2 => ['label' => 'Team', 'icon' => 'users'],
3 => ['label' => 'Settings', 'icon' => 'cog'],
4 => ['label' => 'Assets', 'icon' => 'upload'],
];
}
{{-- Progress indicator --}}
<div class="mb-8">
{{-- Step bubbles --}}
<div class="flex items-center justify-between mb-4">
@foreach($this->stepLabels as $step => $info)
<div
wire:click="goToStep({{ $step }})"
@class([
'flex flex-col items-center cursor-pointer',
'opacity-50 cursor-not-allowed' =>
$step > $currentStep && !in_array($step, $completedSteps),
])
>
<div @class([
'w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium border-2 transition-all',
'bg-blue-600 border-blue-600 text-white' => $step === $currentStep,
'bg-green-500 border-green-500 text-white' => in_array($step, $completedSteps) && $step !== $currentStep,
'bg-white border-gray-300 text-gray-400' => $step > $currentStep && !in_array($step, $completedSteps),
])>
@if(in_array($step, $completedSteps) && $step !== $currentStep)
{{-- Checkmark for completed steps --}}
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
@else
{{ $step }}
@endif
</div>
<span class="text-xs mt-1 font-medium @if($step === $currentStep) text-blue-600 @else text-gray-400 @endif">
{{ $info['label'] }}
</span>
</div>
{{-- Connector line between steps --}}
@if($step < $totalSteps)
<div class="flex-1 h-0.5 mx-2 @if(in_array($step, $completedSteps)) bg-green-500 @else bg-gray-200 @endif"></div>
@endif
@endforeach
</div>
{{-- Progress bar --}}
<div class="w-full bg-gray-200 rounded-full h-1.5">
<div
class="bg-blue-600 h-1.5 rounded-full transition-all duration-300"
style="width: {{ $this->progressPercentage }}%"
></div>
</div>
</div>
The step bubbles are clickable for completed steps and the current step. Future unvisited steps are visually disabled and non-clickable (cursor-not-allowed). The connector line between steps turns green when the step to its left is completed.
Step 1: Project Basics
@if($currentStep === 1)
<div>
<h2 class="text-xl font-semibold mb-6">Project Basics</h2>
<div class="space-y-5">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Project Name <span class="text-red-500">*</span>
</label>
<input
type="text"
wire:model="name"
wire:blur="validateField('name')"
placeholder="e.g. Website Redesign 2026"
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 @error('name') border-red-500 @enderror"
>
@error('name')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Description <span class="text-red-500">*</span>
</label>
<textarea
wire:model="description"
wire:blur="validateField('description')"
rows="4"
placeholder="What is this project about?"
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 @error('description') border-red-500 @enderror"
></textarea>
<div class="flex justify-between mt-1">
@error('description')
<p class="text-sm text-red-600">{{ $message }}</p>
@else
<span></span>
@enderror
<span class="text-xs text-gray-400">{{ strlen($description) }} / 5000</span>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Project Type <span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-3 gap-3">
@foreach(['software' => 'Software', 'marketing' => 'Marketing', 'design' => 'Design', 'research' => 'Research', 'other' => 'Other'] as $value => $label)
<button
type="button"
wire:click="$set('type', '{{ $value }}')"
@class([
'px-4 py-3 rounded-lg border-2 text-sm font-medium transition-all',
'border-blue-500 bg-blue-50 text-blue-700' => $type === $value,
'border-gray-200 text-gray-600 hover:border-gray-300' => $type !== $value,
])
>
{{ $label }}
</button>
@endforeach
</div>
@error('type')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
</div>
@endif
File Uploads Mid-Flow (Step 4)
File uploads in Livewire use the WithFileUploads trait. The key challenge in a multi-step form is that uploaded files are temporary until submission — they need to be stored properly only at the final submit.
@if($currentStep === 4)
<div>
<h2 class="text-xl font-semibold mb-6">Project Assets</h2>
<p class="text-gray-500 text-sm mb-6">
Upload supporting documents. Both files are optional — you can add them later.
</p>
<div class="space-y-6">
{{-- Brief Document --}}
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Project Brief (PDF, Word — max 10MB)
</label>
@if($briefDocument)
<div class="flex items-center p-3 bg-green-50 border border-green-200 rounded-lg mb-3">
<svg class="w-5 h-5 text-green-500 mr-2 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-green-700 truncate">{{ $briefDocument->getClientOriginalName() }}</p>
<p class="text-xs text-green-500">{{ number_format($briefDocument->getSize() / 1024, 1) }}KB</p>
</div>
<button type="button" wire:click="$set('briefDocument', null)" class="ml-2 text-green-400 hover:text-green-600">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
@endif
<div
x-data="{ dragging: false }"
x-on:dragover.prevent="dragging = true"
x-on:dragleave.prevent="dragging = false"
x-on:drop.prevent="dragging = false"
:class="dragging ? 'border-blue-400 bg-blue-50' : 'border-gray-300 hover:border-gray-400'"
class="border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors"
>
<input
type="file"
wire:model="briefDocument"
accept=".pdf,.doc,.docx"
class="hidden"
id="briefDocument"
>
<label for="briefDocument" class="cursor-pointer">
<svg class="w-8 h-8 text-gray-400 mx-auto mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<p class="text-sm text-gray-500">
<span class="text-blue-600 font-medium">Click to upload</span> or drag and drop
</p>
<p class="text-xs text-gray-400 mt-1">PDF, DOC, DOCX up to 10MB</p>
</label>
</div>
{{-- Upload progress --}}
<div wire:loading wire:target="briefDocument" class="mt-2">
<div class="flex items-center text-sm text-blue-600">
<svg class="animate-spin w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Uploading...
</div>
</div>
@error('briefDocument')
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
@enderror
</div>
</div>
</div>
@endif
Submission — Final Validation and Persistence
public function submit(): void
{
// Validate all steps at once before submitting
$allRules = array_merge(...array_values($this->stepRules));
$this->validate($allRules, $this->messages());
$this->isSubmitting = true;
$this->submissionError = null;
try {
$project = DB::transaction(function () {
// Handle file uploads
$briefPath = null;
$coverPath = null;
if ($this->briefDocument) {
$briefPath = $this->briefDocument->store(
'projects/briefs',
'private' // Store in private disk — not publicly accessible
);
}
if ($this->coverImage) {
$coverPath = $this->coverImage->store(
'projects/covers',
'public' // Cover images are public
);
}
// Create the project
$project = Project::create([
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'owner_id' => $this->ownerId,
'start_date' => $this->startDate,
'due_date' => $this->dueDate,
'visibility' => $this->visibility,
'features' => $this->features,
'budget_amount' => $this->budgetAmount ?: null,
'budget_currency' => $this->budgetCurrency,
'brief_path' => $briefPath,
'cover_path' => $coverPath,
'tenant_id' => auth()->user()->current_tenant_id,
'created_by' => auth()->id(),
]);
// Attach team members
if (!empty($this->memberIds)) {
$project->members()->attach($this->memberIds, [
'role' => 'member',
'joined_at' => now(),
]);
}
// Always attach the owner
$project->members()->syncWithoutDetaching([
$this->ownerId => ['role' => 'owner', 'joined_at' => now()]
]);
return $project;
});
// Clear the session after successful submission
$this->clearSession();
// Redirect to the new project
$this->redirect(route('projects.show', $project), navigate: true);
} catch (\Exception $e) {
$this->isSubmitting = false;
$this->submissionError = 'Something went wrong. Please try again.';
report($e);
}
}
The Navigation Footer — Consistent Across All Steps
{{-- Navigation buttons — same across all steps --}}
<div class="flex items-center justify-between pt-6 mt-6 border-t border-gray-200">
<div>
@if($currentStep > 1)
<button
type="button"
wire:click="previousStep"
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back
</button>
@else
<a href="{{ route('projects.index') }}" class="text-sm text-gray-500 hover:text-gray-700">
Cancel
</a>
@endif
</div>
<div class="flex items-center space-x-3">
{{-- Step indicator text --}}
<span class="text-sm text-gray-400">Step {{ $currentStep }} of {{ $totalSteps }}</span>
@if($currentStep < $totalSteps)
<button
type="button"
wire:click="nextStep"
wire:loading.attr="disabled"
wire:target="nextStep"
class="inline-flex items-center px-5 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<span wire:loading.remove wire:target="nextStep">
Next
<svg class="w-4 h-4 ml-2 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
</span>
<span wire:loading wire:target="nextStep">
Validating...
</span>
</button>
@else
<button
type="button"
wire:click="submit"
wire:loading.attr="disabled"
wire:target="submit"
class="inline-flex items-center px-6 py-2 text-sm font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50 transition-colors"
>
<span wire:loading.remove wire:target="submit">
Create Project
<svg class="w-4 h-4 ml-2 inline" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
</span>
<span wire:loading wire:target="submit">
Creating...
</span>
</button>
@endif
</div>
</div>
{{-- Submission error --}}
@if($submissionError)
<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p class="text-sm text-red-600">{{ $submissionError }}</p>
</div>
@endif
The Scroll-to-Top on Step Change
When the step changes, the form should scroll back to the top. This is handled via a JavaScript event listener:
{{-- In the component template --}}
<div
x-data
x-on:step-changed.window="$el.scrollIntoView({ behavior: 'smooth', block: 'start' })"
>
{{-- wizard content --}}
</div>
The $this->dispatch('step-changed') in nextStep() and previousStep() fires this event. The form container scrolls to the top smoothly on every navigation. Without this, if the user scrolls down to fill in fields and then clicks Next, they see the bottom of the next step instead of the top.
The Complete Component Template
<div
class="max-w-2xl mx-auto"
x-data
x-on:step-changed.window="window.scrollTo({ top: $el.offsetTop - 20, behavior: 'smooth' })"
>
{{-- Session restore notification --}}
@if(count($completedSteps) > 0)
<div class="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
<p class="text-sm text-blue-700">
We restored your progress. You were on step {{ $currentStep }}.
</p>
<button
type="button"
wire:click="resetWizard"
class="text-xs text-blue-500 hover:text-blue-700 ml-4"
>
Start over
</button>
</div>
@endif
{{-- Progress indicator --}}
{{-- ... (shown above) --}}
{{-- Step content --}}
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-6">
@if($currentStep === 1)
{{-- ... step 1 content --}}
@elseif($currentStep === 2)
{{-- ... step 2 content --}}
@elseif($currentStep === 3)
{{-- ... step 3 content --}}
@elseif($currentStep === 4)
{{-- ... step 4 content --}}
@endif
{{-- Navigation footer --}}
{{-- ... (shown above) --}}
</div>
</div>
The Reset Method
Users who restore from session get a “Start over” option:
public function resetWizard(): void
{
// Clear all state
$this->currentStep = 1;
$this->completedSteps = [];
$this->name = '';
$this->description = '';
$this->type = '';
$this->ownerId = null;
$this->memberIds = [];
$this->startDate = '';
$this->dueDate = '';
$this->visibility = 'private';
$this->features = [];
$this->budgetAmount = '';
$this->briefDocument = null;
$this->coverImage = null;
$this->submissionError = null;
// Clear session
$this->clearSession();
// Reset validation errors
$this->resetValidation();
}
The Route and View Registration
// routes/web.php
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/projects/create', CreateProjectWizard::class)
->name('projects.create');
});
{{-- resources/views/livewire/create-project-wizard.blade.php --}}
<div>
{{-- full template as shown above --}}
</div>
Testing the Wizard
// tests/Feature/CreateProjectWizardTest.php
use App\Livewire\CreateProjectWizard;
use Livewire\Livewire;
it('starts on step 1', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->assertSet('currentStep', 1);
});
it('validates step 1 before advancing', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->call('nextStep')
->assertHasErrors(['name', 'description', 'type'])
->assertSet('currentStep', 1); // Did not advance
});
it('advances to step 2 with valid step 1 data', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->set('name', 'My Project')
->set('description', 'A detailed description of the project that meets the minimum.')
->set('type', 'software')
->call('nextStep')
->assertHasNoErrors()
->assertSet('currentStep', 2);
});
it('can go back without validation errors', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->set('name', 'My Project')
->set('description', 'A long enough description to pass the minimum.')
->set('type', 'software')
->call('nextStep')
->assertSet('currentStep', 2)
->call('previousStep')
->assertSet('currentStep', 1)
->assertHasNoErrors();
});
it('preserves step 1 data after navigating to step 2 and back', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->set('name', 'My Project')
->set('description', 'A long enough description to pass the minimum.')
->set('type', 'software')
->call('nextStep')
->call('previousStep')
->assertSet('name', 'My Project')
->assertSet('description', 'A long enough description to pass the minimum.')
->assertSet('type', 'software');
});
it('restores from session on mount', function () {
$user = User::factory()->create();
session()->put("wizard:create-project:{$user->id}", [
'currentStep' => 3,
'completedSteps' => [1, 2],
'name' => 'Restored Project',
'description' => 'Restored description.',
'type' => 'design',
'ownerId' => null,
'memberIds' => [],
'startDate' => '',
'dueDate' => '',
'visibility' => 'private',
'features' => [],
'budgetAmount' => '',
'budgetCurrency' => 'USD',
]);
Livewire::actingAs($user)
->test(CreateProjectWizard::class)
->assertSet('currentStep', 3)
->assertSet('name', 'Restored Project')
->assertSet('completedSteps', [1, 2]);
});
What Makes This “Feel Good to Use”
The technical implementation enables four specific UX qualities:
Back navigation that doesn’t clear data. All state is in Livewire component properties. Going back with previousStep() only changes $currentStep. Every field retains its value.
Browser refresh that doesn’t lose progress. saveToSession() is called after every step navigation. On mount, restoreFromSession() restores the state. A user who accidentally closes and reopens the tab comes back to step 3 with steps 1 and 2 already filled in.
Validation at the right moment. validateCurrentStep() only validates the current step’s fields. wire:blur="validateField('name')" shows per-field errors only after the user leaves the field. The user never sees errors for fields they haven’t touched.
A progress indicator that communicates clearly. Blue for current, green with a checkmark for completed, grey for unvisited. Completed steps are clickable for easy review. The progress bar gives an at-a-glance percentage. The step counter in the footer (“Step 2 of 4”) gives the same information in words.
These aren’t difficult to implement individually. The difficulty is implementing all of them together without the state management getting tangled. Storing all step state as flat Livewire properties, validating per-step with $this->stepRules, and persisting to session after each navigation keeps them independent enough to implement cleanly.
