Livewire 4 landed in late 2025 and it is the biggest change to the library since Livewire 3. Single-file components, islands, slots, a real wire: compiler, and a new testing story mean that the way you structured Livewire 2 and 3 components is no longer the way you would start today. If you are running Livewire 3 on Laravel 13, most of your existing components keep working — but the new primitives fix the two problems that dominate every Livewire code review we do: too much re-rendering, and components that grew into god objects.
This tutorial builds a small dashboard feature in Livewire 4 and covers the upgrade path for an existing Livewire 3 application.
You will need a Laravel 12 or 13 application on PHP 8.2+, Vite already configured, and Node installed.
Install
New project:
composer require livewire/livewire
php artisan livewire:install
Existing Livewire 3 project:
composer require livewire/livewire:^4.0
php artisan livewire:upgrade
The upgrade command is interactive. It moves your class-based components to the new default location, updates livewire.php, and flags Blade views that use behaviour that changed. Run it on a clean branch, read every prompt, and expect to review the diff rather than trust it blindly.
Publish the config if you have not already:
php artisan livewire:publish --config
Single-file components
In Livewire 3 every component was a PHP class in app/Livewire plus a Blade view in resources/views/livewire. Livewire 4 makes a single file the default: the class lives in a @php block at the top of the view, in resources/views/components/.
php artisan make:livewire counter
{{-- resources/views/components/counter.blade.php --}}
@php
use Livewire\Component;
new class extends Component {
public int $count = 0;
public function increment(): void
{
$this->count++;
}
};
@endphp
<div>
<button wire:click="increment">+</button>
<span>{{ $count }}</span>
</div>
Render it like any other component:
<x-counter />
Two practical notes. First, single-file components are a default, not a requirement: php artisan make:livewire OrdersTable --class still produces the class-plus-view pair, and that remains the better choice for anything with substantial logic, injected services, or its own tests. Second, the two styles interoperate, so you do not have to convert anything to adopt Livewire 4.
Our rule on client projects: single-file for presentational widgets under roughly 80 lines, class-based for anything a second developer will have to maintain.
Islands: stop re-rendering the whole component
This is the feature worth upgrading for. In Livewire 3, any update re-rendered the component's entire Blade view and diffed the whole DOM. On a dashboard with an expensive aggregate query next to a search box, every keystroke paid for the aggregate.
An island is a region of a component that renders — and updates — independently.
@php
use Livewire\Component;
use App\Models\Order;
new class extends Component {
public string $search = '';
public function with(): array
{
return [
'orders' => Order::query()
->when($this->search, fn ($q) => $q->where('reference', 'like', "%{$this->search}%"))
->latest()
->limit(25)
->get(),
];
}
public function revenueThisMonth(): string
{
return number_format(Order::whereMonth('created_at', now()->month)->sum('total') / 100, 2);
}
};
@endphp
<div class="space-y-6">
@island(lazy: true, poll: '60s')
<div class="rounded border p-4">
<p class="text-sm text-gray-500">Revenue this month</p>
<p class="text-2xl">${{ $this->revenueThisMonth() }}</p>
</div>
@endisland
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Search orders">
<ul>
@foreach ($orders as $order)
<li>{{ $order->reference }} — ${{ number_format($order->total / 100, 2) }}</li>
@endforeach
</ul>
</div>
What that buys you:
lazy: truemeans the island's contents are skipped on the first render and fetched in a follow-up request, so the page paints immediately with a placeholder.poll: '60s'refreshes only that island every minute. The order list is untouched.- Typing in the search box updates the list and not the revenue island. The expensive query does not run.
You can also refresh an island on demand from a server action with $this->island('revenue')->refresh() after naming it (@island(name: 'revenue')), or defer an island until it scrolls into view. Deferred, named islands are how you make a page with six widgets feel instant without splitting it into six components that each need their own state.
Rules of thumb from production use:
- Put every independent expensive query in its own island. Measure with Laravel Debugbar or
DB::listenbefore and after; if you cannot see the difference in query count, you did not need the island. - Do not wrap
wire:modelinputs in a lazy island. The placeholder swap loses focus. - Islands share the parent's state. If a region needs its own lifecycle and its own tests, it is a child component, not an island.
Slots
Livewire 4 components accept slots, so wrapper components finally work the way Blade components do:
{{-- resources/views/components/modal.blade.php --}}
@php
use Livewire\Component;
new class extends Component {
public bool $open = false;
public function close(): void
{
$this->open = false;
}
};
@endphp
<div>
@if ($open)
<div class="fixed inset-0 grid place-items-center bg-black/40" wire:click.self="close">
<div class="rounded bg-white p-6">
<h2 class="text-lg font-semibold">{{ $title }}</h2>
{{ $slot }}
<button wire:click="close">Close</button>
</div>
</div>
@endif
</div>
<x-modal title="Confirm deletion">
<p>This cannot be undone.</p>
<x-confirm-delete-button :order="$order" />
</x-modal>
Slot content is rendered by the parent, which means a Livewire child inside a slot keeps its own state and its own updates. In Livewire 3 the workaround was passing view names or duplicating the wrapper markup; this replaces both.
What changed under the hood
- A
wire:compiler. Directives are compiled at build time rather than interpreted in the browser, which cuts payload and removes several long-standing edge cases aroundwire:keyand nested loops. wire:modeldefaults. Deferred is still the default;wire:model.liveis still explicit. Nothing to change here, but audit any.liveon a text input that does not need it — with islands you often no longer do.- Navigation.
wire:navigateis more aggressive about prefetching and preserves scroll and focus more reliably. If you had custom Alpine to restore scroll position, delete it and retest. - Requirements. Livewire 4 needs PHP 8.2+ and Laravel 11+. Alpine is still bundled.
Testing
The Livewire testing API is unchanged for classes, and single-file components are addressable by view name:
<?php
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
it('filters orders without re-running the revenue query', function () {
$user = User::factory()->create();
Order::factory()->create(['reference' => 'INV-1001']);
Order::factory()->create(['reference' => 'REF-2002']);
Livewire::actingAs($user)
->test('counter') // single-file component by name
->assertSee('INV-1001');
Livewire::actingAs($user)
->test(\App\Livewire\OrdersDashboard::class)
->set('search', 'INV')
->assertSee('INV-1001')
->assertDontSee('REF-2002');
});
Two additions worth adopting:
- Islands can be asserted directly — check that a lazy island is absent on first render and present after it loads, so a refactor that accidentally makes it eager fails the suite.
- Pest 4 browser tests pair well with Livewire 4 for the interactions unit tests cannot cover (focus, scroll, modals). We cover that in Pest 4 browser testing.
Upgrading an existing app: a checklist
- Branch, then bump to
livewire/livewire:^4.0and runphp artisan livewire:upgrade. - Run the test suite before touching anything else. Failures here are almost always view-level.
- Grep for
wire:model.liveon inputs and remove.livewhere a debounce or deferred update is fine. - Find your slowest component with the query log. Convert its independent expensive regions into islands, lazily where the data is below the fold.
- Delete Alpine workarounds for scroll restoration, modal focus traps you built around missing slots, and any manual polling loops replaced by
poll:on an island. - Re-run
npm run buildand check the compiledwire:output in production mode, not justnpm run dev. - Watch first-render payload size. Islands reduce it; a component that eagerly renders twelve widgets does not.
Budget a day for a small app and a week for a large Livewire 3 codebase with heavy custom JavaScript. The upgrade itself is rarely the cost — auditing where you no longer need workarounds is.
Where this pays off
Livewire 4 is most valuable on data-dense internal tools: dashboards, back-office queues, admin panels. Those are exactly the pages where a single component was doing ten things and every interaction re-ran all ten queries. Islands turn that into one query per interaction, without a JavaScript rewrite.
If you have a Livewire application that has grown slow or hard to maintain, our team does this work for clients — see Laravel performance optimization and Laravel feature enhancement, or just get in touch with the details of your app.