Upgrading from Livewire 3 to Livewire 4: A Practical Migration Guide
Livewire 4 landed in early 2026 and most of my apps needed upgrading within weeks. The official docs list the changes well enough, but they don't tell you which ones will actually bite you in production. This guide covers the full upgrade process — start to finish — for a real Laravel app.
What's new in Livewire 4?
Before touching the code, it's worth knowing what you're getting. The headline features are native single-file components (no more Volt dependency for SFCs), reworked wire:model timing, and a handful of new directives.
Single-file components are now built into Livewire itself. You write an anonymous class and a Blade template in the same .blade.php file:
<?php
use Livewire\Component;
new class extends Component {
public string $title = '';
public function save(): void
{
Post::create(['title' => $this->title]);
$this->reset('title');
}
};
?>
<div>
<input type="text" wire:model="title" placeholder="Post title">
<button wire:click="save">Save</button>
</div>
Files live in resources/views/livewire/ alongside your existing class-based components. The optional ⚡ prefix in the filename (⚡create.blade.php) is a convention — disable it in config if you'd rather not have it.
Other notable additions:
wire:sortfor drag-and-drop list reorderingwire:intersectfor intersection-observer triggers (lazy loading, analytics)wire:reffor direct JavaScript element references#[Defer]attribute for deferred property loading.asyncmodifier on actions- Livewire Islands for isolated, independently-updating regions
Before you upgrade: create a checklist
Check your requirements before running composer. Livewire 4 requires PHP 8.1+ and supports Laravel 10, 11, and 12. If you're on PHP 8.0 or below, sort that first.
Run through this before you touch anything:
# Check current versions
php artisan --version
php --version
composer show livewire/livewire | grep versions
Scan your codebase for the patterns that will need changing. These are the ones that won't throw errors but will silently change behaviour:
# Find wire:model with blur or change (behaviour changed in v4)
grep -r "wire:model\.blur\|wire:model\.change" resources/
# Find unclosed livewire tags
grep -r "<livewire:" resources/ | grep -v "/>"
# Find wire:scroll usage
grep -r "wire:scroll" resources/
# Find Volt usage
grep -r "Livewire\\\\Volt" app/ routes/
Make a note of everything that comes back. You'll fix each one below.
Running the upgrade
composer require livewire/livewire:^4.0
php artisan optimize:clear
Publish the updated config:
php artisan vendor:publish --tag=livewire:config --force
Or merge manually — the key renames are:
// config/livewire.php
// v3
'layout' => 'layouts.app',
'lazy_placeholder' => null,
// v4
'component_layout' => 'layouts::app', // note the :: namespace
'component_placeholder' => null,
The smart_wire_keys option also now defaults to true in v4, which is what you want for stable list diffing. Leave it.
Fixing breaking changes one by one
wire:model — the most common trip-up
This is the one that caused the most issues in my apps. In Livewire 3, .blur and .change only controlled when the network request fired. The client-side $wire.property was always synced in real-time as you typed.
In Livewire 4, those modifiers control when client state syncs too. That means if you had an input that validated live-as-you-type using wire:model.blur, the validation no longer runs until the user leaves the field — and $wire.title won't update until then either.
If you want the old behaviour, add .live:
{{-- v3 — fires network request on blur, but syncs client state live --}}
<input wire:model.blur="email">
{{-- v4 equivalent — syncs client state live, sends request on blur --}}
<input wire:model.live.blur="email">
{{-- v4 — delays both client sync and network request until blur --}}
<input wire:model.blur="email">
For most validation-as-you-type patterns, you probably want wire:model.live.debounce.300ms anyway:
<input
type="email"
wire:model.live.debounce.300ms="email"
placeholder="Your email"
>
Component tags must close
Livewire 4 added slot support, so it treats unclosed tags as open containers for slot content. This breaks silently — the component just won't render properly.
{{-- v3 — worked fine --}}
<livewire:user-avatar :user="$user">
{{-- v4 — self-close it --}}
<livewire:user-avatar :user="$user" />
wire:scroll renamed
{{-- v3 --}}
<div class="overflow-y-scroll h-64" wire:scroll>
{{-- v4 --}}
<div class="overflow-y-scroll h-64" wire:navigate:scroll>
The stream() method signature changed
If you're using streaming responses (Livewire's SSE-like feature for AI outputs), the parameter order flipped:
// v3
$this->stream(to: '#output', content: $chunk, replace: false);
// v4
$this->stream($chunk, replace: false, el: '#output');
Routing — use Route::livewire()
Still works with Route::get(), but the v4 way is cleaner and required if you're using Livewire's new full-page component features:
// v3
Route::get('/dashboard', Dashboard::class);
// v4
Route::livewire('/dashboard', Dashboard::class);
Livewire URL hash prefix
Livewire's internal update endpoint now includes a hash of your APP_KEY:
# v3
/livewire/update
# v4
/livewire-abc123def456/update
If you have firewall rules, CSP headers, or middleware that reference /livewire/ literally, update them. If you're using setUpdateRoute(), pass through the dynamic $path:
Livewire::setUpdateRoute(function ($handle, $path) {
return Route::post($path, $handle)->middleware('api');
});
Migrating Volt to native SFCs
If you're using Volt for SFCs, v4 makes Volt optional — Livewire now ships its own SFC format that's nearly identical. I'd migrate away from Volt; one less dependency.
A Volt component in v3:
<?php
// resources/views/livewire/create-post.blade.php
use function Livewire\Volt\{state, action};
state(['title' => '']);
$save = action(function () {
Post::create(['title' => $this->title]);
$this->reset('title');
});
?>
<div>
<input type="text" wire:model="title">
<button wire:click="save">Save</button>
</div>
The equivalent Livewire 4 native SFC:
<?php
// resources/views/livewire/create-post.blade.php
use Livewire\Component;
new class extends Component {
public string $title = '';
public function save(): void
{
Post::create(['title' => $this->title]);
$this->reset('title');
}
};
?>
<div>
<input type="text" wire:model="title">
<button wire:click="save">Save</button>
</div>
Update your routes. Volt::route() becomes Route::livewire():
// v3
use Livewire\Volt\Volt;
Volt::route('/posts/create', 'create-post');
// v4
Route::livewire('/posts/create', 'create-post');
Remove the Volt service provider from config/app.php if you registered it manually, then uninstall the package:
composer remove livewire/volt
Testing after migration
Livewire 4's testing API is essentially unchanged from v3, which is a relief. If you had Volt::test() calls, swap them for Livewire::test():
// v3 — Volt test
Volt::test('create-post')
->set('title', 'Hello world')
->call('save')
->assertHasNoErrors();
// v4
use Livewire\Livewire;
Livewire::test('create-post')
->set('title', 'Hello world')
->call('save')
->assertHasNoErrors();
For class-based components, reference the class directly:
Livewire::test(CreatePost::class)
->set('title', 'Hello world')
->call('save')
->assertDispatched('post-saved')
->assertSet('title', ''); // confirm reset
Run your Pest suite after each section of changes, not just at the end. It's faster to catch regressions as you go.
Gotchas and Edge Cases
Array replacement hooks fire differently. In v3, setting $wire.items = ['a', 'b'] on a 4-item array would trigger updatingItems/updatedItems once per index change. In v4, it fires once with the full new array. If your hooks log granular changes or trigger side effects per-index, audit them.
wire:transition is broken differently. The .opacity, .scale, and .duration.200ms modifiers are gone — Livewire 4 delegates to the browser's View Transitions API. Basic wire:transition still works; custom easing doesn't. If you're relying on specific transition modifiers, you'll need to move that to CSS.
AlpineJS interaction with wire:model. Some packages (notably Flux) had issues with wire:model.live.change and wire:model.change on select elements and pill inputs through early 4.1 releases. Check your package versions and pin to the latest if you see stale values in select-like components.
The config publish is destructive. Running vendor:publish --force overwrites your existing config/livewire.php. Diff it first or merge the new keys manually.
Wrapping Up
The upgrade is straightforward if you tackle the changes methodically. Do the composer update first, fix the config, then work through the grep results from your pre-upgrade checklist. The wire:model change catches the most people — if anything looks broken after the upgrade, start there. With PHP 8.4's property hooks and Livewire 4's SFCs, there's genuinely less boilerplate in new components than there was a year ago — worth the upgrade.
Steven is a software engineer with a passion for building scalable web applications. He enjoys sharing his knowledge through articles and tutorials.