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.

Livewire 4.x Laravel 11 / 12 PHP 8.2+ 26 chapters

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

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

Coming from Livewire 3? Chapters 3, 6, 10 and 11 are where the real novelty is — single-file components, the new wire:model semantics, islands and slots. The rest of the component model will feel familiar.

2. Installation and requirements

Bash
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 endpoints changed. Update URLs now carry a hash: /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

Bash
php artisan livewire:publish --config

Settings that are new or renamed in version 4:

SettingPurpose
component_locationsDirectories scanned for components. Defaults to resources/views/components and resources/views/livewire.
component_namespacesNamed component roots, e.g. pages::.
component_layoutWas layout in v3. Uses the layouts:: namespace.
component_placeholderWas lazy_placeholder in v3.
make_commandControls what make:livewire generates: single-file or class-based.
smart_wire_keysNow defaults to true.
csp_safeEmits a build that satisfies stricter Content Security Policies.

Choosing the default component style

config/livewire.php
// 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.

Bash
php artisan make:livewire post.create
# resources/views/components/post/⚡create.blade.php
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.

Bash
php artisan make:livewire post.create --mfc

Class-based components still work

app/Livewire/CreatePost.php
<?php

namespace App\Livewire;

use Livewire\Component;

class CreatePost extends Component
{
    public string $title = '';

    public function render()
    {
        return view('livewire.create-post');
    }
}
Which to pick. Single-file wins for small and medium components — a form, a table row, a widget. Reach for class-based or multi-file when the class carries real logic, has many dependencies, or when your team already has conventions built around 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.

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

Blade
{{-- 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" />
Component tags must be closed. In v4 an unclosed <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:

routes/web.php
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

config/livewire.php
'component_layout' => 'layouts::app',
PHP
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.

PHP
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".

resources/views/components/⚡alert.blade.php
<?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>
Blade
<livewire:alert type="warning" dismissible class="mt-4">
    The invoice is overdue.
</livewire:alert>

mount() still runs first

PHP
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

PHP
use Livewire\Attributes\Locked;

new class extends Component {
    #[Locked]
    public int $invoiceId;   // the client cannot substitute another id

    public string $note = '';
};
Nothing about v4 changes the threat model: every public property is client-writable unless you lock it, and every public method is a reachable endpoint. See chapter 25.

6. wire:model in version 4

This is the change most likely to bite you. Two aspects of 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.

Blade
{{-- 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.

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

Blade
{{-- 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">
Binding to a model property (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.

PHP
new class extends Component {
    public function save()
    {
        $this->validate();
        // …
    }
};
Blade
<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.

PHP
use Livewire\Attributes\Renderless;

new class extends Component {
    #[Renderless]
    public function incrementViewCount(): void
    {
        $this->post->increment('views');
    }
};
Blade
{{-- 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.

PHP
use Livewire\Attributes\Async;

new class extends Component {
    #[Async]
    public function logInteraction(string $element): void
    {
        Analytics::record($element, auth()->id());
    }
};
Blade
<button wire:click.async="logInteraction('cta-hero')">Get started</button>
Never use an async action that changes state shown in the UI. Because it bypasses the request queue, its result can land out of order relative to other updates, and you get state that contradicts the screen. Async is for side effects that the interface does not read back.

Magic actions

Blade
<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>
Security has not moved. Authorise every parameter server-side, and mark helper methods 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.

Initial render
1. Component instance created
2. boot()
3. mount($params)
4. booted()
5. render()
Subsequent update
 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 + effects
PHP
new 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));
    }
};
Islands change how much gets rendered, not the order of these hooks. An island update still runs the full server lifecycle — it just returns a fragment instead of the whole component.

9. Validation and Form objects

Validation carries over from v3 unchanged, including attributes and Form objects.

PHP
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

PHP
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

app/Livewire/Forms/PostForm.php
<?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'));
    }
}
Single-file component using it
<?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.

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

Blade
@island
    <div>Revenue: {{ $this->revenue }}</div>
@endisland

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

A dashboard with three islands
<?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

OptionEffect
nameIdentifies the island so actions and JavaScript can target it. Several islands sharing a name always render as a group.
lazyRenders when the island scrolls into view (intersection observer).
deferRenders immediately after the page loads, regardless of visibility.
alwaysForces the island to update whenever the parent re-renders.
skipSkips the initial render entirely.

Targeting an island from an action

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

Blade
@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:

Blade
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
    Load more
</button>

Polling scoped to an island

Blade
@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.

Islands vs child components. Use an island when the region shares the parent's state and only needs isolated rendering. Use a child component when the region needs its own state, its own lifecycle, or is reused in several places.

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

Parent template
<livewire:modal>
    <h2>Create a post</h2>

    <form wire:submit="save">
        <input wire:model="title">
        <button type="submit">Save</button>
    </form>
</livewire:modal>
resources/views/components/⚡modal.blade.php
<?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

Parent template
<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>
Inside the component
<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.

resources/views/components/⚡alert.blade.php
<?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>
Blade
<livewire:alert type="danger" class="mt-6" data-testid="overdue-alert">
    This invoice is 14 days overdue.
</livewire:alert>
When you render a full-page component, named slots meant for the layout can be placed outside the component's root element.

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.

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

Keep writing keys where identity matters. Any list that can be reordered, filtered or partially deleted should carry an explicit 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.
Blade
@foreach ($rows as $row)
    <div wire:key="row-{{ $row->id }}">…</div>
@endforeach

Reactive props

PHP
use Livewire\Attributes\Reactive;

new class extends Component {
    #[Reactive]
    public int $quantity;
};

Calling the parent

Blade
<button wire:click="$parent.refreshList()">Refresh the list</button>

Two-way binding on a component

PHP
use Livewire\Attributes\Modelable;

new class extends Component {
    #[Modelable]
    public int $value = 0;
};
Blade
<livewire:rating-input wire:model.live="review.rating" />
With islands available, reach for a child component less often. If the only reason for the split was "so it re-renders on its own", an island is lighter.

13. Events

The event API is unchanged from version 3.

PHP
// 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();
PHP
use Livewire\Attributes\On;

new class extends Component {
    #[On('post-created')]
    public function onPostCreated(int $postId, string $title): void
    {
        // …
    }
};
Blade
<button wire:click="$dispatch('open-modal', { name: 'create-order' })">New order</button>

Events to the browser

PHP
$this->dispatch('notify', type: 'success', message: 'Order saved');
Blade
<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>
Islands often replace events. The classic v3 pattern "component A dispatches, component B listens and refreshes" frequently becomes "both regions are islands in one component, and the action targets the other island" — one request instead of two.

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.

PHP
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);
    }
};
Blade
@island(name: 'totals')
    <p>Subtotal: {{ number_format($this->subtotal, 2) }} €</p>
    <p>Total incl. VAT: {{ number_format($this->total, 2) }} €</p>
