Livewire
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Livewire 4.4 (4.4.7) on Laravel 13.x (13.34.0) and PHP 8.3 to 8.5, checked on 2026-10-05 against the Livewire 4.x documentation. Every component is built in the Bookshelf application used across this guide, and its behaviour is checked with Livewire’s test API. Livewire lets a Blade component keep state on the server and react to the browser: interactions become small AJAX requests, and the server re-renders the component and sends back the changed HTML. It suits teams that want an interactive UI written in PHP; the alternatives are compared on Vite and Frontend Assets.
How Livewire works
The first request renders the component as ordinary HTML, with its state serialized into a wire:snapshot
attribute (signed with APP_KEY, so it cannot be forged). When the user types into a wire:model field or clicks a
wire:click button, Livewire’s JavaScript sends the snapshot and the change to /livewire-…/update; the server
rebuilds the component, applies the change, runs the action, renders again and returns the new HTML and snapshot,
which the client morphs into the page. The rendered page of Bookshelf’s /live/shelf/1, shortened:
<div wire:snapshot="{"data":{"book":[null,{"class":"App\\Models\\Book","key":1,"s":"mdl"}]},…}" wire:id="…">
<h1>Kindred · <span wire:snapshot="…" wire:name="review-count" x-intersect="$wire.__lazyLoad('…')"></span></h1>
<div wire:snapshot="{"data":{"search":"","onlyReviewed":false},…}">
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Search titles">
…
<script src="http://localhost:8000/livewire-635412e1/livewire.js?id=8ea5922c" data-csrf="…" data-update-uri="…"></script>
Source: Livewire — How Livewire Works
The snapshot contains every public property, which is why public properties are public in the browser sense too:
the user can read them and send changed values. A model property is stored as its class and key ("key":1) and
reloaded on each request. Livewire injects its script and styles automatically (or with @livewireScripts and
@livewireStyles) and bundles Alpine.js.
Components: single-file, multi-file and class
php artisan make:livewire book-search creates a single-file component, the Livewire 4 default: an anonymous class
and its template in one file, named with a ⚡ prefix so components stand out in the file tree:
<?php
// resources/views/components/⚡book-search.blade.php (the PHP part; the template follows the closing ?>)
use App\Models\Book;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
new class extends Component
{
#[Url(as: 'q', except: '')] // kept in the address bar: /live/shelf?q=dis
public string $search = '';
public bool $onlyReviewed = false;
#[Computed] // memoised for this request, read as $this->books
public function books()
{
return Book::query()
->when($this->search !== '', fn ($q) => $q->where('title', 'like', "%{$this->search}%"))
->when($this->onlyReviewed, fn ($q) => $q->has('reviews'))
->orderBy('title')
->limit(5)
->get();
}
public function clear(): void
{
$this->reset('search', 'onlyReviewed');
}
};
{{-- resources/views/components/⚡book-search.blade.php (the template) --}}
<div>
<input type="search" wire:model.live.debounce.300ms="search" placeholder="Search titles">
<label><input type="checkbox" wire:model.live="onlyReviewed"> Reviewed only</label>
<button wire:click="clear">Clear</button>
<ul wire:loading.class="opacity-50">
@forelse ($this->books as $book)
<li wire:key="book-{{ $book->id }}">{{ $book->title }}</li>
@empty
<li>No match for "{{ $search }}".</li>
@endforelse
</ul>
</div>
Source: Livewire — Components
--mfc creates a multi-file component, a ⚡book-search/ directory with book-search.php,
book-search.blade.php and optional .js, .css and .test.php files; --class creates the Livewire 3 layout, a
class in app/Livewire and a view in resources/views/livewire, which the review form below uses;
livewire:convert switches a component between formats. The ⚡ is optional ('make_command' => ['emoji' => false]
in config/livewire.php). A component is used as <livewire:book-search />; in Livewire 4 the tag must be closed,
because components can now take slots. A template must have a single root element.
Properties, wire:model and actions
Public properties are the state. wire:model="search" binds an input to a property; when the server hears about
it depends on the modifier, and Livewire 4 changed the meaning of some:
| Binding | Behaviour in Livewire 4 |
|---|---|
|
Updates the client-side state as the user types; sent with the next action (a click or submit). |
|
Sends a request on each change, debounced by 150 ms by default. |
|
Updates the client-side state only on blur or change, and sends nothing until the next action. Livewire 3’s
meaning (send on blur) is now |
|
Unchanged from Livewire 3. |
|
Listen to input events from child elements; Livewire 4 listens only to the element itself by default. |
Source: Livewire — Upgrade Guide and Properties
Public methods are actions, called with wire:click="clear", wire:submit="save", wire:keydown.enter, or from
Alpine as $wire.clear(). Parameters come from the template (wire:click="remove({{ $book->id }})") and therefore
from the browser: validate and authorize them like request input. Actions receive dependencies from the container,
as save(PostReview $postReview) does below. wire:loading shows or styles elements while a request is in flight,
wire:target narrows it to one action, and wire:key gives loop items a stable identity so the DOM morph does not
mix them up.
Forms, validation, hooks and uploads
The review form is a class-based component with a form object, a Livewire\Form class that groups fields and
their #[Validate] rules, and an upload:
<?php
// app/Livewire/Forms/ReviewData.php (php artisan livewire:form ReviewData)
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class ReviewData extends Form
{
#[Validate('required|integer|between:1,5')]
public int $rating = 5;
#[Validate('required|string|min:10|max:2000')]
public string $body = '';
#[Validate('nullable|image|max:1024')] // kilobytes
public $photo;
}
<?php
// app/Livewire/ReviewForm.php (php artisan make:livewire ReviewForm --class)
namespace App\Livewire;
use App\Actions\PostReview;
use App\Livewire\Forms\ReviewData;
use App\Models\Book;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithFileUploads;
class ReviewForm extends Component
{
use WithFileUploads;
#[Locked] // the browser cannot change it
public int $bookId;
public ReviewData $form;
public function mount(Book $book): void // runs once, on the first render
{
$this->bookId = $book->id;
}
public function updatedFormBody(): void // lifecycle hook: after form.body changes
{
$this->validateOnly('form.body');
}
public function save(PostReview $postReview): void
{
$this->authorize('create', \App\Models\Review::class);
$this->validate();
$review = $postReview->handle(auth()->user(), Book::findOrFail($this->bookId), $this->form->rating, $this->form->body);
if ($this->form->photo) {
$this->form->photo->store("reviews/{$review->id}", 'local');
}
$this->form->reset();
$this->dispatch('review-posted', bookId: $this->bookId); // other components can listen
session()->flash('status', 'Thanks for your review.');
}
public function render()
{
return view('livewire.review-form');
}
}
{{-- resources/views/livewire/review-form.blade.php --}}
<form wire:submit="save">
@session('status') <p class="notice">{{ $value }}</p> @endsession
<select wire:model="form.rating">
@foreach (range(5, 1) as $n) <option value="{{ $n }}">{{ $n }} stars</option> @endforeach
</select>
<textarea wire:model.blur="form.body"></textarea>
@error('form.body') <p class="error">{{ $message }}</p> @enderror
<input type="file" wire:model="form.photo">
<div wire:loading wire:target="form.photo">Uploading…</div>
@if ($form->photo) <img src="{{ $form->photo->temporaryUrl() }}" alt="Preview"> @endif
<button type="submit" wire:loading.attr="disabled">Post review</button>
</form>
Source: Livewire — Forms, Lifecycle Hooks and File Uploads
-
Lifecycle hooks:
mount()runs once with the props,boot()andhydrate()on every request,updating{Property}/updated{Property}around a property change (updatedFormBodyforform.body),renderingandrenderedaround the template. -
#[Locked]refuses any change from the browser; without it,bookIdcould be edited in the snapshot request and the review posted to another book. Models in public properties are safe from tampering for the same reason, but IDs and other scalars that drive authorization need#[Locked]or a check. -
$this->validate()uses the#[Validate]rules and puts errors in the usual$errorsbag, so@errorworks; the rules are Laravel’s own (Validation and Form Requests). -
Uploads go to a temporary directory as soon as the user picks a file (validated against
livewire.temporary_file_upload.rules, 12 MB by default),temporaryUrl()previews an image, andstore()moves it to a disk when the form is saved.
Events, lazy loading and pages
dispatch('review-posted', bookId: ...) sends a browser event that any Livewire component on the page can handle
with #[On('review-posted')]. The review count listens for it, and is lazy: its slow mount() runs in a separate
request once the element scrolls into view, with placeholder() shown until then:
<?php
// resources/views/components/⚡review-count.blade.php (template: <span>{{ $count }} {{ Str::plural('review', $count) }}</span>)
use App\Models\Review;
use Livewire\Attributes\On;
use Livewire\Component;
new class extends Component
{
public int $bookId;
public int $count = 0;
public function mount(): void
{
usleep(300_000); // a slow query, worth loading lazily
$this->count = Review::where('book_id', $this->bookId)->count();
}
#[On('review-posted')] // dispatched by ReviewForm
public function refreshCount(int $bookId): void
{
if ($bookId === $this->bookId) {
$this->count = Review::where('book_id', $bookId)->count();
}
}
public function placeholder(): string
{
return '<span>Counting reviews…</span>';
}
};
A page component is routed with Route::livewire() and rendered inside resources/views/layouts/app.blade.php
(created by php artisan livewire:layout); the pages:: namespace maps to resources/views/pages:
<?php
// resources/views/pages/live/⚡shelf.blade.php (PHP part)
use App\Models\Book;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Title('Live shelf')] class extends Component
{
public Book $book; // route model binding: /live/shelf/{book}
};
?>
<div>
<h1>{{ $book->title }} · <livewire:review-count :book-id="$book->id" lazy /></h1>
<livewire:book-search />
<livewire:review-form :book="$book" />
<a href="/live/shelf/2" wire:navigate>Next book</a>
</div>
<?php
// routes/catalogue.php
use Illuminate\Support\Facades\Route;
Route::livewire('/live/shelf/{book}', 'pages::live.shelf');
Source: Livewire — Events, Lazy Loading and Pages
The rendered page above shows the lazy component as an empty <span> with x-intersect="$wire.__lazyLoad(...)" and
"count":0 in its snapshot: mount() had not run. wire:navigate on a link fetches the next page in the
background and swaps the body without a full reload, keeping @persist elements (an audio player, a sidebar) alive;
wire:navigate.hover prefetches on hover. Scripts relying on DOMContentLoaded must listen for livewire:navigated
instead.
Testing components
Livewire::test() mounts a component without a browser and drives it through the same lifecycle:
<?php
// tests/Feature/LivewireTest.php
use App\Livewire\ReviewForm;
use App\Models\Book;
use App\Models\Review;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
it('filters books as the search changes', function () {
Book::factory()->create(['title' => 'The Dispossessed']);
Book::factory()->create(['title' => 'Kindred']);
Livewire::test('book-search')
->assertSee('Kindred')
->set('search', 'dispo')
->assertSee('The Dispossessed')
->assertDontSee('Kindred')
->call('clear')
->assertSet('search', '')
->assertSee('Kindred');
});
it('keeps the search in the query string', function () {
Book::factory()->create(['title' => 'Kindred']);
Livewire::withQueryParams(['q' => 'kin'])->test('book-search')->assertSet('search', 'kin');
});
it('validates, saves, uploads and dispatches an event', function () {
Storage::fake('local');
$book = Book::factory()->create();
Livewire::actingAs(User::factory()->create())
->test(ReviewForm::class, ['book' => $book])
->set('form.body', 'Too short')
->assertHasErrors(['form.body' => 'min'])
->set('form.body', 'A slow start, then impossible to put down.')
->set('form.photo', UploadedFile::fake()->image('shelf.jpg'))
->call('save')
->assertHasNoErrors()
->assertDispatched('review-posted', bookId: $book->id)
->assertSet('form.body', '');
expect(Review::where('book_id', $book->id)->count())->toBe(1);
Storage::disk('local')->assertExists('reviews/'.Review::first()->id);
});
it('refuses unverified users', function () {
Livewire::actingAs(User::factory()->unverified()->create())
->test(ReviewForm::class, ['book' => Book::factory()->create()])
->set('form.body', 'A slow start, then impossible to put down.')
->call('save')
->assertForbidden();
});
it('refuses changes to a locked property', function () {
Livewire::test(ReviewForm::class, ['book' => Book::factory()->create()])->set('bookId', 999);
})->throws(CannotUpdateLockedPropertyException::class);
it('updates the lazy count when a review is posted', function () {
$book = Book::factory()->create();
$component = Livewire::test('review-count', ['bookId' => $book->id])->assertSee('0 reviews');
Review::factory()->for($book)->create();
$component->dispatch('review-posted', bookId: $book->id)->assertSee('1 review');
});
$ php artisan test --filter=LivewireTest
PASS Tests\Feature\LivewireTest
✓ it filters books as the search changes 0.20s
✓ it keeps the search in the query string 0.01s
✓ it validates, saves, uploads and dispatches an event 0.05s
✓ it refuses unverified users 0.02s
✓ it refuses changes to a locked property 0.01s
✓ it updates the lazy count when a review is posted 0.32s
Tests: 6 passed (18 assertions)
Duration: 0.67s
Source: Livewire — Testing
The updatedFormBody hook produced the min error as soon as the field changed, before save. The
ReviewPolicy::create() rule (a verified e-mail address) answered 403 for the unverified user. The test of the
lazy component took 0.32 s because Livewire::test() mounts it immediately; lazy loading only applies to the tag.
Single-file components are addressed by name ('book-search'), class components by class.
Migrating from Volt
Volt was the single-file option of Livewire 3. Livewire 4’s own single-file components use the same syntax as Volt’s class-based components, so the upgrade guide’s migration is mechanical:
-
Livewire\Volt\ComponentbecomesLivewire\Component; -
Volt::route('/dashboard', 'dashboard')becomesRoute::livewire('/dashboard', 'pages::dashboard'); -
Volt::test('counter')becomesLivewire::test('counter'); -
delete
app/Providers/VoltServiceProvider.phpand its line inbootstrap/providers.php, thencomposer remove livewire/volt.
Source: Livewire — Upgrading Volt
Volt’s functional API (state([...]), closures as actions) still works with Livewire 4 for those who prefer it,
but it is optional and most applications no longer need it.
Flux UI and Alpine.js
Flux (livewire/flux, 2.20 here) is the official component library for Livewire, built on Tailwind CSS: buttons,
inputs, modals, dropdowns, tables and more as <flux:…> tags, with a free core and paid Pro components. The
Livewire starter kit uses it. One button, rendered with Blade::render() after composer require livewire/flux:
$ php artisan tinker --execute 'echo Blade::render("<flux:button variant=\"primary\" icon=\"plus\">Add book</flux:button>");'
<button type="button" class="relative items-center font-medium justify-center whitespace-nowrap disabled:opacity-50 … bg-[var(--color-accent)] …" data-flux-button>…Add book</button>
Source: Flux — Installation
Livewire includes Alpine.js and gives Alpine code inside a component the $wire object: $wire.search reads a
property, $wire.search = 'x' sets it, $wire.clear() calls an action, and $wire.entangle('open') shares a value
between Alpine and the server. Behaviour that needs no server round trip (toggles, tabs, character counters) belongs
in Alpine, with x-data, x-show and x-on, to avoid a request per keystroke.
Source: Livewire — Alpine
Under a Content Security Policy without 'unsafe-eval', such as the default policy on
Middleware, Alpine cannot compile those expressions and actions silently do
nothing. 'csp_safe' => true in config/livewire.php switches to Alpine’s CSP build, which parses a restricted
expression syntax instead (Livewire — CSP). The browser test on
Browser Testing with Dusk found the problem.
Where older books and tutorials are outdated
-
Livewire 2 and 3 tutorials with classes in
app/Http/Livewireorapp/Livewireand views inresources/views/livewirestill work, but Livewire 4 generates single-file components inresources/views/componentsby default. -
wire:model.defer(Livewire 2) became the defaultwire:modelin Livewire 3;wire:modelsending on every keystroke (Livewire 2) iswire:model.live. -
wire:model.blursending a request on blur iswire:model.live.blursince Livewire 4.1. -
Route::get('/dashboard', Dashboard::class)for full-page components still works for classes, butRoute::livewire()is required for single-file and multi-file components. -
$this->emit()and$listeners(Livewire 2) are$this->dispatch()and#[On]. -
Volt for single-file components is replaced by the built-in format.
-
Unclosed
<livewire:component>tags rendered in Livewire 3; in Livewire 4 they are treated as the start of a slot and the component does not render.