Livewire 3 — complete guide
Livewire lets you build dynamic interfaces in Laravel with PHP and Blade — no separate frontend application and no API layer. This guide runs from installation to production: components, state, forms, events, file uploads, testing, security and performance.
1. What Livewire is
Livewire is a full-stack framework for Laravel that lets you build dynamic interfaces in PHP and Blade without leaving the backend. You do not write a separate SPA, you do not stand up a REST or GraphQL layer, and you do not maintain a second data model in JavaScript.
How it works
A Livewire component is a PHP class plus a Blade view. On the first page load the component renders server-side as ordinary markup. After that a small JS runtime intercepts user interactions (click, input, submit), sends an AJAX request carrying the component's current state, the server re-renders the component and returns fresh markup. The runtime diffs the old and new DOM trees and patches only what changed — the page never reloads.
Browser Server
│ │
│ click on wire:click="save" │
├───────── POST /livewire/update ┤
│ { snapshot, calls, updates } │
│ ├── restore component from snapshot
│ ├── apply property updates
│ ├── call save()
│ ├── run render()
│ { snapshot, html, effects } │
├◄───────────────────────────────┤
├── morph the DOM (diffs only) │
▼ ▼When Livewire is the right call
- Admin panels, CRMs, dashboards, internal tools — lots of forms and tables, little complex animation.
- Forms with dependent fields, multi-step wizards, live validation.
- Tables with search, filters, sorting and pagination.
- A team that is strong in PHP and does not want to maintain a separate frontend stack.
- Shipping features fast without duplicating logic across PHP and JS.
When to reach for something else
- Interfaces that update constantly: online editors, canvases, drag-and-drop builders, games.
- Offline-first apps and PWAs with complex local synchronisation.
- Mobile apps — those need an API, not markup over the wire.
- Poor or high-latency connections: every cycle is a network round trip.
Versions
This guide covers Livewire 3.x. Requirements: PHP 8.1+, Laravel 10 or newer.
Version 3 bundles Alpine.js, renamed the event API (dispatch instead of
emit), introduced PHP attributes (#[Computed], #[Validate],
#[Url]), and made wire:model deferred by default.
2. Installation and setup
Installing into an existing Laravel project is a single command.
composer require livewire/livewire
In Livewire 3 you do not have to wire up the assets yourself: the package injects
@livewireStyles and @livewireScripts into your layout. With a non-standard
layout or a strict CSP you can place the directives manually.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ $title ?? 'Application' }}</title>
<link rel="stylesheet" href="{{ asset('css/app.css') }}">
@livewireStyles
</head>
<body class="antialiased">
{{ $slot }}
@livewireScripts
</body>
</html>@livewireScripts — or it sits before your own Alpine.js build.
Livewire 3 already ships Alpine; a second Alpine instance breaks reactivity.
Publishing the config
php artisan livewire:publish --configThe settings that matter in config/livewire.php:
| Setting | Purpose |
|---|---|
class_namespace | Namespace for component classes. Defaults to App\Livewire. |
view_path | Directory holding component Blade views. |
layout | Layout used by full-page components. |
temporary_file_upload | Disk, lifetime and rules for temporary uploads. |
inject_assets | Automatic CSS/JS injection. Turn off if you place the directives yourself. |
navigate.show_progress_bar | Progress bar for wire:navigate. |
Verifying the install
php artisan livewire:make Counter
# CLASS: app/Livewire/Counter.php
# VIEW: resources/views/livewire/counter.blade.php3. Your first component
A component is two files: a class and a view.
<?php
namespace App\Livewire;
use Livewire\Component;
class Counter extends Component
{
public int $count = 0;
public function increment(): void
{
$this->count++;
}
public function decrement(): void
{
$this->count--;
}
public function reset(): void
{
$this->count = 0;
}
public function render()
{
return view('livewire.counter');
}
}<div class="flex items-center gap-3">
<button type="button" wire:click="decrement" class="btn">−</button>
<span class="text-2xl font-bold">{{ $count }}</span>
<button type="button" wire:click="increment" class="btn">+</button>
<button type="button" wire:click="reset" class="btn btn-ghost">Reset</button>
</div><div>.
Three ways to render a component
{{-- 1. Tag syntax (recommended) --}}
<livewire:counter />
{{-- 2. Directive --}}
@livewire('counter')
{{-- 3. With parameters --}}
<livewire:counter :start="10" title="Order counter" />Full-page components
A component can be attached straight to a route and render as a standalone page.
use App\Livewire\Dashboard;
Route::get('/dashboard', Dashboard::class)->middleware('auth')->name('dashboard');use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
class Dashboard extends Component
{
#[Layout('layouts.app')]
#[Title('Dashboard')]
public function render()
{
return view('livewire.dashboard');
}
}Inline components
For very small components you can return the view as a string and skip the Blade file entirely.
public function render()
{
return <<<'BLADE'
<div>
<button wire:click="$refresh">Refresh</button>
</div>
BLADE;
}4. Component properties
Public class properties are automatically available in the Blade view and survive between requests. They are the component's state.
class UserProfile extends Component
{
public string $name = '';
public ?int $age = null;
public array $tags = [];
public bool $isPublic = false;
public function render()
{
return view('livewire.user-profile');
}
}What you can store
State is serialised to JSON between requests, so the set of supported types is limited.
| Allowed | Not allowed |
|---|---|
string, int, float, bool, null |
Closures |
array of scalar values |
Resources, streams |
| Eloquent models and model collections | Arbitrary objects without Wireable/Synth support |
Carbon, DateTime, Stringable, enums |
Objects holding connections, PDO handles and similar |
Initialisation: mount()
mount() is the component constructor. It runs once, on the first render, and receives
the parameters passed from Blade.
use App\Models\Order;
class OrderCard extends Component
{
public Order $order;
public string $mode;
public function mount(Order $order, string $mode = 'compact'): void
{
$this->order = $order;
$this->mode = $mode;
}
}<livewire:order-card :order="$order" mode="full" :key="$order->id" />Protecting properties: #[Locked]
By default the client can change the value of any public property. For identifiers and anything that
affects access control that is a hole. The #[Locked] attribute forbids changes from the
frontend.
use Livewire\Attributes\Locked;
class InvoiceEditor extends Component
{
#[Locked]
public int $invoiceId; // the client cannot swap in someone else's id
public string $comment = '';
}Properties hidden from JSON
protected and private properties are not serialised and are
reset between requests. Use them only for values recomputed in every cycle.
class Report extends Component
{
public string $period = 'month';
// Cleared after every request — do not keep state here.
protected array $cache = [];
}5. wire:model and data binding
wire:model binds a form field to a component property. In Livewire 3 the binding is
deferred by default: the value does not travel to the server on every keystroke, it
goes along with the next action (a click, a submit).
{{-- Deferred: the value ships with the next request --}}
<input type="text" wire:model="name">
{{-- Immediate: a request on every change --}}
<input type="text" wire:model.live="search">
{{-- 500 ms after typing stops --}}
<input type="text" wire:model.live.debounce.500ms="search">
{{-- At most once every 2 s while typing --}}
<input type="text" wire:model.live.throttle.2s="search">
{{-- Only when the field loses focus --}}
<input type="text" wire:model.blur="email">
{{-- Updates instantly on the client, the server finds out later --}}
<input type="text" wire:model.lazy="draft">Modifiers
| Modifier | Behaviour | Use it for |
|---|---|---|
| — | Deferred send | Ordinary form fields |
.live | Request on every change | Live search, dependent selects |
.blur | Request on focus loss | Per-field validation on exit |
.debounce.Xms | Wait for a pause in typing | Search-as-you-type |
.throttle.Xs | At most once per interval | Expensive queries |
.number | Cast to a number | Numeric fields |
.boolean | Cast to a boolean | Yes/no selects |
.fill | Take the initial value from the markup | Pre-filled forms |
Every field type
{{-- Text, textarea --}}
<input type="text" wire:model="title">
<textarea wire:model="body"></textarea>
{{-- Single checkbox: bool --}}
<input type="checkbox" wire:model="agreed">
{{-- Checkbox group: array --}}
<input type="checkbox" value="php" wire:model="skills">
<input type="checkbox" value="js" wire:model="skills">
<input type="checkbox" value="sql" wire:model="skills">
{{-- Radio buttons --}}
<input type="radio" value="card" wire:model="payment">
<input type="radio" value="cash" wire:model="payment">
{{-- Select --}}
<select wire:model.live="categoryId">
<option value="">All categories</option>
@foreach ($categories as $category)
<option value="{{ $category->id }}">{{ $category->name }}</option>
@endforeach
</select>
{{-- Multiple select --}}
<select wire:model="tagIds" multiple>
@foreach ($tags as $tag)
<option value="{{ $tag->id }}">{{ $tag->name }}</option>
@endforeach
</select>Nested data
Dot notation works for arrays and for model properties alike.
public array $form = [
'name' => '',
'address' => ['city' => '', 'street' => ''],
];
public Post $post;<input wire:model="form.name">
<input wire:model="form.address.city">
<input wire:model="post.title">wire:model="post.title" to work the field must
be allowed by a validation rule (rules or #[Validate]) — otherwise Livewire
throws. That is the guard against mass assignment.
6. Actions
An action is a public component method called from the view. It replaces the usual
fetch() plus a server-side handler.
{{-- Click --}}
<button wire:click="save">Save</button>
{{-- Form submit (intercepted) --}}
<form wire:submit="save">
<input wire:model="title">
<button type="submit">Send</button>
</form>
{{-- Keys --}}
<input wire:keydown.enter="search" wire:keydown.escape="clear">
{{-- Other DOM events --}}
<div wire:mouseenter="preload">…</div>
<select wire:change="applyFilter">…</select>Parameters
<button wire:click="delete({{ $post->id }})">Delete</button>
<button wire:click="setStatus('published')">Publish</button>
<button wire:click="move({{ $item->id }}, 'up')">Move up</button>public function delete(int $postId): void
{
$post = Post::findOrFail($postId);
$this->authorize('delete', $post); // always check authorisation
$post->delete();
$this->dispatch('notify', message: 'Post deleted');
}delete(999) with any id at all. Checking permissions inside
the method is mandatory.
Model binding in parameters
Livewire resolves models from an id the same way the Laravel router does.
public function archive(Post $post): void
{
$this->authorize('update', $post);
$post->update(['archived_at' => now()]);
}Action modifiers
{{-- Browser confirmation before sending --}}
<button wire:click="delete" wire:confirm="Delete for good? This cannot be undone.">Delete</button>
{{-- preventDefault / stopPropagation --}}
<a href="#" wire:click.prevent="open">Open</a>
<div wire:click.stop="select">…</div>
{{-- Runs once --}}
<button wire:click.once="init">Initialise</button>
{{-- Only when this exact element was clicked --}}
<div wire:click.self="close">…</div>Magic actions
| Action | What it does |
|---|---|
$refresh | Re-render without changing state |
$set('prop', value) | Assign a value to a property |
$toggle('prop') | Flip a boolean property |
$dispatch('event') | Dispatch a Livewire event |
$parent.method() | Call a method on the parent component |
<button wire:click="$refresh">Refresh</button>
<button wire:click="$set('tab', 'settings')">Settings</button>
<button wire:click="$toggle('showFilters')">Filters</button>
<button wire:click="$parent.closeModal()">Close</button>Redirects
public function store(): void
{
$this->validate();
$post = Post::create($this->only('title', 'body'));
session()->flash('status', 'Post created');
$this->redirect(route('posts.show', $post), navigate: true);
}7. The request lifecycle
Knowing the order of execution removes 90% of the "mysterious" bugs. There are two paths: the initial render (an ordinary HTTP page request) and subsequent updates (Livewire AJAX requests).
Initial render
1. The component instance is created
2. boot()
3. mount($params)
4. booted()
5. hydrate hooks do NOT run
6. render()
7. Markup is inserted into the pageSubsequent update
1. POST /livewire/update arrives with the state snapshot
2. The snapshot checksum is verified (tamper protection)
3. The component instance is created
4. boot()
5. State is restored from the snapshot
6. hydrate() and hydrateFoo() for each property
7. booted()
8. updating($prop, $value) / updatingFoo($value) — before assignment
9. Properties receive their new values
10. updated($prop, $value) / updatedFoo($value) — after assignment
11. Queued calls (actions) are executed
12. rendering()
13. render()
14. rendered($view, $html)
15. dehydrate() — state is packed back into the snapshot
16. Response: new markup plus effects (events, redirects, browser dispatches)Snapshot and checksum
The entire component state travels to the client and back inside the wire:snapshot
attribute. To stop the client tampering with it, Livewire signs the snapshot with an HMAC derived
from APP_KEY. A signature mismatch is rejected with "Livewire encountered corrupt data".
render() or via #[Computed].
What happens to the DOM
Livewire does not replace the node wholesale, it performs a morph: it walks the old and the
new tree and changes only the differences. That is why input focus, scroll position and Alpine state
survive. When the structure of a list changes, the algorithm needs a hint — see wire:key
in chapter 10.
8. Lifecycle hooks
Hooks let you step into any phase of the cycle.
class ProductEditor extends Component
{
public Product $product;
public string $name = '';
public float $price = 0;
// Runs at the start of EVERY request, before state is restored.
public function boot(): void
{
// A good place for dependencies that cannot be serialised.
}
// Only on the first render.
public function mount(Product $product): void
{
$this->product = $product;
$this->name = $product->name;
$this->price = $product->price;
}
// After state is restored, on every subsequent request.
public function hydrate(): void
{
}
// After boot() and state restoration.
public function booted(): void
{
}
// Before any property changes.
public function updating(string $property, mixed $value): void
{
}
// After any property changes.
public function updated(string $property, mixed $value): void
{
$this->validateOnly($property);
}
// Only for the $price property.
public function updatedPrice(mixed $value): void
{
$this->price = round((float) $value, 2);
}
// For the nested key form.email
public function updatedFormEmail(mixed $value): void
{
$this->form['email'] = strtolower(trim($value));
}
public function rendering(): void
{
}
public function rendered(mixed $view, string $html): void
{
}
// Before state is packed into the snapshot.
public function dehydrate(): void
{
}
public function render()
{
return view('livewire.product-editor');
}
}Naming rules
| Property | Hook |
|---|---|
$price | updatedPrice() |
$isActive | updatedIsActive() |
$form['email'] | updatedFormEmail() |
$post->title | updatedPostTitle() |
public function updatedSearch() { $this->resetPage(); }
9. Validation and Form objects
Livewire uses Laravel's validator — the same rules, messages and localisation.
Option 1: attributes (Livewire 3)
use Livewire\Attributes\Validate;
class ContactForm extends Component
{
#[Validate('required|string|min:2|max:80')]
public string $name = '';
#[Validate('required|email:rfc,dns')]
public string $email = '';
#[Validate('required|string|min:20|max:2000')]
public string $message = '';
#[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();
session()->flash('status', 'Message sent');
}
}Option 2: the rules() method
Needed when the rules depend on state.
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',
];
}
protected function messages(): array
{
return [
'email.unique' => 'That email address is already registered.',
];
}
protected function validationAttributes(): array
{
return [
'email' => 'email address',
];
}Live validation
public function updated(string $property): void
{
$this->validateOnly($property); // check only the field that changed
}<form wire:submit="submit" novalidate>
<label for="email">Email</label>
<input id="email" type="email" wire:model.blur="email"
class="@error('email') border-red-500 @enderror">
@error('email')
<p class="text-sm text-red-600">{{ $message }}</p>
@enderror
<button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove wire:target="submit">Send</span>
<span wire:loading wire:target="submit">Sending…</span>
</button>
</form>Driving errors by hand
$this->addError('email', 'That domain is blocklisted.');
$this->resetValidation('email');
$this->resetValidation(); // clear all
$this->validateOnly('email');
// Throwing exactly as you would in a controller
throw ValidationException::withMessages([
'code' => 'Wrong confirmation code.',
]);Form objects
Once a form grows, move it into its own class. The component stays thin, and the rules and data can be reused between create and edit.
php artisan livewire:form PostForm<?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 = '';
#[Validate('nullable|date|after_or_equal:today')]
public ?string $publishAt = null;
public function setPost(Post $post): void
{
$this->post = $post;
$this->title = $post->title;
$this->body = $post->body;
$this->publishAt = $post->publish_at?->toDateString();
}
public function store(): Post
{
$this->validate();
return Post::create($this->except('post'));
}
public function update(): void
{
$this->validate();
$this->post->update($this->except('post'));
}
}class PostEditor extends Component
{
public PostForm $form;
public function mount(?Post $post = null): void
{
if ($post?->exists) {
$this->form->setPost($post);
}
}
public function save(): void
{
$this->form->post
? $this->form->update()
: $this->form->store();
$this->redirect(route('posts.index'), navigate: true);
}
}<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>10. Nested components and wire:key
Components nest. Each child is an independent unit with its own state and its own update cycle: a click inside a child does not re-render the parent.
<div>
<h1>Orders</h1>
@foreach ($orders as $order)
<livewire:order-row :order="$order" :key="'order-'.$order->id" />
@endforeach
</div>:key is mandatory in loops. Without a unique key the morph algorithm
confuses elements when sorting, filtering or deleting: field values "move" into neighbouring rows.
The key must be stable — $loop->index will not do, use the record id.
Passing data down
Parameters are handed over once, in mount(). On later parent renders the child does
not update automatically — it lives its own life.
use Livewire\Attributes\Reactive;
class OrderTotal extends Component
{
// With #[Reactive] the value arrives from the parent on each of its renders.
#[Reactive]
public int $quantity;
}Calling the parent
<button wire:click="$parent.refreshList()">Refresh the list</button>Two-way binding: #[Modelable]
Lets you put wire:model on the component itself — handy for input widgets.
use Livewire\Attributes\Modelable;
class RatingInput extends Component
{
#[Modelable]
public int $value = 0;
public function set(int $value): void
{
$this->value = $value;
}
}{{-- In the parent view --}}
<livewire:rating-input wire:model.live="review.rating" />Rendering children conditionally
@if ($showDetails)
<livewire:order-details :order-id="$orderId" :key="'details-'.$orderId" />
@endif11. Events
Events connect components that are not in a parent-child relationship. In Livewire 3 you send with
dispatch() (version 2 used emit()).
Dispatching
// A plain event
$this->dispatch('post-created');
// With named parameters
$this->dispatch('post-created', postId: $post->id, title: $post->title);
// To one specific component
$this->dispatch('refresh')->to(OrderList::class);
// To itself only
$this->dispatch('recalculate')->self();{{-- Straight from the view --}}
<button wire:click="$dispatch('open-modal', { name: 'create-order' })">New order</button>Listening
use Livewire\Attributes\On;
class OrderList extends Component
{
public array $orders = [];
#[On('post-created')]
public function onPostCreated(int $postId, string $title): void
{
$this->orders[] = ['id' => $postId, 'title' => $title];
}
// Dynamic event name
#[On('order-updated.{orderId}')]
public function onOrderUpdated(): void
{
$this->refreshList();
}
}The alternative is the $listeners array (version 2 compatible):
protected $listeners = [
'post-created' => 'onPostCreated',
'refresh' => '$refresh',
];Events to the browser
Livewire can dispatch an ordinary DOM event, caught by Alpine.js or your own JS. That is the right way to show toasts and open modals.
$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>document.addEventListener('notify', (event) => {
console.log(event.detail.message);
});12. Computed properties
#[Computed] gives you derived data without storing it in state. The result is cached for
the duration of one request, so using it three times in a view does not cost three queries.
use Livewire\Attributes\Computed;
use Illuminate\Support\Collection;
class Cart extends Component
{
public array $items = [];
#[Computed]
public function products(): Collection
{
return Product::whereIn('id', array_keys($this->items))->get();
}
#[Computed]
public function subtotal(): float
{
return $this->products->sum(
fn (Product $product) => $product->price * $this->items[$product->id]
);
}
#[Computed]
public function total(): float
{
return round($this->subtotal * 1.2, 2); // with VAT
}
}<div>
@foreach ($this->products as $product)
<div>{{ $product->name }} — {{ $this->items[$product->id] }} pcs</div>
@endforeach
<p>Subtotal: {{ number_format($this->subtotal, 2) }} €</p>
<p>Total incl. VAT: {{ number_format($this->total, 2) }} €</p>
</div>$this. In the view a computed property is
$this->products, not $products. That is what sets it apart from ordinary
public properties.
Caching across requests
// Cached for 5 minutes in the shared application cache
#[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'),
];
}
// One cache entry shared by all users
#[Computed(cache: true, key: 'global-stats')]
public function globalStats(): array
{
return app(StatsService::class)->build();
}Busting the cache
public function addItem(int $productId): void
{
$this->items[$productId] = ($this->items[$productId] ?? 0) + 1;
unset($this->products, $this->subtotal, $this->total); // drop the cache
}render() data vs #[Computed]
Neither stores data in state. The difference is reach: render() hands variables to the
view only, while #[Computed] is available in PHP methods and in the view, and is cached.
public function render()
{
return view('livewire.orders', [
'orders' => Order::query()
->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
->latest()
->paginate(20),
]);
}13. State in the URL and the session
Filters, search and the active tab should survive a reload and be shareable as a link. The
#[Url] attribute keeps a property in sync with the query string.
use Livewire\Attributes\Url;
class ProductCatalog extends Component
{
#[Url]
public string $search = '';
// A different parameter name in the URL
#[Url(as: 'cat')]
public ?int $categoryId = null;
// Keep it out of the URL while it equals the initial value
#[Url(except: '')]
public string $sort = 'popular';
// Preserve across wire:navigate visits
#[Url(keep: true)]
public int $perPage = 24;
// Use history.pushState instead of replaceState
#[Url(history: true)]
public string $tab = 'all';
}The result: /catalog?search=laptop&cat=5&sort=price-asc&tab=sale
Storing in the session
use Livewire\Attributes\Session;
class Sidebar extends Component
{
#[Session]
public bool $collapsed = false;
#[Session(key: 'admin.table.density')]
public string $density = 'comfortable';
}Session and flash by hand
public function save(): void
{
$this->validate();
Setting::updateOrCreate(['key' => 'theme'], ['value' => $this->theme]);
session()->flash('status', 'Settings saved');
}@if (session('status'))
<div class="docs-note docs-note--tip">{{ session('status') }}</div>
@endif#[Url] property hits the
History API when it changes. For fields that change on every keystroke, combine it with
wire:model.live.debounce.
14. File uploads
The WithFileUploads trait adds real uploads: the file goes to the server as soon as it
is picked, lands in temporary storage and becomes available as a
TemporaryUploadedFile object.
use Livewire\WithFileUploads;
use Livewire\Attributes\Validate;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
class AvatarUploader extends Component
{
use WithFileUploads;
#[Validate('required|image|mimes:jpg,jpeg,png,webp|max:4096')] // up to 4 MB
public $avatar;
#[Validate(['documents.*' => 'file|mimes:pdf,docx|max:10240'])]
public array $documents = [];
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');
}
public function removeDocument(int $index): void
{
unset($this->documents[$index]);
$this->documents = array_values($this->documents);
}
}<form wire:submit="save">
<input type="file" wire:model="avatar" accept="image/*">
{{-- Upload progress --}}
<div wire:loading wire:target="avatar" class="text-sm">Uploading…</div>
{{-- Preview before saving --}}
@if ($avatar)
<img src="{{ $avatar->temporaryUrl() }}" alt="Preview" class="w-32 h-32 object-cover rounded">
@endif
@error('avatar') <p class="text-red-600">{{ $message }}</p> @enderror
<button type="submit" wire:loading.attr="disabled">Save</button>
</form>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="h-2 bg-gray-200 rounded">
<div class="h-2 bg-green-600 rounded" :style="`width: ${progress}%`"></div>
</div>
</div>Configuring temporary storage
'temporary_file_upload' => [
'disk' => 's3', // or null for the default disk
'rules' => ['file', 'max:12288'],
'directory' => 'livewire-tmp',
'middleware' => 'throttle:60,1',
'preview_mimes' => ['png', 'jpeg', 'jpg', 'webp', 'gif', 'mp4', 'pdf'],
'max_upload_time' => 5, // minutes before automatic cleanup
],upload_max_filesize, post_max_size
and max_execution_time in php.ini, plus client_max_body_size
in nginx, must all be at least as large as your max: rule. Otherwise the upload dies
without a readable error.
15. Pagination
The WithPagination trait wires up Laravel pagination without page reloads.
use Livewire\WithPagination;
use Livewire\Attributes\Url;
class OrderTable extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
public string $sortField = 'created_at';
public string $sortDirection = 'desc';
public int $perPage = 25;
// Back to page one whenever a filter changes.
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): 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 render()
{
return view('livewire.order-table', [
'orders' => Order::query()
->with('customer')
->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderBy($this->sortField, $this->sortDirection)
->paginate($this->perPage),
]);
}
}<div>
<input type="search" wire:model.live.debounce.400ms="search" placeholder="Search by number">
<table>
<thead>
<tr>
<th wire:click="sortBy('number')" style="cursor:pointer">Number</th>
<th wire:click="sortBy('created_at')" style="cursor:pointer">Date</th>
<th wire:click="sortBy('total')" style="cursor:pointer">Total</th>
</tr>
</thead>
<tbody>
@forelse ($orders as $order)
<tr wire:key="order-{{ $order->id }}">
<td>{{ $order->number }}</td>
<td>{{ $order->created_at->format('d/m/Y') }}</td>
<td>{{ number_format($order->total, 2) }}</td>
</tr>
@empty
<tr><td colspan="3">No orders found</td></tr>
@endforelse
</tbody>
</table>
{{ $orders->links() }}
</div>Useful details
// A custom page parameter name — needed when a page has two paginators
protected string $paginationTheme = 'tailwind'; // or 'bootstrap'
public function render()
{
return view('livewire.dashboard', [
'orders' => Order::paginate(10, pageName: 'orders-page'),
'invoices' => Invoice::paginate(10, pageName: 'invoices-page'),
]);
}paginate() runs an extra
COUNT(*). If you do not need the total page count, use simplePaginate() —
it is noticeably cheaper.
16. Loading states
Every action is a network request. Without feedback the interface feels frozen. Livewire ships declarative directives that need no JavaScript at all.
{{-- Show during any request from this component --}}
<div wire:loading>Loading…</div>
{{-- Hide during a request --}}
<div wire:loading.remove>Content</div>
{{-- Only for one action --}}
<button wire:click="save">Save</button>
<span wire:loading wire:target="save">Saving…</span>
{{-- For several targets --}}
<span wire:loading wire:target="save,delete,publish">Working…</span>
{{-- For a specific property --}}
<span wire:loading wire:target="search">Searching…</span>
{{-- Exclude a target --}}
<div wire:loading wire:target.except="search">Updating…</div>Modifiers
{{-- Disable the button --}}
<button wire:click="save" wire:loading.attr="disabled">Save</button>
{{-- Add a CSS class --}}
<button wire:click="save" wire:loading.class="opacity-50 cursor-wait">Save</button>
{{-- Remove a class --}}
<div wire:loading.class.remove="bg-white">…</div>
{{-- Delay: only show the indicator if the request takes over 300 ms --}}
<div wire:loading.delay>Loading…</div>
{{-- Exact thresholds: shortest 50ms, shorter 100ms, short 150ms,
default 200ms, long 300ms, longer 500ms, longest 1s --}}
<div wire:loading.delay.long>Loading…</div>
{{-- Control the display mode --}}
<div wire:loading.flex>…</div>
<div wire:loading.grid>…</div>
<div wire:loading.inline-flex>…</div>Unsaved changes: wire:dirty
<input wire:model="title">
<span wire:dirty wire:target="title" class="text-amber-600">You have unsaved changes</span>
<button wire:click="save" wire:dirty.class="ring-2 ring-amber-400">Save</button>Lost connection: wire:offline
<div wire:offline class="banner banner--warn">
No connection to the server. Changes are not being saved.
</div>A skeleton on first load
<div wire:init="loadHeavyData">
@if ($loaded)
{{-- the real data --}}
@else
<div class="skeleton h-40 w-full animate-pulse bg-gray-200"></div>
@endif
</div>18. Lazy loading and polling
Lazy-loading a component
A heavy component does not have to render in the first response: the page ships immediately with a placeholder and the body arrives in a second request.
<livewire:revenue-chart lazy />use Livewire\Attributes\Lazy;
#[Lazy]
class RevenueChart extends Component
{
public function placeholder(): string
{
return <<<'BLADE'
<div class="skeleton h-64 w-full animate-pulse rounded bg-gray-200"></div>
BLADE;
}
public function render()
{
return view('livewire.revenue-chart', [
'points' => app(RevenueService::class)->monthly(), // an expensive query
]);
}
}
By default a lazy component loads right after the page paints. #[Lazy(isolate: false)]
bundles the requests of several lazy components into one, and
lazy="on-load" / lazy="on-scroll" control when loading happens.
{{-- Load when the block enters the viewport --}}
<livewire:revenue-chart lazy="on-scroll" />Deferred initialisation: wire:init
<div wire:init="loadStats">
@if ($stats)
…
@else
<div class="skeleton"></div>
@endif
</div>Polling the server: wire:poll
{{-- Refresh every 2 s (the default is 2500 ms) --}}
<div wire:poll>…</div>
{{-- A custom interval --}}
<div wire:poll.5s>…</div>
<div wire:poll.750ms>…</div>
{{-- Call a specific method --}}
<div wire:poll.10s="refreshQueue">…</div>
{{-- Stop polling while the tab is hidden --}}
<div wire:poll.visible.5s="refreshQueue">…</div>
{{-- Stop after 5 minutes of user inactivity --}}
<div wire:poll.keep-alive.5s>…</div>wire:poll.2s on a page open at 100 desks is 3,000
requests per minute hitting your PHP processes. Always add .visible, pick a sane
interval, and consider websockets (Laravel Echo + Reverb) for genuinely live data.
19. Alpine.js, $wire and JS hooks
Alpine.js ships with Livewire 3. Inside a component you get the $wire object — a proxy
onto the PHP component's state and methods, straight from JavaScript.
<div x-data="{ open: false }">
{{-- Purely client-side state: the server is not involved --}}
<button x-on:click="open = !open">Details</button>
<div x-show="open" x-transition>
{{-- Read a property --}}
<p x-text="$wire.title"></p>
{{-- Write a property --}}
<button x-on:click="$wire.title = 'New title'">Rename</button>
{{-- Call a method (returns a Promise) --}}
<button x-on:click="$wire.save()">Save</button>
{{-- Await the result --}}
<button x-on:click="await $wire.calculate(); open = false">Calculate</button>
{{-- Call without re-rendering --}}
<button x-on:click="$wire.$set('tab', 'stats', false)">Stats</button>
</div>
</div>Two-way binding: $wire.entangle
<div x-data="{ query: $wire.entangle('search') }">
<input x-model="query">
<p x-show="query.length > 0">Searching for: <span x-text="query"></span></p>
</div>
{{-- Deferred sync: do not fire a request per character --}}
<div x-data="{ query: $wire.entangle('search').live }">…</div>Your own scripts inside a component
@script
<script>
// Runs once when the component initialises.
const chart = new Chart(document.getElementById('sales'), {
type: 'line',
data: @json($chartData),
});
// $wire is available here too.
$wire.on('data-updated', ({ points }) => {
chart.data.datasets[0].data = points;
chart.update();
});
</script>
@endscript{{-- Load an external library once for the whole page --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassetsIgnoring a subtree
When a third-party widget manages the DOM itself, the morph algorithm only gets in the way.
<div wire:ignore>
<select id="select2-field">…</select>
</div>
{{-- Ignore children but keep updating the element's own attributes --}}
<div wire:ignore.self>…</div>Global JS hooks
document.addEventListener('livewire:init', () => {
// Before every outgoing request
Livewire.hook('request', ({ uri, options, payload, respond, succeed, fail }) => {
options.headers['X-Tenant'] = window.tenantId;
succeed(({ status, json }) => {
console.debug('Livewire responded', status);
});
fail(({ status, preventDefault }) => {
if (status === 419) {
preventDefault();
window.location.reload(); // the CSRF token expired
}
});
});
// Before and after morphing an individual element
Livewire.hook('morph.updated', ({ el, component }) => {});
// A component has initialised
Livewire.hook('component.init', ({ component }) => {});
});
// Programmatic access to components
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());20. Testing
Livewire ships its own testing API on top of Laravel's. The tests are fast — no browser needed, plain PHPUnit or Pest will do.
<?php
use App\Livewire\Counter;
use Livewire\Livewire;
it('increments the counter', function () {
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1)
->call('increment')
->assertSet('count', 2)
->call('decrement')
->assertSet('count', 1);
});use App\Livewire\ContactForm;
use App\Models\Contact;
use Livewire\Livewire;
it('validates the required fields', function () {
Livewire::test(ContactForm::class)
->set('name', '')
->set('email', 'not-an-email')
->call('submit')
->assertHasErrors([
'name' => 'required',
'email' => 'email',
])
->assertNoRedirect();
});
it('stores a valid enquiry', function () {
Livewire::test(ContactForm::class)
->set('name', 'Irene Kovac')
->set('email', 'irene@example.com')
->set('message', str_repeat('We need a website for a clinic. ', 2))
->set('consent', true)
->call('submit')
->assertHasNoErrors()
->assertDispatched('notify');
expect(Contact::where('email', 'irene@example.com')->exists())->toBeTrue();
});The main assertions
| Method | Checks |
|---|---|
assertSet('prop', $value) | A property's value |
assertNotSet('prop', $value) | The value differs |
assertSee('text') | The text is in the markup |
assertDontSee('text') | The text is absent |
assertSeeHtml('<b>') | The markup is present |
assertHasErrors(['email']) | Validation errors |
assertHasNoErrors() | No errors |
assertDispatched('event') | An event was dispatched |
assertRedirect(route(…)) | A redirect happened |
assertStatus(403) | HTTP status |
assertForbidden() | Access denied |
assertCount('items', 3) | Array or collection size |
Authentication, parameters, events
// As a signed-in user
Livewire::actingAs($admin)
->test(OrderTable::class)
->assertSee('All orders');
// With mount() parameters
Livewire::test(OrderCard::class, ['order' => $order, 'mode' => 'full'])
->assertSee($order->number);
// Receiving an event
Livewire::test(OrderList::class)
->dispatch('order-created', orderId: 42)
->assertSee('Order #42');
// File upload
use Illuminate\Http\UploadedFile;
Livewire::test(AvatarUploader::class)
->set('avatar', UploadedFile::fake()->image('avatar.jpg', 400, 400))
->call('save')
->assertHasNoErrors();
// A component inside a page
$this->get('/dashboard')
->assertSeeLivewire(Dashboard::class)
->assertDontSeeLivewire(AdminPanel::class);21. Security
1. Authorise inside every action
public function delete(int $postId): void
{
$post = Post::findOrFail($postId);
$this->authorize('delete', $post); // policy — non-negotiable
$post->delete();
}// Authorising the whole component
public function mount(Project $project): void
{
$this->authorize('view', $project);
$this->project = $project;
}2. #[Locked] on identifiers
use Livewire\Attributes\Locked;
class InvoiceEditor extends Component
{
#[Locked]
public int $invoiceId; // cannot be tampered with from the frontend
public string $note = '';
}Without #[Locked] one line in the browser console is enough:
Livewire.find('...').set('invoiceId', 999) // someone else's invoice3. Rules for model binding
wire:model="post.title" only works when the field is allowed by a validation rule —
that is how Livewire guards against mass assignment.
protected function rules(): array
{
return [
'post.title' => 'required|string|max:180',
'post.body' => 'required|string',
// 'post.user_id' deliberately omitted — otherwise the post could be reassigned
];
}4. Escape your output
{{-- Safe: escaped --}}
{{ $comment->body }}
{{-- Dangerous: raw markup from a user --}}
{!! $comment->body !!}
{{-- If you need markup, sanitise it server-side --}}
{!! clean($comment->body) !!}5. Rate limiting
use Illuminate\Support\Facades\RateLimiter;
public function login(): void
{
$key = 'login:' . request()->ip();
if (RateLimiter::tooManyAttempts($key, 5)) {
throw ValidationException::withMessages([
'email' => 'Too many attempts. Try again in a minute.',
]);
}
RateLimiter::hit($key, 60);
// …
}6. Never keep secrets in properties
class PaymentForm extends Component
{
public string $cardLast4 = ''; // fine
// DO NOT: this reaches the browser in clear text inside wire:snapshot
// public string $apiSecret = '';
// public string $fullCardNumber = '';
protected function gateway(): PaymentGateway
{
return app(PaymentGateway::class); // secrets stay on the server
}
}Pre-release checklist
$this->authorize()appears in every action that mutates data.- All identifiers are marked
#[Locked]. - Validation rules exclude internal fields (
user_id,role,price). - Public properties hold no tokens, keys or unnecessary personal data.
- File uploads are constrained by MIME type and size.
- Login and contact forms are rate limited.
{!! !!}is used only for trusted or sanitised markup.
22. Performance
1. Keep public state small
public Collection $products; // 500 models travel to the client and back
public function mount(): void
{
$this->products = Product::with('category', 'images')->get();
}public function render()
{
return view('livewire.catalog', [
'products' => Product::with('category')->paginate(24),
]);
}2. Do not fire a request per keystroke
{{-- Bad: a request per character --}}
<input wire:model.live="search">
{{-- Good --}}
<input wire:model.live.debounce.400ms="search">3. Split heavy pages into components
Updating a child does not re-render the parent. A dashboard built from six independent widgets feels markedly snappier than one monolith.
4. Cache expensive work
#[Computed(persist: true, seconds: 600)]
public function monthlyRevenue(): array
{
return app(RevenueService::class)->byMonth();
}5. Watch for N+1
// Bad — one query per row in the view
$orders = Order::paginate(50);
// Good
$orders = Order::with(['customer', 'items.product'])->paginate(50);Turn on strict mode in development so N+1 fails loudly:
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}6. wire:key in lists
Without keys the morph algorithm rebuilds more nodes than needed — and gets it wrong.
7. Lazy-load heavy blocks
<livewire:revenue-chart lazy="on-scroll" />8. Go easy on polling
wire:poll.visible.10s instead of wire:poll saves the server an order of
magnitude in requests.
| Symptom | Likely cause |
|---|---|
| 300–800 ms lag on every action | Expensive render() or N+1 |
| Enormous page markup | Model collections in public properties |
| Rising PHP-FPM load | Aggressive wire:poll or .live without debounce |
| Fields "jump" when the list updates | wire:key is missing |
| The whole page re-renders | Everything in one component, no split |
23. Common errors and fixes
"Component must have a single root element"
The view has several root nodes, or text or a comment at the top level. Wrap everything in one <div>.
<h1>Heading</h1>
<p>Text</p><div>
<h1>Heading</h1>
<p>Text</p>
</div>"Livewire encountered corrupt data"
The snapshot signature does not match. Causes: a changed APP_KEY, a page served from
cache after a deploy, two tabs with different sessions. A reload usually fixes it; after a deploy run
php artisan optimize:clear.
Clicks do nothing and the console is empty
@livewireScriptsis missing from the layout.- A second Alpine.js instance is loaded (Livewire 3 already bundles it).
- A JS error higher up the page aborted execution.
- The element sits inside
wire:ignore.
Field values "move" between rows
The loop is missing wire:key or :key. The key must be stable and unique.
@foreach ($rows as $row)
<div wire:key="row-{{ $row->id }}">…</div>
@endforeach"Unable to set component data. Public property not found"
You bound wire:model to a property that does not exist or is not public. Check the
spelling and the visibility modifier.
"Cannot bind to model data without validation rules"
A binding like wire:model="post.title" needs a rule for post.title in
rules() or #[Validate].
A third-party widget breaks after an update
Select2, Flatpickr, TinyMCE and friends mutate the DOM themselves. Wrap them in
wire:ignore and synchronise manually.
<div wire:ignore x-data x-init="
const picker = flatpickr($refs.input, {
onChange: (dates, str) => $wire.set('date', str),
});
">
<input x-ref="input" type="text">
</div>File uploads fail silently
Check upload_max_filesize and post_max_size in PHP, plus
client_max_body_size in nginx. They must exceed the limit in your max: rule.
The modal does not close after saving
Dispatch a browser event and handle it in Alpine instead of relying on a re-render.
$this->dispatch('close-modal', name: 'order-form');Scripts stop working after wire:navigate
Move initialisation from DOMContentLoaded to livewire:navigated.
Error 419 (Page Expired)
The session expired. Raise SESSION_LIFETIME, or catch the status in the
request hook and reload the page — see the example in chapter 19.
24. Cheat sheet
Blade directives
| Directive | Purpose |
|---|---|
wire:model | Bind a field to a property (deferred) |
wire:model.live | Bind with a request on every change |
wire:model.blur | Send on focus loss |
wire:click | Call a method on click |
wire:submit | Handle a form submit |
wire:keydown.enter | React to a key |
wire:change | React to change |
wire:confirm | Confirm before acting |
wire:loading | Loading indicator |
wire:target | Scope the indicator to an action |
wire:dirty | There are unsaved changes |
wire:offline | No connection |
wire:poll | Periodic refresh |
wire:init | Call a method right after render |
wire:navigate | SPA-style link navigation |
wire:key | Element identity inside a loop |
wire:ignore | Exclude a subtree from morphing |
wire:transition | Enter/leave animation |
wire:stream | Stream content |
PHP attributes
| Attribute | Purpose |
|---|---|
#[Validate] | Validation rule for a property |
#[Locked] | Forbid changes from the frontend |
#[Computed] | Computed property with caching |
#[Url] | Sync with the query string |
#[Session] | Persist the value in the session |
#[On] | Event listener |
#[Reactive] | Refresh a prop from the parent |
#[Modelable] | Support wire:model on the component |
#[Lazy] | Lazy-load the component |
#[Layout] | Layout for a full-page component |
#[Title] | Page title |
#[Renderless] | Method that skips re-rendering |
Handy component methods
$this->reset(); // all properties back to their initial values
$this->reset('search', 'page'); // reset a selection
$this->only('title', 'body'); // array of some properties
$this->except('password'); // everything but the named ones
$this->fill(['title' => 'New']); // mass assignment
$this->pull('draft'); // read and reset
$this->validate();
$this->validateOnly('email');
$this->resetValidation();
$this->addError('email', 'Message');
$this->dispatch('saved', id: $post->id);
$this->redirect('/orders', navigate: true);
$this->redirectRoute('orders.index', navigate: true);
$this->skipRender(); // skip re-rendering this cycle
$this->js('alert("Done")'); // run JS on the client
$this->stream(to: 'answer', content: $chunk);Artisan commands
php artisan livewire:make Orders/OrderTable
php artisan livewire:make Counter --inline
php artisan livewire:form PostForm
php artisan livewire:attribute ValidPhone
php artisan livewire:publish --config
php artisan livewire:publish --assetsOfficial resources
- Livewire documentation: livewire.laravel.com/docs
- Laravel documentation: laravel.com/docs
- Alpine.js: alpinejs.dev