@endisland

Caching beyond the request

PHP
#[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

PHP
unset($this->products, $this->subtotal, $this->total);
In the view a computed property is $this->products, never $products.

15. State in the URL and the session

PHP
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

PHP
public function save(): void
{
    $this->validate();

    Setting::updateOrCreate(['key' => 'theme'], ['value' => $this->theme]);

    session()->flash('status', 'Settings saved');
}
Blade
@if (session('status'))
    <div class="alert alert-success">{{ session('status') }}</div>
@endif
Every #[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.

resources/views/components/⚡avatar-uploader.blade.php
<?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

Blade
<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>
config/livewire.php
'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

PHP
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

Blade
<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>
On big tables 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.

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

CSS
[data-loading] .btn {
    opacity: 0.5;
    pointer-events: none;
}

Unsaved changes and connectivity

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

Blade
{{-- Toggles visibility via CSS, immediately, no round trip --}}
<div wire:show="showModal" class="modal">…</div>

<button wire:click="$toggle('showModal')">Open</button>

wire:text

Blade
{{-- Text content follows the property on the client --}}
<span wire:text="title"></span>

<input wire:model="title">

wire:bind

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

Blade
<?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>
Optimistic does not mean authoritative. These directives change what the user sees before the server has agreed. Anything that affects data, permissions or money still has to be validated and applied server-side — the client state is a preview, not a source of truth.
Good rule: 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.

Blade
<ul wire:sort="reorder">
    @foreach ($tasks as $task)
        <li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
            {{ $task->title }}
        </li>
    @endforeach
</ul>
PHP
new class extends Component {
    public function reorder(array $order): void
    {
        foreach ($order as $position => $id) {
            Task::where('id', $id)->update(['position' => $position]);
        }
    }
};

Drag handles

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

Blade
<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>
Persist the order. The directive reorders the DOM optimistically and hands you the new sequence — writing it to the database is your job. If the handler throws, the UI and the data disagree until the next full render.

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.

