Livewire 4 — complete guide
Livewire 4 keeps the component model you know and rebuilds everything around it: single-file components, islands that re-render on their own, slots evaluated in the parent context, built-in drag and drop, and client-side directives that skip the server entirely. This guide covers the framework end to end, with the version-4 differences called out where they bite.
1. What Livewire 4 changes
Livewire 4 is the largest release in the framework's history. The component model itself is the same — a PHP class plus a Blade view, state on the server, HTML over the wire — but almost everything around it moved: where components live, how they are written, and how much of the page a single update touches.
The five things that matter
| Feature | What it buys you |
|---|---|
| Single-file components | Class and markup live in one .blade.php file. No more jumping between two directories for a twenty-line component. |
| Islands | Isolated regions inside a component that re-render on their own. A ticking revenue counter no longer re-runs the queries for the whole dashboard. |
| Slots | Parents inject markup into children — evaluated in the parent's context, so wire:click inside a slot calls the parent's method. |
| Optimistic UI | wire:show, wire:text, wire:bind update the DOM instantly on the client with no round trip. |
| Drag and drop | wire:sort is built in. No SortableJS, no glue code. |
Is it a rewrite of your app?
No. Livewire 4 keeps strong backwards compatibility: class-based components keep working. Single-file is the default for new components, not a forced migration. There is a set of breaking changes worth reading before you upgrade — they are collected in the Livewire 3 vs 4 comparison, and the official upgrade guide is the authoritative list.
wire:model semantics, islands and slots. The rest of the
component model will feel familiar.
2. Installation and requirements
composer require livewire/livewire:^4.0
php artisan optimize:clear
As in version 3 the assets are injected automatically. @livewireStyles and
@livewireScripts only need to be placed by hand when you use a non-standard layout or a
strict Content Security Policy.
/livewire/ became /livewire-{hash}/. If you have firewall rules, WAF
exceptions, CDN bypass rules or nginx locations pinned to the literal /livewire/ path,
they must be widened to the new pattern or the app will look broken in production while working
perfectly on your laptop.
Publishing the config
php artisan livewire:publish --configSettings that are new or renamed in version 4:
| Setting | Purpose |
|---|---|
component_locations | Directories scanned for components. Defaults to resources/views/components and resources/views/livewire. |
component_namespaces | Named component roots, e.g. pages::. |
component_layout | Was layout in v3. Uses the layouts:: namespace. |
component_placeholder | Was lazy_placeholder in v3. |
make_command | Controls what make:livewire generates: single-file or class-based. |
smart_wire_keys | Now defaults to true. |
csp_safe | Emits a build that satisfies stricter Content Security Policies. |
Choosing the default component style
// Keep generating class-based components instead of single-file ones
'make_command' => [
'type' => 'class',
],3. Single-file components
The headline change in day-to-day work. A component is now one Blade file that opens with a PHP block declaring an anonymous class, followed by the markup.
php artisan make:livewire post.create
# resources/views/components/post/⚡create.blade.php<?php
use Livewire\Component;
use App\Models\Post;
use Livewire\Attributes\Validate;
new class extends Component {
#[Validate('required|string|min:5|max:180')]
public string $title = '';
#[Validate('required|string|min:50')]
public string $body = '';
public function save()
{
$this->validate();
Post::create([
'title' => $this->title,
'body' => $this->body,
'user_id' => auth()->id(),
]);
$this->reset();
$this->dispatch('notify', message: 'Post created');
}
};
?>
<div>
<form wire:submit="save">
<input type="text" wire:model="title" placeholder="Title">
@error('title') <span class="error">{{ $message }}</span> @enderror
<textarea wire:model="body" placeholder="Body"></textarea>
@error('body') <span class="error">{{ $message }}</span> @enderror
<button type="submit">Save post</button>
</form>
</div>
Public properties are available in the markup as plain variables — {{ $title }} works
the same as it did in a separate view file.
About that emoji
The default filename carries a ⚡ prefix. It is a visual marker that makes Livewire
components stand out from ordinary Blade components in the same directory, and it is
optional — you can turn it off in config/livewire.php if your
toolchain, terminal or team prefers plain names.
Multi-file components
When a component grows past a comfortable single file, generate it as a multi-file component and get the class, view and test split apart again.
php artisan make:livewire post.create --mfcClass-based components still work
<?php
namespace App\Livewire;
use Livewire\Component;
class CreatePost extends Component
{
public string $title = '';
public function render()
{
return view('livewire.create-post');
}
}app/Livewire.
4. Locations, naming and namespaces
Livewire components no longer live in their own resources/views/livewire silo by
default; they sit next to your other Blade components.
resources/views/
├── components/
│ ├── ⚡counter.blade.php → <livewire:counter />
│ ├── post/
│ │ ├── ⚡create.blade.php → <livewire:post.create />
│ │ └── ⚡index.blade.php → <livewire:post.index />
│ └── button.blade.php (a plain Blade component)
└── pages/
└── ⚡dashboard.blade.php → <livewire:pages::dashboard />Rendering
{{-- By name --}}
<livewire:counter />
{{-- Nested directories use dots --}}
<livewire:post.create />
{{-- Namespaced --}}
<livewire:pages::post.create />
{{-- With props --}}
<livewire:post.create :title="$initialTitle" :author="$user" /><livewire:some-component> simply does not render — no error, no output. Always
self-close: <livewire:some-component />. This is the single most common surprise
when moving Blade files over from v3.
Routing to a component
Full-page components get a dedicated route macro. The old
Route::get('/dashboard', Dashboard::class) form is replaced by:
use App\Livewire\Dashboard;
// By class
Route::livewire('/dashboard', Dashboard::class);
// By component name — the usual choice for view-based components
Route::livewire('/dashboard', 'pages::dashboard')
->middleware('auth')
->name('dashboard');Layouts
'component_layout' => 'layouts::app',use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
new class extends Component {
#[Layout('layouts::app')]
#[Title('Dashboard')]
public function render() { /* … */ }
};5. Properties and props
Public properties are still the component's state, still serialised into the snapshot between
requests, and still subject to the same type restrictions: scalars, arrays of scalars, Eloquent
models and collections, Carbon, enums.
new class extends Component {
public string $name = '';
public ?int $age = null;
public array $tags = [];
public bool $isPublic = false;
};Props from the parent: #[Prop]
Values passed in from a parent template can be declared explicitly. That makes the component's public interface readable at a glance and separates "this comes from outside" from "this is internal state".
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $type = 'info';
#[Prop]
public bool $dismissible = false;
};
?>
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $slot }}
@if ($dismissible)
<button wire:click="$dispatch('dismiss')">×</button>
@endif
</div><livewire:alert type="warning" dismissible class="mt-4">
The invoice is overdue.
</livewire:alert>mount() still runs first
use App\Models\Order;
new class extends Component {
public Order $order;
public string $mode;
public function mount(Order $order, string $mode = 'compact'): void
{
$this->order = $order;
$this->mode = $mode;
}
};#[Locked] is as important as ever
use Livewire\Attributes\Locked;
new class extends Component {
#[Locked]
public int $invoiceId; // the client cannot substitute another id
public string $note = '';
};6. wire:model in version 4
wire:model behave differently in v4, and both fail quietly rather than loudly.
Change 1: modifiers now control client-side syncing
In v3, .blur and .change only decided when a network request fires.
In v4 they decide when the value syncs at all — including on the client. To get the old
behaviour, add .live.
{{-- Livewire 3 --}}
<input wire:model.blur="title">
{{-- The v4 equivalent of that behaviour --}}
<input wire:model.live.blur="title">Change 2: no more event bubbling from children
wire:model on a wrapper no longer captures events bubbling up from nested inputs. If you
relied on that, add .deep.
{{-- Livewire 3: the wrapper caught the input's event --}}
<div wire:model="value">
<input type="text">
</div>
{{-- Livewire 4: opt back in explicitly --}}
<div wire:model.deep="value">
<input type="text">
</div>Everything else is unchanged
{{-- Deferred by default --}}
<input type="text" wire:model="name">
{{-- Request on every change --}}
<input type="text" wire:model.live="search">
{{-- Debounced live search --}}
<input type="text" wire:model.live.debounce.400ms="search">
{{-- Nested data --}}
<input wire:model="form.address.city">
<input wire:model="post.title">post.title) still requires a matching validation rule.
That is the mass-assignment guard, and it survived into v4 untouched.
7. Actions, async and renderless
Actions are declared exactly as before — public methods, called from the markup.
new class extends Component {
public function save()
{
$this->validate();
// …
}
};<button wire:click="save">Save</button>
<form wire:submit="save">…</form>
<button wire:click="delete({{ $post->id }})"
wire:confirm="Delete this post? This cannot be undone.">Delete</button>Renderless actions
When an action changes nothing the user can see — bumping a view counter, writing an audit row — skipping the re-render saves a full template pass and a DOM diff.
use Livewire\Attributes\Renderless;
new class extends Component {
#[Renderless]
public function incrementViewCount(): void
{
$this->post->increment('views');
}
};{{-- Or from the template side --}}
<button type="button" wire:click.renderless="incrementViewCount">Track</button>The imperative equivalent inside a method is still $this->skipRender().
Async actions
#[Async] (or the .async modifier) runs an action in parallel,
outside the normal request queue. Ideal for fire-and-forget work: analytics pings, logging,
cache warming.
use Livewire\Attributes\Async;
new class extends Component {
#[Async]
public function logInteraction(string $element): void
{
Analytics::record($element, auth()->id());
}
};<button wire:click.async="logInteraction('cta-hero')">Get started</button>Magic actions
<button wire:click="$refresh">Refresh</button>
<button wire:click="$set('tab', 'settings')">Settings</button>
<button wire:click="$toggle('showFilters')">Filters</button>
<button wire:click="$dispatch('open-modal', { name: 'create' })">New</button>
<button wire:click="$parent.closeModal()">Close</button>protected or private so they cannot be invoked from the client.
8. Lifecycle and hooks
The lifecycle is unchanged from version 3. Knowing the order still removes most of the confusing bugs.
1. Component instance created
2. boot()
3. mount($params)
4. booted()
5. render() 1. POST /livewire-{hash}/update arrives with the state snapshot
2. Snapshot checksum verified
3. Component instance created
4. boot()
5. State restored from the snapshot
6. hydrate() / hydrateFoo()
7. booted()
8. updating($prop, $value) / updatingFoo($value)
9. Properties receive new values
10. updated($prop, $value) / updatedFoo($value)
11. Queued actions run
12. rendering() → render() → rendered($view, $html)
13. dehydrate() — state packed back into the snapshot
14. Response: markup + effectsnew class extends Component {
public float $price = 0;
public function boot(): void {}
public function booted(): void {}
public function hydrate(): void {}
public function dehydrate(): void {}
public function updating(string $property, mixed $value): void {}
public function updated(string $property, mixed $value): void
{
$this->validateOnly($property);
}
// Property-specific
public function updatedPrice(mixed $value): void
{
$this->price = round((float) $value, 2);
}
// Nested key form.email
public function updatedFormEmail(mixed $value): void
{
$this->form['email'] = strtolower(trim($value));
}
};9. Validation and Form objects
Validation carries over from v3 unchanged, including attributes and Form objects.
use Livewire\Attributes\Validate;
new class extends Component {
#[Validate('required|string|min:2|max:80')]
public string $name = '';
#[Validate('required|email:rfc,dns')]
public string $email = '';
#[Validate('accepted', message: 'Please agree to the processing of your data.')]
public bool $consent = false;
public function submit(): void
{
$data = $this->validate();
Contact::create($data);
$this->reset();
}
};Conditional rules
protected function rules(): array
{
return [
'email' => [
'required',
'email',
Rule::unique('users', 'email')->ignore($this->userId),
],
'password' => $this->userId ? 'nullable|min:8|confirmed' : 'required|min:8|confirmed',
];
}Form objects
<?php
namespace App\Livewire\Forms;
use App\Models\Post;
use Livewire\Attributes\Validate;
use Livewire\Form;
class PostForm extends Form
{
public ?Post $post = null;
#[Validate('required|string|min:5|max:180')]
public string $title = '';
#[Validate('required|string|min:50')]
public string $body = '';
public function setPost(Post $post): void
{
$this->post = $post;
$this->title = $post->title;
$this->body = $post->body;
}
public function store(): Post
{
$this->validate();
return Post::create($this->except('post'));
}
public function update(): void
{
$this->validate();
$this->post->update($this->except('post'));
}
}<?php
use Livewire\Component;
use App\Livewire\Forms\PostForm;
new class extends Component {
public PostForm $form;
public function save()
{
$this->form->post ? $this->form->update() : $this->form->store();
$this->redirect(route('posts.index'), navigate: true);
}
};
?>
<div>
<form wire:submit="save">
<input wire:model="form.title">
@error('form.title') <span>{{ $message }}</span> @enderror
<textarea wire:model="form.body"></textarea>
@error('form.body') <span>{{ $message }}</span> @enderror
<button type="submit">Save</button>
</form>
</div>Errors in JavaScript
Version 4 exposes validation errors to the client through the $errors magic property.
<div x-show="$errors.has('email')" x-text="$errors.first('email')"></div>10. Islands
Islands are the marquee feature of Livewire 4 and the one that changes how you think about component size. An island is a region inside a component that re-renders independently: when it updates, only that fragment is recomputed and patched — not the whole component.
@island
<div>Revenue: {{ $this->revenue }}</div>
@endislandWhy this matters
In v3 the standard fix for "this dashboard is slow" was to split it into six child components so that updating one widget did not re-run the queries behind the other five. Islands give you that isolation without the component boundary: one component, one class, several independently updating regions.
<?php
use Livewire\Component;
use Livewire\Attributes\Computed;
new class extends Component {
#[Computed]
public function revenue()
{
return Order::whereMonth('created_at', now()->month)->sum('total');
}
#[Computed]
public function queue()
{
return Job::pending()->count();
}
#[Computed]
public function feed()
{
return Activity::latest()->take(20)->get();
}
};
?>
<div>
@island(name: 'revenue')
<div class="card">
<h3>Revenue this month</h3>
<p class="stat">{{ number_format($this->revenue, 2) }} €</p>
</div>
@endisland
@island(name: 'queue', poll: '5s')
<div class="card">
<h3>Jobs pending</h3>
<p class="stat">{{ $this->queue }}</p>
</div>
@endisland
@island(name: 'feed', lazy: true)
@placeholder
<div class="card skeleton animate-pulse h-64"></div>
@endplaceholder
<div class="card">
@foreach ($this->feed as $activity)
<div wire:key="activity-{{ $activity->id }}">{{ $activity->summary }}</div>
@endforeach
</div>
@endisland
</div>Options
| Option | Effect |
|---|---|
name | Identifies the island so actions and JavaScript can target it. Several islands sharing a name always render as a group. |
lazy | Renders when the island scrolls into view (intersection observer). |
defer | Renders immediately after the page loads, regardless of visibility. |
always | Forces the island to update whenever the parent re-renders. |
skip | Skips the initial render entirely. |
Targeting an island from an action
@island(name: 'revenue')
Revenue: {{ $this->revenue }}
@endisland
<button wire:click="$refresh" wire:island="revenue">Refresh revenue</button>Append and prepend — infinite scroll without the plumbing
@island(name: 'feed')
@foreach ($this->posts as $post)
<article wire:key="post-{{ $post->id }}">{{ $post->title }}</article>
@endforeach
@endisland
<button wire:click="loadMore" wire:island.append="feed">Load more</button>The same thing driven from Alpine or plain JavaScript:
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
Load more
</button>Polling scoped to an island
@island(name: 'queue')
<div wire:poll.3s>
Jobs pending: {{ $this->queue }}
</div>
@endisland
The poll refreshes the island only. Compare that to v3, where wire:poll on a dashboard
re-ran every query on the page every few seconds.
11. Slots and attribute forwarding
Livewire components can now accept slot content the way Blade components always could — with one
important twist: slot content is evaluated in the parent's context. A
wire:click written inside a slot calls the parent's method, not the child's.
Default slot
<livewire:modal>
<h2>Create a post</h2>
<form wire:submit="save">
<input wire:model="title">
<button type="submit">Save</button>
</form>
</livewire:modal><?php
use Livewire\Component;
new class extends Component {
public bool $isOpen = false;
public function toggle(): void
{
$this->isOpen = ! $this->isOpen;
}
};
?>
<div wire:show="isOpen" class="modal">
<button wire:click="toggle" class="modal__close">×</button>
<div class="modal__body">
{{ $slot }}
</div>
</div>
In that example wire:submit="save" and wire:model="title" belong to the
parent component, while wire:click="toggle" belongs to the modal. That split is exactly
what you want: the modal owns opening and closing, the parent owns the form.
Named slots
<livewire:modal>
<wire:slot name="header">
<h2>Create a post</h2>
</wire:slot>
<form wire:submit="save">
<input wire:model="title">
</form>
<wire:slot name="footer">
<button wire:click="save">Save</button>
<button wire:click="$parent.close()">Cancel</button>
</wire:slot>
</livewire:modal><div class="modal">
@if ($header = $slot('header'))
<div class="modal__header">{{ $header }}</div>
@endif
<div class="modal__body">{{ $slot }}</div>
@if ($footer = $slot('footer'))
<div class="modal__footer">{{ $footer }}</div>
@endif
</div>Attribute forwarding
Just like Blade components, a Livewire component can receive and merge arbitrary HTML attributes
through $attributes.
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $type = 'info';
};
?>
<div {{ $attributes->merge(['class' => 'alert alert-'.$type]) }}>
{{ $slot }}
</div><livewire:alert type="danger" class="mt-6" data-testid="overdue-alert">
This invoice is 14 days overdue.
</livewire:alert>12. Nested components and wire:key
Nesting works as it did in v3: each child is an independent unit with its own state and its own update cycle.
<div>
@foreach ($orders as $order)
<livewire:order-row :order="$order" :key="'order-'.$order->id" />
@endforeach
</div>Smart wire keys
Version 4 turns on smart_wire_keys by default, so Livewire infers keys in many loop
situations that previously needed an explicit one. That reduces boilerplate, but it does not make
keys obsolete.
wire:key tied to the record id. Smart keys
are a convenience for the simple cases, not a replacement for telling the framework what a row
actually is.
@foreach ($rows as $row)
<div wire:key="row-{{ $row->id }}">…</div>
@endforeachReactive props
use Livewire\Attributes\Reactive;
new class extends Component {
#[Reactive]
public int $quantity;
};Calling the parent
<button wire:click="$parent.refreshList()">Refresh the list</button>Two-way binding on a component
use Livewire\Attributes\Modelable;
new class extends Component {
#[Modelable]
public int $value = 0;
};<livewire:rating-input wire:model.live="review.rating" />13. Events
The event API is unchanged from version 3.
// Dispatch
$this->dispatch('post-created');
$this->dispatch('post-created', postId: $post->id, title: $post->title);
$this->dispatch('refresh')->to(OrderList::class);
$this->dispatch('recalculate')->self();use Livewire\Attributes\On;
new class extends Component {
#[On('post-created')]
public function onPostCreated(int $postId, string $title): void
{
// …
}
};<button wire:click="$dispatch('open-modal', { name: 'create-order' })">New order</button>Events to the browser
$this->dispatch('notify', type: 'success', message: 'Order saved');<div x-data="{ show: false, message: '' }"
x-on:notify.window="message = $event.detail.message; show = true; setTimeout(() => show = false, 3000)"
x-show="show"
x-transition
class="toast">
<span x-text="message"></span>
</div>14. Computed properties
#[Computed] works as in v3 and pairs particularly well with islands: an island that
reads a computed property only recomputes it when that island updates.
use Livewire\Attributes\Computed;
new class extends Component {
public array $items = [];
#[Computed]
public function products()
{
return Product::whereIn('id', array_keys($this->items))->get();
}
#[Computed]
public function subtotal(): float
{
return $this->products->sum(
fn ($product) => $product->price * $this->items[$product->id]
);
}
#[Computed]
public function total(): float
{
return round($this->subtotal * 1.2, 2);
}
};@island(name: 'totals')
<p>Subtotal: {{ number_format($this->subtotal, 2) }} €</p>
<p>Total incl. VAT: {{ number_format($this->total, 2) }} €</p>
@endislandCaching beyond the request
#[Computed(persist: true, seconds: 300)]
public function statistics(): array
{
return [
'orders' => Order::whereMonth('created_at', now()->month)->count(),
'revenue' => Order::whereMonth('created_at', now()->month)->sum('total'),
];
}
#[Computed(cache: true, key: 'global-stats')]
public function globalStats(): array
{
return app(StatsService::class)->build();
}Busting the cache
unset($this->products, $this->subtotal, $this->total);$this->products, never $products.
15. State in the URL and the session
use Livewire\Attributes\Url;
use Livewire\Attributes\Session;
new class extends Component {
#[Url]
public string $search = '';
#[Url(as: 'cat')]
public ?int $categoryId = null;
#[Url(except: '')]
public string $sort = 'popular';
#[Url(keep: true)]
public int $perPage = 24;
#[Url(history: true)]
public string $tab = 'all';
#[Session]
public bool $sidebarCollapsed = false;
#[Session(key: 'admin.table.density')]
public string $density = 'comfortable';
};Result: /catalog?search=laptop&cat=5&sort=price-asc&tab=sale
Flash messages
public function save(): void
{
$this->validate();
Setting::updateOrCreate(['key' => 'theme'], ['value' => $this->theme]);
session()->flash('status', 'Settings saved');
}@if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif#[Url] property calls the History API on change. For fields that change per
keystroke, pair it with wire:model.live.debounce.
16. File uploads
Uploads work as in version 3, through the WithFileUploads trait.
<?php
use Livewire\Component;
use Livewire\WithFileUploads;
use Livewire\Attributes\Validate;
new class extends Component {
use WithFileUploads;
#[Validate('required|image|mimes:jpg,jpeg,png,webp|max:4096')]
public $avatar;
public function save(): void
{
$this->validate();
$path = $this->avatar->store('avatars', 'public');
auth()->user()->update(['avatar_path' => $path]);
$this->reset('avatar');
$this->dispatch('notify', message: 'Avatar updated');
}
};
?>
<div>
<form wire:submit="save">
<input type="file" wire:model="avatar" accept="image/*">
<div wire:loading wire:target="avatar">Uploading…</div>
@if ($avatar)
<img src="{{ $avatar->temporaryUrl() }}" alt="Preview" class="preview">
@endif
@error('avatar') <p class="error">{{ $message }}</p> @enderror
<button type="submit" wire:loading.attr="disabled">Save</button>
</form>
</div>A precise progress bar
<div x-data="{ progress: 0, uploading: false }"
x-on:livewire-upload-start="uploading = true"
x-on:livewire-upload-finish="uploading = false; progress = 0"
x-on:livewire-upload-error="uploading = false"
x-on:livewire-upload-progress="progress = $event.detail.progress">
<input type="file" wire:model="avatar">
<div x-show="uploading" class="progress">
<div class="progress__bar" :style="`width: ${progress}%`"></div>
</div>
</div>'temporary_file_upload' => [
'disk' => 's3',
'rules' => ['file', 'max:12288'],
'directory' => 'livewire-tmp',
'middleware' => 'throttle:60,1',
'preview_mimes' => ['png', 'jpeg', 'jpg', 'webp', 'gif', 'mp4', 'pdf'],
'max_upload_time' => 5,
],upload_max_filesize, post_max_size in PHP and
client_max_body_size in nginx must all exceed your max: rule, or uploads
fail with no readable error.
17. Pagination
use Livewire\WithPagination;
use Livewire\Attributes\Url;
new class extends Component {
use WithPagination;
#[Url]
public string $search = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public function updatedSearch(): void
{
$this->resetPage();
}
public function sortBy(string $field): void
{
if ($this->sortField === $field) {
$this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
} else {
$this->sortField = $field;
$this->sortDirection = 'asc';
}
$this->resetPage();
}
public function with(): array
{
return [
'orders' => Order::query()
->with('customer')
->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
->orderBy($this->sortField, $this->sortDirection)
->paginate(25),
];
}
};Islands turn pagination into infinite scroll
<div>
<input type="search" wire:model.live.debounce.400ms="search">
@island(name: 'rows')
@foreach ($orders as $order)
<div wire:key="order-{{ $order->id }}">{{ $order->number }}</div>
@endforeach
@endisland
<button wire:click="nextPage" wire:island.append="rows">Load more</button>
</div>paginate() costs an extra COUNT(*). If you do not display a
total page count — and with append-style loading you usually do not — simplePaginate()
is meaningfully cheaper.
18. Loading states
The wire:loading family is unchanged, and v4 adds data-loading attributes for styling.
<div wire:loading>Loading…</div>
<div wire:loading.remove>Content</div>
<button wire:click="save">Save</button>
<span wire:loading wire:target="save">Saving…</span>
<span wire:loading wire:target="save,delete,publish">Working…</span>
<div wire:loading wire:target.except="search">Updating…</div>
<button wire:click="save" wire:loading.attr="disabled">Save</button>
<button wire:click="save" wire:loading.class="opacity-50 cursor-wait">Save</button>
{{-- Only show the indicator if the request takes longer than the threshold --}}
<div wire:loading.delay>Loading…</div>
<div wire:loading.delay.long>Loading…</div>Styling from CSS
[data-loading] .btn {
opacity: 0.5;
pointer-events: none;
}Unsaved changes and connectivity
<input wire:model="title">
<span wire:dirty wire:target="title">You have unsaved changes</span>
<div wire:offline class="banner banner--warn">
No connection to the server. Changes are not being saved.
</div>19. Optimistic UI on the client
A whole class of interactions never needed the server: toggling a panel, showing a character counter, colouring a field red when it gets too long. Version 4 gives these first-class directives that update the DOM instantly, with no request at all.
wire:show
{{-- Toggles visibility via CSS, immediately, no round trip --}}
<div wire:show="showModal" class="modal">…</div>
<button wire:click="$toggle('showModal')">Open</button>wire:text
{{-- Text content follows the property on the client --}}
<span wire:text="title"></span>
<input wire:model="title">wire:bind
<textarea wire:model="message" maxlength="280"></textarea>
<span wire:text="message.length"></span> / 280
<span wire:bind:class="message.length > 240 && 'text-red-500'">
Getting long
</span>Client-only actions with $js
<?php
new class extends Component {
public function save() { /* … */ }
};
?>
<div>
<button wire:click="save">Save</button>
{{-- Runs purely on the client --}}
<button x-on:click="$wire.$js.showToast = true">Show toast</button>
</div>wire:show / wire:text for feedback the user should feel
instantly; a normal action for anything that must persist.
20. Drag and drop
Reordering used to mean pulling in SortableJS, wiring its callbacks to a Livewire action and keeping the two models in sync. In version 4 it is a directive.
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
{{ $task->title }}
</li>
@endforeach
</ul>new class extends Component {
public function reorder(array $order): void
{
foreach ($order as $position => $id) {
Task::where('id', $id)->update(['position' => $position]);
}
}
};Drag handles
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
<span wire:sort:handle class="cursor-grab">⠿</span>
<span>{{ $task->title }}</span>
{{-- Interactive elements should not start a drag --}}
<button wire:sort:ignore wire:click="delete({{ $task->id }})">Delete</button>
</li>
@endforeach
</ul>Dragging between lists
<div class="board">
@foreach ($columns as $column)
<ul wire:sort="moveCard" wire:sort:group="board" wire:key="column-{{ $column->id }}">
@foreach ($column->cards as $card)
<li wire:sort:item="{{ $card->id }}" wire:key="card-{{ $card->id }}">
{{ $card->title }}
</li>
@endforeach
</ul>
@endforeach
</div>21. Scoped CSS and scripts
Since a component is now one file, its styles and scripts belong in that file too. Livewire scopes the CSS to the component so it cannot leak into the rest of the page.
<?php
use Livewire\Component;
use Livewire\Attributes\Prop;
new class extends Component {
#[Prop]
public string $plan = 'starter';
};
?>
<div class="card">
<h3 class="title">{{ ucfirst($plan) }}</h3>
<p class="price">{{ $this->price }} € / month</p>
</div>
<style>
/* Scoped to this component — no global .title collision */
.card { border: 1px solid #e4e2da; border-radius: 14px; padding: 24px; }
.title { font-size: 20px; font-weight: 700; }
.price { color: #2d6a4f; }
</style>Deliberately global styles
<style global>
:root { --brand: #f53004; }
</style>Component scripts
@script
<script>
const chart = new Chart(document.getElementById('sales'), {
type: 'line',
data: @json($chartData),
});
$wire.on('data-updated', ({ points }) => {
chart.data.datasets[0].data = points;
chart.update();
});
</script>
@endscript{{-- Load a third-party library once for the whole page --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassets23. JavaScript integration
Alpine.js still ships with Livewire, and $wire remains the bridge into component state
from JavaScript.
<div x-data="{ open: false }">
<button x-on:click="open = !open">Details</button>
<div x-show="open">
<p x-text="$wire.title"></p>
<button x-on:click="$wire.title = 'New title'">Rename</button>
<button x-on:click="$wire.save()">Save</button>
<button x-on:click="await $wire.calculate(); open = false">Calculate</button>
</div>
</div>{{-- Two-way binding into Alpine --}}
<div x-data="{ query: $wire.entangle('search') }">
<input x-model="query">
</div>wire:ref — naming elements and components
<livewire:modal wire:ref="modal" />
<button x-on:click="$refs.modal.open()">Open the modal</button>#[Json] — returning data straight to JavaScript
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchSuggestions(string $term): array
{
return Product::search($term)->take(5)->pluck('name')->all();
}
};<input x-on:input.debounce.300ms="suggestions = await $wire.searchSuggestions($event.target.value)">Interceptors
Version 4 replaces the v3 commit and request hooks with
interceptMessage() and interceptRequest().
document.addEventListener('livewire:init', () => {
Livewire.interceptRequest(({ options, fail }) => {
options.headers['X-Tenant'] = window.tenantId;
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload(); // the CSRF token expired
}
});
});
Livewire.interceptMessage(({ component, succeed }) => {
succeed(() => {
console.debug('component updated', component.name);
});
});
});// Programmatic access
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());Ignoring a subtree
<div wire:ignore>
<select id="select2-field">…</select>
</div>
<div wire:ignore.self>…</div>24. Testing
Pest is the recommended way to test Livewire 4 components. View-based components can keep their test next to the component instead of in a separate tree.
php artisan make:livewire post.create --test
# resources/views/components/post/create.test.phpTesting a view-based component
Reference the component by its dot-notation name rather than a class:
use Livewire\Livewire;
it('renders', function () {
Livewire::test('post.create')
->assertStatus(200);
});
it('validates required fields', function () {
Livewire::test('post.create')
->set('title', '')
->set('body', 'too short')
->call('save')
->assertHasErrors(['title' => 'required']);
});
it('stores a valid post', function () {
Livewire::actingAs($user)
->test('post.create')
->set('title', 'A perfectly reasonable title')
->set('body', str_repeat('Body copy that is long enough. ', 3))
->call('save')
->assertHasNoErrors()
->assertDispatched('notify');
expect(Post::where('title', 'A perfectly reasonable title')->exists())->toBeTrue();
});Class-based components
use App\Livewire\Counter;
it('increments', function () {
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1);
});The main assertions
| Method | Checks |
|---|---|
assertSet() / assertNotSet() | Property values |
assertSee() / assertDontSee() | Rendered output |
assertViewHas() | Data passed to the view |
assertHasErrors() / assertHasNoErrors() | Validation |
assertDispatched() | Events |
assertRedirect() | Redirects |
assertUnauthorized() / assertForbidden() | Authorisation |
assertStatus() | HTTP status |
// Uploads
use Illuminate\Http\UploadedFile;
Livewire::test('avatar-uploader')
->set('avatar', UploadedFile::fake()->image('avatar.jpg', 400, 400))
->call('save')
->assertHasNoErrors();
// A component inside a page
$this->get('/dashboard')
->assertSeeLivewire('pages::dashboard');25. Security and performance
Security checklist
$this->authorize()in every action that mutates data.- All identifiers marked
#[Locked]. - Helper methods declared
protectedorprivateso the client cannot call them. - Validation rules exclude internal fields (
user_id,role,price). - No tokens, keys or surplus personal data in public properties — the snapshot travels to the browser in clear text.
- Uploads constrained by MIME type and size.
- Login and contact forms behind a rate limiter.
{!! !!}only for trusted or sanitised markup.- Optimistic UI never treated as authoritative.
public function delete(int $postId): void
{
$post = Post::findOrFail($postId);
$this->authorize('delete', $post);
$post->delete();
}Performance
| Symptom | Fix in v4 |
|---|---|
| A widget update re-runs every query on the page | Wrap the regions in @island |
| Huge page markup | Model collections out of public properties; fetch them in with() or #[Computed] |
| A request per keystroke | wire:model.live.debounce.400ms |
| Polling load | Poll inside an island, add .visible |
| A round trip to toggle a panel | wire:show instead of an action |
| Counter bumps re-render the page | #[Renderless] |
| N+1 queries | with() eager loading; Model::preventLazyLoading() in dev |
| Slow first paint on a heavy block | @island(lazy: true) with a @placeholder |
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}26. Troubleshooting and cheat sheet
The component does not render at all
The tag is not self-closed. In v4 <livewire:my-component> produces nothing —
silently. Write <livewire:my-component />.
Everything 404s in production but works locally
Livewire's endpoint moved from /livewire/ to /livewire-{hash}/. A firewall
rule, WAF exception, CDN rule or nginx location still pinned to the old literal path will block the
update requests.
A field stopped syncing after the upgrade
wire:model.blur and .change now govern client-side syncing, not just the
network. Add .live: wire:model.live.blur="title".
A wrapper stopped picking up its input
Event bubbling is off by default. Use wire:model.deep.
wire:transition modifiers do nothing
.opacity, .scale and .duration.200ms are gone — v4 uses the
native View Transitions API. Express the animation in CSS instead.
An island never updates
- The island has no
namebut the action targets one. wire:island="…"points at a name that does not exist in the rendered output.- The region depends on parent state that did not actually change — add
always: true.
Slot content calls the wrong method
That is by design: slot markup is evaluated in the parent's context. To call the child, go
through an event or a wire:ref.
Volt components stopped working
Volt folded into core. Swap Livewire\Volt\Component for Livewire\Component,
Volt::route() for Route::livewire(), Volt::test() for
Livewire::test(), and remove the Volt service provider and package.
Directive cheat sheet
| Directive | Purpose | New in v4 |
|---|---|---|
wire:model | Bind a field to a property | semantics changed |
wire:model.deep | Capture events from child elements | yes |
wire:click / wire:submit | Call an action | — |
wire:click.async | Run the action in parallel | yes |
wire:click.renderless | Skip the re-render | yes |
wire:island | Target an island from an action | yes |
wire:island.append | Append to an island instead of replacing | yes |
wire:show | Toggle visibility on the client | yes |
wire:text | Bind text content on the client | yes |
wire:bind | Bind an attribute reactively | yes |
wire:sort | Drag-and-drop reordering | yes |
wire:intersect | Act when the element enters the viewport | yes |
wire:ref | Name an element or component for JS | yes |
wire:navigate | SPA-style navigation | — |
wire:navigate:scroll | Preserve container scroll | renamed |
wire:loading / wire:dirty / wire:offline | Request state | — |
wire:poll | Periodic refresh | island-scoped |
wire:key | Element identity in a loop | smart keys default on |
wire:ignore | Exclude a subtree from morphing | — |
Blade directives
| Directive | Purpose |
|---|---|
@island … @endisland | An independently rendering region |
@placeholder … @endplaceholder | Placeholder for a lazy island |
<wire:slot name="…"> | Named slot content |
@script … @endscript | Component-scoped JavaScript |
@assets … @endassets | Page-level assets loaded once |
@persist … @endpersist | Keep an element across navigations |
<style> / <style global> | Scoped / global component CSS |
PHP attributes
| Attribute | Purpose |
|---|---|
#[Validate] | Validation rule |
#[Locked] | Block frontend writes |
#[Computed] | Cached derived value |
#[Url] / #[Session] | Persist state |
#[On] | Event listener |
#[Prop] | Declare a parent-supplied prop |
#[Reactive] / #[Modelable] | Parent-child binding |
#[Async] | Run the action in parallel |
#[Renderless] | Skip re-rendering |
#[Json] | Return data straight to JavaScript |
#[Lazy] / #[Layout] / #[Title] | Loading and page chrome |
Artisan commands
php artisan make:livewire post.create # single-file
php artisan make:livewire post.create --mfc # multi-file
php artisan make:livewire post.create --test # with a test
php artisan make:livewire pages::dashboard # namespaced
php artisan livewire:publish --config
php artisan optimize:clearOfficial resources
- Livewire documentation: livewire.laravel.com/docs
- Upgrade guide: livewire.laravel.com/docs/upgrading
- Laravel documentation: laravel.com/docs
- Alpine.js: alpinejs.dev