A multi-step form wizard in Livewire keeps the current step number and all field values as public properties of one component. The Next button validates only the fields of the current step before moving on, and the final button validates everything and saves. This tutorial builds a three-step sign-up wizard for Laravel 13 with a Livewire 4 single-file component.
Targets: Laravel 13 (PHP 8.3 to 8.5) and Livewire 4. The component uses the single-file format that the Livewire 4 documentation shows: a PHP class and its Blade template in the same file.
What will the wizard do?
- Step 1: name and email.
- Step 2: company and plan.
- Step 3: review and confirm.
At the end it saves a record to a leads table. You can replace that with any model you like.
Step 1: Create the model
php artisan make:model Lead -mSchema::create('leads', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email');
$table->string('company')->nullable();
$table->string('plan');
$table->timestamps();
});// app/Models/Lead.php
protected $fillable = ['name', 'email', 'company', 'plan'];Run php artisan migrate and make sure Livewire is installed (composer require livewire/livewire).
Step 2: Create the wizard component
Create the file resources/views/components/⚡signup-wizard.blade.php (you can also try php artisan make:livewire signup-wizard, which creates a component in the format your Livewire version uses):
<?php
use App\Models\Lead;
use Livewire\Component;
new class extends Component {
public int $step = 1;
public string $name = '';
public string $email = '';
public string $company = '';
public string $plan = 'basic';
public bool $saved = false;
protected function rulesForStep(int $step): array
{
return match ($step) {
1 => [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255'],
],
2 => [
'company' => ['nullable', 'string', 'max:255'],
'plan' => ['required', 'in:basic,pro,team'],
],
default => [],
};
}
public function next(): void
{
$this->validate($this->rulesForStep($this->step));
$this->step = min($this->step + 1, 3);
}
public function previous(): void
{
$this->step = max($this->step - 1, 1);
}
public function save(): void
{
$this->validate($this->rulesForStep(1) + $this->rulesForStep(2));
Lead::create($this->only(['name', 'email', 'company', 'plan']));
$this->saved = true;
}
};
?>
<div>
@if ($saved)
<p>Thank you, {{ $name }}. Your details were saved.</p>
@else
<p>Step {{ $step }} of 3</p>
@if ($step === 1)
<input type="text" wire:model="name" placeholder="Name">
@error('name') <span>{{ $message }}</span> @enderror
<input type="email" wire:model="email" placeholder="Email">
@error('email') <span>{{ $message }}</span> @enderror
@elseif ($step === 2)
<input type="text" wire:model="company" placeholder="Company (optional)">
@error('company') <span>{{ $message }}</span> @enderror
<select wire:model="plan">
<option value="basic">Basic</option>
<option value="pro">Pro</option>
<option value="team">Team</option>
</select>
@error('plan') <span>{{ $message }}</span> @enderror
@else
<ul>
<li>Name: {{ $name }}</li>
<li>Email: {{ $email }}</li>
<li>Company: {{ $company ?: '-' }}</li>
<li>Plan: {{ $plan }}</li>
</ul>
@endif
<div>
@if ($step > 1)
<button type="button" wire:click="previous">Back</button>
@endif
@if ($step < 3)
<button type="button" wire:click="next">Next</button>
@else
<button type="button" wire:click="save">Confirm and save</button>
@endif
</div>
@endif
</div>Display it in any Blade view with <livewire:signup-wizard />.
How does it work?
- State: all values live in public properties, so Livewire keeps them between requests and across steps.
- Validation per step:
rulesForStep()returns the rules for one step.next()validates only those fields, so the user is not blocked by fields from steps they have not seen. - Final check:
save()validates the rules of steps 1 and 2 again. Never rely on step validation alone, because a user can change a public property value in the browser. - Safe saving:
$this->only([...])picks the listed properties, so only the intended data goes intoLead::create().
How do I show a progress bar?
<div style="background: #eee; height: 8px;">
<div style="background: #0d6efd; height: 8px; width: {{ ($step / 3) * 100 }}%;"></div>
</div>Add it above the step text. The width changes whenever the step changes.
How do I check that it works?
- Click Next on step 1 with empty fields. You should see the validation messages and stay on step 1.
- Fill the fields and move to step 2, then use Back and confirm your values are still there.
- Reach step 3, click Confirm and check the
leadstable for the new row.
Common mistakes
- Validating all fields on every Next: users get errors for fields they cannot see yet. Validate by step.
- Skipping the final validation: public properties can be tampered with. Validate again in
save(). - Using
wire:model.liveeverywhere: it sends a request on each keystroke. Plainwire:modelsends values with the next action, which is lighter. - Putting secrets in public properties: public properties are visible to the browser.
- Following Livewire 3 file layout: Livewire 3 keeps the class in
app/Livewireand the view separate. The logic is the same.
FAQ
Can the wizard keep progress after a page refresh?
You can store the step and values in the session or the database, or use Livewire's URL binding for the step number. Do not put sensitive data in the URL.
Can I split steps into child components?
Yes, for large wizards. Keep the shared state and the final save in the parent and pass data down.
Can I upload files in a step?
Yes. Livewire supports file uploads with its WithFileUploads trait. See the Livewire documentation for the current usage.