resources/views/components/⚡pricing-card.blade.php
<?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

Blade
<style global>
    :root { --brand: #f53004; }
</style>

Component scripts

Blade
@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
Blade
{{-- Load a third-party library once for the whole page --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassets
Scoped CSS is a convenience for component-local styling, not a replacement for a design system. Keep tokens, typography and layout primitives in your global stylesheet; keep the handful of rules that only this component cares about in the component.

23. JavaScript integration

Alpine.js still ships with Livewire, and $wire remains the bridge into component state from JavaScript.

Blade
<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>
Blade
{{-- Two-way binding into Alpine --}}
<div x-data="{ query: $wire.entangle('search') }">
    <input x-model="query">
</div>

wire:ref — naming elements and components

Blade
<livewire:modal wire:ref="modal" />

<button x-on:click="$refs.modal.open()">Open the modal</button>

#[Json] — returning data straight to JavaScript

PHP
use Livewire\Attributes\Json;

new class extends Component {
    #[Json]
    public function searchSuggestions(string $term): array
    {
        return Product::search($term)->take(5)->pluck('name')->all();
    }
};
Blade
<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().

JavaScript
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);
        });
    });
});
JavaScript
// Programmatic access
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());

Ignoring a subtree

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

Bash
php artisan make:livewire post.create --test
# resources/views/components/post/create.test.php

Testing a view-based component

Reference the component by its dot-notation name rather than a class:

PHP
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

PHP
use App\Livewire\Counter;

it('increments', function () {
    Livewire::test(Counter::class)
        ->assertSet('count', 0)
        ->call('increment')
        ->assertSet('count', 1);
});

The main assertions

MethodChecks
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
PHP
// 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');
Test authorisation in actions, validation rules and state transitions first. That is where functionality breaks and where security holes hide — islands and slots do not change that.

25. Security and performance

The threat model is identical to v3. Every public method is an open HTTP endpoint, every public property is client-writable. Single-file components make it easier to skim a component — which also makes it easier to skim past a missing authorisation check.

Security checklist

  • $this->authorize() in every action that mutates data.
  • All identifiers marked #[Locked].
  • Helper methods declared protected or private so 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.
PHP
public function delete(int $postId): void
{
    $post = Post::findOrFail($postId);

    $this->authorize('delete', $post);

    $post->delete();
}

Performance

SymptomFix in v4
A widget update re-runs every query on the pageWrap the regions in @island
Huge page markupModel collections out of public properties; fetch them in with() or #[Computed]
A request per keystrokewire:model.live.debounce.400ms
Polling loadPoll inside an island, add .visible
A round trip to toggle a panelwire:show instead of an action
Counter bumps re-render the page#[Renderless]
N+1 querieswith() eager loading; Model::preventLazyLoading() in dev
Slow first paint on a heavy block@island(lazy: true) with a @placeholder
app/Providers/AppServiceProvider.php
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 name but 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

DirectivePurposeNew in v4
wire:modelBind a field to a propertysemantics changed
wire:model.deepCapture events from child elementsyes
wire:click / wire:submitCall an action
wire:click.asyncRun the action in parallelyes
wire:click.renderlessSkip the re-renderyes
wire:islandTarget an island from an actionyes
wire:island.appendAppend to an island instead of replacingyes
wire:showToggle visibility on the clientyes
wire:textBind text content on the clientyes
wire:bindBind an attribute reactivelyyes
wire:sortDrag-and-drop reorderingyes
wire:intersectAct when the element enters the viewportyes
wire:refName an element or component for JSyes
wire:navigateSPA-style navigation
wire:navigate:scrollPreserve container scrollrenamed
wire:loading / wire:dirty / wire:offlineRequest state
wire:pollPeriodic refreshisland-scoped
wire:keyElement identity in a loopsmart keys default on
wire:ignoreExclude a subtree from morphing

Blade directives

DirectivePurpose
@island … @endislandAn independently rendering region
@placeholder … @endplaceholderPlaceholder for a lazy island
<wire:slot name="…">Named slot content
@script … @endscriptComponent-scoped JavaScript
@assets … @endassetsPage-level assets loaded once
@persist … @endpersistKeep an element across navigations
<style> / <style global>Scoped / global component CSS

PHP attributes

AttributePurpose
#[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

Bash
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:clear

Official resources

Livewire 4 is actively evolving. Where this guide and the official documentation disagree, the official documentation wins — check it before relying on a detail in production.