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="{&quot;data&quot;:{&quot;book&quot;:[null,{&quot;class&quot;:&quot;App\\Models\\Book&quot;,&quot;key&quot;:1,&quot;s&quot;:&quot;mdl&quot;}]},…}" wire:id="…">
    <h1>Kindred · <span wire:snapshot="…" wire:name="review-count" x-intersect="$wire.__lazyLoad('…')"></span></h1>
    <div wire:snapshot="{&quot;data&quot;:{&quot;search&quot;:&quot;&quot;,&quot;onlyReviewed&quot;: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>

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>

--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

wire:model

Updates the client-side state as the user types; sent with the next action (a click or submit).

wire:model.live (.live.debounce.300ms, .live.throttle.1s)

Sends a request on each change, debounced by 150 ms by default.

wire:model.blur, wire:model.change

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 wire:model.live.blur.

wire:model.lazy

Unchanged from Livewire 3.

wire:model.deep

Listen to input events from child elements; Livewire 4 listens only to the element itself by default.

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>
  • Lifecycle hooks: mount() runs once with the props, boot() and hydrate() on every request, updating{Property}/updated{Property} around a property change (updatedFormBody for form.body), rendering and rendered around the template.

  • #[Locked] refuses any change from the browser; without it, bookId could 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 $errors bag, so @error works; 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, and store() 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');

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

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\Component becomes Livewire\Component;

  • Volt::route('/dashboard', 'dashboard') becomes Route::livewire('/dashboard', 'pages::dashboard');

  • Volt::test('counter') becomes Livewire::test('counter');

  • delete app/Providers/VoltServiceProvider.php and its line in bootstrap/providers.php, then composer remove livewire/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>

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.

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/Livewire or app/Livewire and views in resources/views/livewire still work, but Livewire 4 generates single-file components in resources/views/components by default.

  • wire:model.defer (Livewire 2) became the default wire:model in Livewire 3; wire:model sending on every keystroke (Livewire 2) is wire:model.live.

  • wire:model.blur sending a request on blur is wire:model.live.blur since Livewire 4.1.

  • Route::get('/dashboard', Dashboard::class) for full-page components still works for classes, but Route::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.