Livewire 3 vs 4 — what changed
Everything that changed between Livewire 3 and Livewire 4: the new features worth adopting, every breaking change with before-and-after code, and a checklist for the upgrade itself. Including the three changes that pass your test suite and break production.
1. The short version
Livewire 4 is a big release with a small upgrade path. The component model did not change: PHP class, Blade view, state on the server, HTML over the wire. What changed is where components live, how they are written, and how much of the page a single update touches.
What you gain
- Single-file components — class and markup in one file.
- Islands — regions that re-render independently, without splitting into child components.
- Slots — parents inject markup into children, evaluated in the parent's context.
- Optimistic UI —
wire:show,wire:text,wire:bindupdate the DOM with no round trip. - Drag and drop —
wire:sort, no external library. - Scoped CSS — component styles that cannot leak.
What will bite you
- Unclosed
<livewire:…>tags silently render nothing. wire:modelmodifiers changed meaning — fields stop syncing.- The update endpoint moved to
/livewire-{hash}/— infrastructure rules break. wire:transitionmodifiers were removed.- Volt folded into core.
Each of these is covered in detail below, with before-and-after code.
2. Side by side
| Area | Livewire 3 | Livewire 4 |
|---|---|---|
| Component files | app/Livewire/Counter.php + resources/views/livewire/counter.blade.php |
resources/views/components/⚡counter.blade.php (single file) |
| Make command | php artisan livewire:make Counter |
php artisan make:livewire counter |
| Routing | Route::get('/x', Dashboard::class) |
Route::livewire('/x', 'pages::dashboard') |
| Partial rendering | Split into child components | @island regions |
| Slots | Not supported | {{ $slot }}, <wire:slot name="…"> |
| Drag and drop | SortableJS plus glue code | wire:sort |
| Instant UI | Alpine.js by hand | wire:show, wire:text, wire:bind |
| Component CSS | Global stylesheet | Scoped <style> in the component |
| Parallel actions | Not available | #[Async], wire:click.async |
| Update endpoint | /livewire/update |
/livewire-{hash}/update |
| JS hooks | Livewire.hook('request'|'commit') |
interceptRequest(), interceptMessage() |
| Volt | Separate package | Folded into core |
| Testing | PHPUnit or Pest, tests/Feature/Livewire |
Pest recommended, tests can sit beside the component |
3. The upgrade, step by step
# 1. Bump the dependency
composer require livewire/livewire:^4.0
# 2. Clear every cache — stale compiled views are the usual first failure
php artisan optimize:clear
# 3. Run your test suite before touching anything else
php artisan testLaravel Shift can automate a large part of the mechanical work. Whether you use it or not, work through the checklist below afterwards — the silent failures are the ones a tool is least likely to flag.
Order of work that tends to go smoothly
- Upgrade the package and clear caches.
- Fix the config renames (chapter 4).
- Fix routing (chapter 5).
- Search the codebase for unclosed component tags (chapter 6).
- Audit every
wire:modelwith a modifier (chapter 7). - Update infrastructure rules for the new endpoint (chapter 8).
- Replace removed
wire:transitionmodifiers (chapter 9). - Migrate Volt components if you use Volt (chapter 12).
- Run the suite again, then click through the app in a browser.
- Only then start adopting islands, slots and single-file components.
4. Configuration renames
| Livewire 3 | Livewire 4 | Note |
|---|---|---|
layout | component_layout | Now uses the layouts:: namespace |
lazy_placeholder | component_placeholder | — |
smart_wire_keys | smart_wire_keys | Default flipped from false to true |
| — | component_locations | Directories scanned for components |
| — | component_namespaces | Named roots such as pages:: |
| — | make_command | Single-file or class-based generation |
| — | csp_safe | Build compatible with stricter CSP |
'layout' => 'layouts.app',
'lazy_placeholder' => 'livewire.placeholder','component_layout' => 'layouts::app',
'component_placeholder' => 'livewire.placeholder',
'component_locations' => [
'resources/views/components',
'resources/views/livewire',
],
'component_namespaces' => [
'pages' => 'resources/views/pages',
],
// Keep generating class-based components
'make_command' => [
'type' => 'class',
],5. Routing
Full-page components get a dedicated route macro.
use App\Livewire\Dashboard;
Route::get('/dashboard', Dashboard::class)
->middleware('auth')
->name('dashboard');use App\Livewire\Dashboard;
// By class
Route::livewire('/dashboard', Dashboard::class)
->middleware('auth')
->name('dashboard');
// Or by component name, for view-based components
Route::livewire('/dashboard', 'pages::dashboard')
->middleware('auth')
->name('dashboard');grep -rn "Route::get(.*::class" routes/, then check which
of those classes extend Livewire\Component.
6. Component tags must be closed
<livewire:user-profile>
<livewire:order-list><livewire:user-profile />
<livewire:order-list />Finding them
# Component tags that are not self-closed
grep -rn "<livewire:[^>]*[^/]>" resources/views/
# The @livewire() directive form is unaffected
grep -rn "@livewire(" resources/views/Once the app is running, a click-through of the main flows is worth the time: a missing block is easy to miss in a diff and obvious on screen.
7. wire:model changed twice
Two independent changes, both of which produce a field that "just stops working" rather than an
error. Audit every wire:model that carries a modifier.
7.1 Modifiers now control client-side syncing
In v3, .blur and .change decided when a network request fired. The
value itself was always tracked on the client. In v4 they decide when the value syncs at all.
Add .live to restore the old behaviour.
<input wire:model.blur="title">
<select wire:model.change="status"><input wire:model.live.blur="title">
<select wire:model.live.change="status">7.2 No more bubbling from child elements
wire:model on a wrapper no longer captures events that bubble up from nested inputs.
<div wire:model="value">
<input type="text">
</div><div wire:model.deep="value">
<input type="text">
</div>Finding them
# Every wire:model with a modifier
grep -rn "wire:model\.[a-z]" resources/views/
# wire:model on a non-input element is a bubbling candidate
grep -rn "<div[^>]*wire:model" resources/views/8. The update endpoint moved
All Livewire URLs now include a hash: /livewire/ became
/livewire-{hash}/.
/livewire/ rule.
Places to check
- nginx or Apache
locationblocks - WAF and firewall allow-lists
- CDN cache-bypass rules (Cloudflare page rules and similar)
- Rate limiters keyed on the path
- Load balancer routing rules
- Monitoring and uptime checks pointed at the endpoint
- CSP
connect-srcentries pinned to a path
location /livewire/ {
# …
}location ~ ^/livewire(-[a-z0-9]+)?/ {
# …
}9. wire:transition was rebuilt
wire:transition no longer accepts modifiers such as .opacity,
.scale or .duration.200ms. It is built on the native View Transitions API
now, and the animation is expressed in CSS.
<div wire:transition.opacity.duration.300ms>
…
</div><div wire:transition>
…
</div>::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 300ms;
}10. PHP API changes
Streaming
$this->stream(to: '#container', content: 'Hello', replace: true);$this->stream('Hello', replace: true, el: '#container');Component mounting
mount($name, $params = [], $key = null)mount($name, $params = [], $key = null, $slots = [])Relevant if you mount components programmatically or have custom code that wraps Livewire's mounting. Ordinary application code is unaffected.
New attributes worth knowing
| Attribute | Purpose |
|---|---|
#[Async] | Run an action in parallel, outside the request queue |
#[Renderless] | Skip the re-render after an action |
#[Prop] | Declare a property as a parent-supplied prop |
#[Json] | Return data straight to JavaScript |
11. JavaScript API changes
$js actions
$wire.$js('showToast', { message: 'Saved' });$wire.$js.showToast = { message: 'Saved' };Hooks became interceptors
The commit and request hooks are deprecated in favour of
interceptMessage() and interceptRequest().
document.addEventListener('livewire:init', () => {
Livewire.hook('request', ({ options, fail }) => {
options.headers['X-Tenant'] = window.tenantId;
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload();
}
});
});
});document.addEventListener('livewire:init', () => {
Livewire.interceptRequest(({ options, fail }) => {
options.headers['X-Tenant'] = window.tenantId;
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload();
}
});
});
Livewire.interceptMessage(({ component, succeed }) => {
succeed(() => {
console.debug('component updated', component.name);
});
});
});New magic properties
{{-- Validation errors, on the client --}}
<div x-show="$errors.has('email')" x-text="$errors.first('email')"></div>12. Volt folded into core
Volt's single-file syntax was the prototype for what became Livewire 4's single-file components. The separate package is no longer needed.
| Volt | Livewire 4 |
|---|---|
Livewire\Volt\Component | Livewire\Component |
Volt::route() | Route::livewire() |
Volt::test() | Livewire::test() |
| Volt service provider | Remove it |
livewire/volt package | Remove the dependency |
composer remove livewire/volt<?php
use Livewire\Volt\Component;
new class extends Component {
public string $title = '';
};
?>
<div>…</div><?php
use Livewire\Component;
new class extends Component {
public string $title = '';
};
?>
<div>…</div>13. Adopting the new features
Once the app is green on v4, these are the changes that pay for themselves fastest, roughly in order of return on effort.
1. Islands, on your slowest page
Find the page where one small interaction re-runs a lot of queries — usually a dashboard or a table
with filters. Wrap the independent regions in @island.
<div>
<div class="card">Revenue: {{ $this->revenue }}</div>
<div class="card">Pending jobs: {{ $this->queue }}</div>
<div class="card">Latest activity: …</div>
</div><div>
@island(name: 'revenue')
<div class="card">Revenue: {{ $this->revenue }}</div>
@endisland
@island(name: 'queue', poll: '5s')
<div class="card">Pending jobs: {{ $this->queue }}</div>
@endisland
@island(name: 'activity', lazy: true)
@placeholder
<div class="card skeleton animate-pulse h-48"></div>
@endplaceholder
<div class="card">Latest activity: …</div>
@endisland
</div>2. Replace round trips with client directives
<button wire:click="$toggle('showFilters')">Filters</button>
@if ($showFilters)
<div class="filters">…</div>
@endif<button wire:click="$toggle('showFilters')">Filters</button>
<div class="filters" wire:show="showFilters">…</div>3. Drop SortableJS
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
<span wire:sort:handle>⠿</span> {{ $task->title }}
</li>
@endforeach
</ul>4. Collapse child components that only existed for rendering
In v3 the standard fix for "this re-renders too much" was a child component. If a child has no state of its own and is not reused, an island in the parent is lighter — one component, one class, one snapshot.
5. Use #[Renderless] for invisible work
use Livewire\Attributes\Renderless;
#[Renderless]
public function trackView(): void
{
$this->post->increment('views');
}6. Convert components to single-file — last, and only where it helps
This is cosmetic. Do it opportunistically as you touch files, not as a migration sprint. Small components benefit; components with heavy logic and many dependencies are often clearer as classes.
14. Upgrade checklist
Before you start
- The test suite passes on Livewire 3.
- A branch, and a database backup if the environment is shared.
- Someone available to click through the app afterwards — the silent failures need eyes.
Mechanical changes
composer require livewire/livewire:^4.0php artisan optimize:clearlayout→component_layout(and thelayouts::namespace)lazy_placeholder→component_placeholderRoute::get(…, Component::class)→Route::livewire(…)- Every
<livewire:…>tag self-closed wire:model.blur/.change→ add.live- Wrapper
wire:model→ add.deep wire:scroll→wire:navigate:scrollwire:transitionmodifiers → CSS$this->stream()argument order$wire.$js('name', …)→$wire.$js.name = …Livewire.hook('request'|'commit')→interceptRequest()/interceptMessage()- Volt classes, routes, tests and package removed
Infrastructure
- nginx / Apache location blocks widened for
/livewire-{hash}/ - WAF, CDN and firewall rules updated
- Rate limiters and monitoring checks repointed
- CSP
connect-srcreviewed
Verification
- Test suite green
- Every form submits and validates
- File uploads work, including the progress indicator
- Tables paginate, sort and filter
- Modals open and close
- Third-party widgets inside
wire:ignorestill initialise - Browser console clean on the main flows
- Update requests return 200 in production, not 403 from a WAF
Official resources
- Upgrade guide: livewire.laravel.com/docs/upgrading
- Livewire documentation: livewire.laravel.com/docs