[ Livewire · Документація ]

Документація Livewire 4

Повна документація з Livewire 4: однофайлові компоненти, острови з незалежним рендерингом, слоти, вбудований drag-and-drop і клієнтські директиви — з позначками, де четверта версія відрізняється від третьої.

  • 4.x · Поточна
  • Laravel 11 / 12
  • PHP 8.2+
  • 26 розділів
  • 40 хв читання

1. Що змінює Livewire 4

Livewire 4 — найбільший реліз в історії фреймворку. Сама модель компонента не змінилася: PHP-клас плюс Blade-шаблон, стан на сервері, HTML по дроту. Але майже все навколо неї зрушило: де лежать компоненти, як вони пишуться і яку частину сторінки зачіпає одне оновлення.

П’ять головних речей

МожливістьЩо вона дає
Однофайлові компоненти Клас і розмітка в одному файлі .blade.php. Більше не треба стрибати між двома каталогами заради компонента на двадцять рядків.
Острови (islands) Ізольовані ділянки всередині компонента, які перемальовуються самі по собі. Лічильник виручки, що оновлюється щосекунди, більше не перезапускає запити всього дашборда.
Слоти Батько передає розмітку в дочірній компонент, і вона обчислюється в контексті батька: wire:click усередині слота викликає метод батька.
Оптимістичний UI wire:show, wire:text, wire:bind миттєво змінюють DOM на клієнті, без звернення до сервера.
Drag and drop wire:sort вбудований. Ні SortableJS, ні склеювального коду.

Це переписування застосунку?

Ні. Livewire 4 зберігає високу зворотну сумісність: класові компоненти продовжують працювати. Однофайловий формат — умовчання для нових компонентів, а не примусова міграція. Є набір ламких змін, які варто прочитати до оновлення — вони зібрані в порівнянні Livewire 3 і 4, а офіційний upgrade guide залишається першоджерелом.

Прийшли з Livewire 3? Справжня новизна — у розділах 3, 6, 10 і 11: однофайлові компоненти, нова семантика wire:model, острови та слоти. Решта моделі компонента здасться знайомою.

2. Встановлення та вимоги

Bash
composer require livewire/livewire:^4.0

php artisan optimize:clear

Як і в третій версії, ассети підключаються автоматично. @livewireStyles і @livewireScripts потрібно ставити вручну лише за нестандартного layout або суворої Content Security Policy.

Ендпоінти Livewire змінилися. В URL оновлень з’явився хеш: /livewire/ став /livewire-{hash}/. Якщо у вас є правила фаєрвола, винятки WAF, обходи CDN або location-блоки nginx, прив’язані до буквального шляху /livewire/, їх треба розширити під новий шаблон — інакше на проді застосунок виглядатиме зламаним, ідеально працюючи на ноутбуці.

Публікація конфігурації

Bash
php artisan livewire:publish --config

Нові та перейменовані параметри четвертої версії:

ПараметрПризначення
component_locationsКаталоги, де шукаються компоненти. За замовчуванням resources/views/components і resources/views/livewire.
component_namespacesІменовані корені компонентів, наприклад pages::.
component_layoutБув layout у v3. Використовує namespace layouts::.
component_placeholderБув lazy_placeholder у v3.
make_commandВизначає, що генерує make:livewire: однофайловий чи класовий компонент.
smart_wire_keysТепер за замовчуванням true.
csp_safeВидає збірку, сумісну із суворішою Content Security Policy.

Вибір стилю компонентів за замовчуванням

config/livewire.php
// Продовжувати генерувати класові компоненти замість однофайлових
'make_command' => [
    'type' => 'class',
],

3. Однофайлові компоненти

Головна зміна в повсякденній роботі. Компонент тепер — це один Blade-файл, що починається з PHP-блоку з оголошенням анонімного класу, а далі йде розмітка.

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>

Публічні властивості доступні в розмітці як звичайні змінні — {{ $title }} працює так само, як працювало в окремому файлі шаблону.

Про емодзі в імені

За замовчуванням ім’я файлу починається з ⚡. Це візуальний маркер, який відрізняє Livewire-компоненти від звичайних Blade-компонентів у тому ж каталозі, і він необов’язковий — у config/livewire.php його можна вимкнути, якщо вашим інструментам, терміналу чи команді зручніші звичайні імена.

Багатофайлові компоненти

Коли компонент переростає комфортний розмір одного файлу, згенеруйте його багатофайловим — клас, шаблон і тест знову опиняться окремо.

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

Класові компоненти працюють далі

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');
    }
}
Що обирати. Однофайловий формат виграє для малих і середніх компонентів — форма, рядок таблиці, віджет. Класовий або багатофайловий кращий там, де в класі реальна логіка, багато залежностей, або коли в команди вже вибудувані домовленості навколо app/Livewire.

4. Розташування, іменування та namespace

Компоненти Livewire більше не живуть за замовчуванням в окремому «загоні» resources/views/livewire; вони лежать поруч з іншими Blade-компонентами.

Структура каталогів
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 />

Виведення

Blade
{{-- За іменем --}}
<livewire:counter />

{{-- Вкладені каталоги через крапку --}}
<livewire:post.create />

{{-- З namespace --}}
<livewire:pages::post.create />

{{-- З props --}}
<livewire:post.create :title="$initialTitle" :author="$user" />
Теги компонентів мають закриватися. У v4 незакритий <livewire:some-component> просто не рендериться — без помилки, без виводу. Завжди самозакривайте: <livewire:some-component />. Це найчастіша несподіванка під час перенесення Blade-файлів з v3.

Маршрут на компонент

Повносторінкові компоненти отримали окремий макрос маршруту. Стару форму Route::get('/dashboard', Dashboard::class) замінює:

routes/web.php
use App\Livewire\Dashboard;

// За класом
Route::livewire('/dashboard', Dashboard::class);

// За іменем компонента — звичний вибір для view-based компонентів
Route::livewire('/dashboard', 'pages::dashboard')
    ->middleware('auth')
    ->name('dashboard');

Layout

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. Властивості та props

Публічні властивості так само утворюють стан компонента, так само серіалізуються у snapshot між запитами і підпорядковані тим самим обмеженням за типами: скаляри, масиви скалярів, Eloquent-моделі та колекції, Carbon, enum.

PHP
new class extends Component {
    public string $name = '';
    public ?int $age = null;
    public array $tags = [];
    public bool $isPublic = false;
};

Props від батька: #[Prop]

Значення, передані з батьківського шаблону, можна оголосити явно. Тоді публічний інтерфейс компонента читається з першого погляду, а «це приходить ззовні» відокремлено від «це внутрішній стан».

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() так само виконується першим

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] важливий як ніколи

PHP
use Livewire\Attributes\Locked;

new class extends Component {
    #[Locked]
    public int $invoiceId;   // клієнт не зможе підставити чужий id

    public string $note = '';
};
Четверта версія нічого не змінює в моделі загроз: будь-яка публічна властивість доступна клієнту на запис, поки ви її не заблокували, і будь-який публічний метод — досяжний ендпоінт. Див. розділ 25.

6. wire:model у четвертій версії

Ця зміна підставить вас з найбільшою ймовірністю. Два аспекти wire:model поводяться у v4 інакше, і обидва ламаються мовчки, без помилки.

Зміна 1: модифікатори тепер керують синхронізацією на клієнті

У v3 .blur і .change вирішували лише, коли йде мережевий запит. У v4 вони вирішують, коли значення взагалі синхронізується — зокрема на клієнті. Щоб повернути попередню поведінку, додайте .live.

Blade
{{-- Livewire 3 --}}
<input wire:model.blur="title">

{{-- Еквівалент тієї ж поведінки у v4 --}}
<input wire:model.live.blur="title">

Зміна 2: події більше не спливають від дочірніх елементів

wire:model на обгортці більше не перехоплює події, що спливають від вкладених полів. Якщо ви на це покладалися, додайте .deep.

Blade
{{-- Livewire 3: обгортка ловила подію поля --}}
<div wire:model="value">
    <input type="text">
</div>

{{-- Livewire 4: вмикаємо явно --}}
<div wire:model.deep="value">
    <input type="text">
</div>

Решта без змін

Blade
{{-- За замовчуванням відкладено --}}
<input type="text" wire:model="name">

{{-- Запит на кожну зміну --}}
<input type="text" wire:model.live="search">

{{-- Живий пошук із затримкою --}}
<input type="text" wire:model.live.debounce.400ms="search">

{{-- Вкладені дані --}}
<input wire:model="form.address.city">
<input wire:model="post.title">
Прив’язка до властивості моделі (post.title) так само потребує відповідного правила валідації. Це захист від масового присвоєння, і він перейшов у v4 без змін.

7. Дії, async і renderless

Дії оголошуються точно як і раніше — публічні методи, які викликаються з розмітки.

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>

Дії без перемальовування

Коли дія не змінює нічого видимого для користувача — збільшує лічильник переглядів, пише рядок аудиту, — пропуск повторного рендеру економить повний прохід шаблону і порівняння DOM.

PHP
use Livewire\Attributes\Renderless;

new class extends Component {
    #[Renderless]
    public function incrementViewCount(): void
    {
        $this->post->increment('views');
    }
};
Blade
{{-- Або з боку шаблону --}}
<button type="button" wire:click.renderless="incrementViewCount">Track</button>

Імперативний еквівалент усередині методу — як і раніше $this->skipRender().

Асинхронні дії

#[Async] (або модифікатор .async) виконує дію паралельно, поза звичайною чергою запитів. Ідеально для роботи за принципом «вистрілив і забув»: пінги аналітики, логування, прогрів кешу.

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>
Ніколи не використовуйте async для дій, що змінюють стан, видимий в інтерфейсі. Оскільки така дія обходить чергу запитів, її результат може прийти не в тому порядку відносно інших оновлень — і ви отримаєте стан, що суперечить екрану. Async — для побічних ефектів, які інтерфейс не читає назад.

Магічні дії

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>
Безпека не зрушила. Авторизуйте кожен параметр на сервері, а допоміжні методи позначайте protected або private, щоб їх не можна було викликати з клієнта.

8. Життєвий цикл і хуки

Життєвий цикл не змінився порівняно з третьою версією. Знання порядку, як і раніше, позбавляє від більшості заплутаних багів.

Перший рендер
1. Component instance created
2. boot()
3. mount($params)
4. booted()
5. render()
Подальше оновлення
 1. POST /livewire-{hash}/update arrives with the state snapshot
 2. Snapshot checksum verified
 3. Component instance created
 4. boot()
 5. State restored from the snapshot
 6. hydrate() / hydrateFoo()
 7. booted()
 8. updating($prop, $value) / updatingFoo($value)
 9. Properties receive new values
10. updated($prop, $value) / updatedFoo($value)
11. Queued actions run
12. rendering() → render() → rendered($view, $html)
13. dehydrate() — state packed back into the snapshot
14. Response: markup + 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);
    }

    // Для конкретної властивості
    public function updatedPrice(mixed $value): void
    {
        $this->price = round((float) $value, 2);
    }

    // Вкладений ключ form.email
    public function updatedFormEmail(mixed $value): void
    {
        $this->form['email'] = strtolower(trim($value));
    }
};
Острови змінюють обсяг рендеру, а не порядок цих хуків. Оновлення острова так само проходить повний серверний життєвий цикл — просто повертає фрагмент, а не весь компонент.

9. Валідація та Form-об’єкти

Валідація перейшла з v3 без змін, включно з атрибутами і Form-об’єктами.

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();
    }
};

Умовні правила

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-об’єкти

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'));
    }
}
Однофайловий компонент, що його використовує
<?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>

Помилки в JavaScript

Четверта версія відкриває клієнту помилки валідації через магічну властивість $errors.

Blade
<div x-show="$errors.has('email')" x-text="$errors.first('email')"></div>

10. Острови (islands)

Острови — візитівка Livewire 4 і та можливість, що змінює уявлення про розмір компонента. Острів — це ділянка всередині компонента, яка перемальовується незалежно: при оновленні перераховується і патчиться лише цей фрагмент, а не весь компонент.

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

Навіщо це

У v3 стандартними ліками від «дашборд гальмує» було розбиття на шість дочірніх компонентів, щоб оновлення одного віджета не перезапускало запити решти п’яти. Острови дають ту саму ізоляцію без межі компонента: один компонент, один клас, кілька незалежно оновлюваних ділянок.

Дашборд із трьома островами
<?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>

Опції

ОпціяДія
nameІм’я острова, щоб на нього могли націлитися дії та JavaScript. Кілька островів з одним іменем завжди рендеряться групою.
lazyРендериться, коли острів потрапляє у в’юпорт (intersection observer).
deferРендериться одразу після завантаження сторінки, незалежно від видимості.
alwaysПримусово оновлює острів при кожному рендері батька.
skipПовністю пропускає первинний рендер.

Націлювання на острів із дії

Blade
@island(name: 'revenue')
    Revenue: {{ $this->revenue }}
@endisland

<button wire:click="$refresh" wire:island="revenue">Refresh revenue</button>

Append і prepend — нескінченна стрічка без обв’язки

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>

Те саме, але з Alpine або чистого JavaScript:

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

Опитування в межах острова

Blade
@island(name: 'queue')
    <div wire:poll.3s>
        Jobs pending: {{ $this->queue }}
    </div>
@endisland

Опитування оновлює лише острів. Порівняйте з v3, де wire:poll на дашборді кожні кілька секунд перезапускав усі запити сторінки.

Острів чи дочірній компонент. Острів — коли ділянка використовує стан батька і потребує лише ізольованого рендерингу. Дочірній компонент — коли ділянці потрібен власний стан, власний життєвий цикл або перевикористання в кількох місцях.

11. Слоти та передача атрибутів

Livewire-компоненти тепер приймають вміст слота так само, як це завжди вміли Blade-компоненти — з однією важливою особливістю: вміст слота обчислюється в контексті батька. wire:click, написаний усередині слота, викликає метод батька, а не дочірнього компонента.

Слот за замовчуванням

Батьківський шаблон
<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>

У цьому прикладі wire:submit="save" і wire:model="title" належать батьківському компоненту, а wire:click="toggle" — модальному вікну. Саме такий поділ і потрібен: модалка відповідає за відкриття й закриття, батько — за форму.

Іменовані слоти

Батьківський шаблон
<livewire:modal>
    <wire:slot name="header">
        <h2>Create a post</h2>
    </wire:slot>

    <form wire:submit="save">
        <input wire:model="title">
    </form>

    <wire:slot name="footer">
        <button wire:click="save">Save</button>
        <button wire:click="$parent.close()">Cancel</button>
    </wire:slot>
</livewire:modal>
Усередині компонента
<div class="modal">
    @if ($header = $slot('header'))
        <div class="modal__header">{{ $header }}</div>
    @endif

    <div class="modal__body">{{ $slot }}</div>

    @if ($footer = $slot('footer'))
        <div class="modal__footer">{{ $footer }}</div>
    @endif
</div>

Передача атрибутів

Як і Blade-компоненти, Livewire-компонент може отримувати й об’єднувати довільні HTML-атрибути через $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>
Коли ви рендерите повносторінковий компонент, іменовані слоти, призначені для layout, можна розміщувати поза кореневим елементом компонента.

12. Вкладені компоненти та wire:key

Вкладення працює так само, як у v3: кожен дочірній компонент — незалежна одиниця з власним станом і власним циклом оновлення.

Blade
<div>
    @foreach ($orders as $order)
        <livewire:order-row :order="$order" :key="'order-'.$order->id" />
    @endforeach
</div>

Розумні ключі

У четвертій версії smart_wire_keys увімкнено за замовчуванням, тож Livewire сам виводить ключі в багатьох ситуаціях із циклами, де раніше потрібен був явний. Це зменшує шаблонний код, але не робить ключі зайвими.

Продовжуйте писати ключі там, де важлива ідентичність. Будь-який список, який може бути перевпорядкований, відфільтрований або частково видалений, має нести явний wire:key, прив’язаний до id запису. Розумні ключі — зручність для простих випадків, а не заміна явній вказівці фреймворку, чим насправді є рядок.
Blade
@foreach ($rows as $row)
    <div wire:key="row-{{ $row->id }}">…</div>
@endforeach

Реактивні props

PHP
use Livewire\Attributes\Reactive;

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

Звернення до батька

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

Двобічна прив’язка на компоненті

PHP
use Livewire\Attributes\Modelable;

new class extends Component {
    #[Modelable]
    public int $value = 0;
};
Blade
<livewire:rating-input wire:model.live="review.rating" />
З появою островів дочірні компоненти потрібні рідше. Якщо єдиною причиною розбиття було «щоб перемальовувалося окремо», острів легший.

13. Події

API подій не змінився порівняно з третьою версією.

PHP
// Надсилання
$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>

Події в браузер

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>
Острови часто замінюють події. Класичний патерн v3 «компонент A надсилає, компонент B слухає і оновлюється» нерідко перетворюється на «обидві ділянки — острови одного компонента, і дія націлена на сусідній острів» — один запит замість двох.

14. Обчислювані властивості

#[Computed] працює так само, як у v3, і особливо добре поєднується з островами: острів, що читає обчислювану властивість, перераховує її лише тоді, коли оновлюється сам.

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

Кешування довше за один запит

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();
}

Скидання кешу

PHP
unset($this->products, $this->subtotal, $this->total);
У шаблоні обчислювана властивість — це $this->products, ніколи не $products.

15. Стан в URL і сесії

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';
};

Результат: /catalog?search=laptop&cat=5&sort=price-asc&tab=sale

Flash-повідомлення

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
Кожна #[Url]-властивість при зміні смикає History API. Для полів, що змінюються на кожне натискання клавіші, комбінуйте з wire:model.live.debounce.

16. Завантаження файлів

Завантаження працюють так само, як у третій версії, — через трейт WithFileUploads.

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>

Точний прогрес-бар

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 у PHP і client_max_body_size у nginx мають перевищувати ваше правило max:, інакше завантаження падає без зрозумілої помилки.

17. Пагінація

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),
        ];
    }
};

Острови перетворюють пагінацію на нескінченну стрічку

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>
На великих таблицях paginate() коштує додаткового COUNT(*). Якщо ви не показуєте загальну кількість сторінок — а при довантаженні зазвичай не показуєте, — simplePaginate() помітно дешевший.

18. Стани завантаження

Сімейство wire:loading не змінилося, а v4 додає атрибути data-loading для стилізації.

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>

{{-- Показувати індикатор, лише якщо запит триває довше за поріг --}}
<div wire:loading.delay>Loading…</div>
<div wire:loading.delay.long>Loading…</div>

Стилізація з CSS

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

Незбережені зміни та з’єднання

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. Оптимістичний UI на клієнті

Цілому класу взаємодій сервер ніколи не був потрібен: відкрити панель, показати лічильник символів, підсвітити поле червоним, коли текст задовгий. У четвертій версії для них є повноцінні директиви, які змінюють DOM миттєво, взагалі без запиту.

wire:show

Blade
{{-- Перемикає видимість через CSS, миттєво, без round trip --}}
<div wire:show="showModal" class="modal">…</div>

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

wire:text

Blade
{{-- Текст слідує за властивістю на клієнті --}}
<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>

Дії лише на клієнті через $js

Blade
<?php

new class extends Component {
    public function save() { /* … */ }
};

?>

<div>
    <button wire:click="save">Save</button>

    {{-- Виконується виключно на клієнті --}}
    <button x-on:click="$wire.$js.showToast = true">Show toast</button>
</div>
Оптимістично не означає достовірно. Ці директиви змінюють те, що бачить користувач, до того як сервер погодився. Усе, що впливає на дані, права або гроші, все одно має валідуватися і застосовуватися на сервері — стан на клієнті є попереднім переглядом, а не джерелом істини.
Добре правило: wire:show / wire:text — для відгуку, який користувач має відчути миттєво; звичайна дія — для всього, що має зберегтися.

20. Drag and drop

Раніше зміна порядку означала підключити SortableJS, прив’язати його колбеки до дії Livewire і тримати дві моделі синхронними. У четвертій версії це директива.

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]);
        }
    }
};

Ручки перетягування

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>

            {{-- Інтерактивні елементи не повинні запускати перетягування --}}
            <button wire:sort:ignore wire:click="delete({{ $task->id }})">Delete</button>
        </li>
    @endforeach
</ul>

Перетягування між списками

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>
Зберігайте порядок. Директива переставляє DOM оптимістично і віддає вам нову послідовність — записати її в базу ваша задача. Якщо обробник кине виняток, UI і дані розходитимуться до наступного повного рендеру.

21. Скоуплений CSS і скрипти

Оскільки компонент тепер — один файл, його стилі та скрипти теж живуть у цьому файлі. Livewire скоупить CSS компонентом, тож він не протікає на решту сторінки.

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>

Свідомо глобальні стилі

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

Скрипти компонента

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
{{-- Завантажити сторонню бібліотеку один раз на всю сторінку --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassets
Скоуплений CSS — зручність для локальної стилізації компонента, а не заміна дизайн-системі. Токени, типографіку та примітиви розмітки тримайте в глобальних стилях; у компоненті — лише кілька правил, які стосуються тільки його.

23. Інтеграція з JavaScript

Alpine.js і далі постачається разом із Livewire, а $wire лишається містком від 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
{{-- Двостороннє зв’язування з Alpine --}}
<div x-data="{ query: $wire.entangle('search') }">
    <input x-model="query">
</div>

wire:ref — імена для елементів і компонентів

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

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

#[Json] — дані прямо в 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)">

Перехоплювачі

Четверта версія замінює хуки commit і request з v3 на interceptMessage() та 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();   // CSRF-токен прострочився
            }
        });
    });

    Livewire.interceptMessage(({ component, succeed }) => {
        succeed(() => {
            console.debug('component updated', component.name);
        });
    });
});
JavaScript
// Програмний доступ
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());

Ігнорування піддерева

Blade
<div wire:ignore>
    <select id="select2-field">…</select>
</div>

<div wire:ignore.self>…</div>

24. Тестування

Pest — рекомендований спосіб тестувати компоненти Livewire 4. Компоненти на основі view можуть тримати свій тест поруч із компонентом, а не в окремому дереві.

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

Тестування компонента на основі view

Звертайтеся до компонента за іменем у dot-нотації, а не за класом:

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();
});

Компоненти на основі класу

PHP
use App\Livewire\Counter;

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

Основні перевірки

МетодПеревіряє
assertSet() / assertNotSet()Значення властивостей
assertSee() / assertDontSee()Відрендерений вивід
assertViewHas()Дані, передані у view
assertHasErrors() / assertHasNoErrors()Валідацію
assertDispatched()Події
assertRedirect()Редиректи
assertUnauthorized() / assertForbidden()Авторизацію
assertStatus()HTTP-статус
PHP
// Завантаження файлів
use Illuminate\Http\UploadedFile;

Livewire::test('avatar-uploader')
    ->set('avatar', UploadedFile::fake()->image('avatar.jpg', 400, 400))
    ->call('save')
    ->assertHasNoErrors();

// Компонент усередині сторінки
$this->get('/dashboard')
    ->assertSeeLivewire('pages::dashboard');
Тестуйте насамперед авторизацію в діях, правила валідації та переходи станів. Саме там ламається функціональність і ховаються діри в безпеці — острови та слоти цього не змінюють.

25. Безпека та продуктивність

Модель загроз ідентична v3. Будь-який публічний метод — відкритий HTTP-ендпоінт, будь-яка публічна властивість доступна клієнту на запис. Однофайлові компоненти легше швидко переглянути — а отже, легше й не помітити відсутню перевірку авторизації.

Чек-лист безпеки

  • $this->authorize() у кожній дії, що змінює дані.
  • Усі ідентифікатори позначені #[Locked].
  • Допоміжні методи оголошені protected або private, щоб клієнт не міг їх викликати.
  • Правила валідації не містять службових полів (user_id, role, price).
  • Ні токенів, ні ключів, ні зайвих персональних даних у публічних властивостях — snapshot їде у браузер відкритим текстом.
  • Завантаження файлів обмежене за MIME-типом і розміром.
  • Форми входу та зворотного зв’язку — за rate limiter.
  • {!! !!} лише для довіреної або очищеної розмітки.
  • Оптимістичний UI ніколи не вважається достовірним.
PHP
public function delete(int $postId): void
{
    $post = Post::findOrFail($postId);

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

    $post->delete();
}

Продуктивність

СимптомРішення у v4
Оновлення віджета перезапускає всі запити сторінкиОбгорнути ділянки в @island
Величезний HTML сторінкиПрибрати колекції моделей з публічних властивостей; отримувати їх у with() або #[Computed]
Запит на кожне натискання клавішіwire:model.live.debounce.400ms
Навантаження від опитуванняОпитування всередині острова плюс .visible
Round trip заради перемикання панеліwire:show замість дії
Інкремент лічильника перемальовує сторінку#[Renderless]
Запити N+1Жадібне завантаження через with(); Model::preventLazyLoading() у dev
Повільна перша відмальовка важкого блоку@island(lazy: true) з @placeholder
app/Providers/AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

26. Часті помилки та шпаргалка

Компонент взагалі не рендериться

Тег не самозакритий. У v4 <livewire:my-component> не виводить нічого — і мовчки. Пишіть <livewire:my-component />.

На проді все 404, локально працює

Ендпоінт Livewire переїхав з /livewire/ на /livewire-{hash}/. Правило фаєрвола, виняток WAF, правило CDN або location у nginx, досі прив’язані до старого буквального шляху, блокуватимуть запити оновлення.

Поле перестало синхронізуватися після апгрейду

wire:model.blur і .change тепер керують синхронізацією на клієнті, а не лише мережею. Додайте .live: wire:model.live.blur="title".

Обгортка перестала підхоплювати своє поле

Спливання подій за замовчуванням вимкнене. Використовуйте wire:model.deep.

Модифікатори wire:transition нічого не роблять

.opacity, .scale і .duration.200ms прибрано — v4 використовує нативний View Transitions API. Опишіть анімацію в CSS.

Острів не оновлюється

  • В острова немає name, а дія цілиться в ім’я.
  • wire:island="…" вказує на ім’я, якого немає у відрендереному виводі.
  • Ділянка залежить від стану батька, який фактично не змінювався — додайте always: true.

Вміст слота викликає не той метод

Так задумано: розмітка слота обчислюється в контексті батька. Щоб викликати дочірній компонент, використовуйте подію або wire:ref.

Компоненти Volt перестали працювати

Volt увійшов у ядро. Замініть Livewire\Volt\Component на Livewire\Component, Volt::route() на Route::livewire(), Volt::test() на Livewire::test(), видаліть сервіс-провайдер і пакет Volt.

Шпаргалка по директивах

ДирективаПризначенняНове у v4
wire:modelПрив’язка поля до властивостізмінилася семантика
wire:model.deepЛовити події від дочірніх елементівтак
wire:click / wire:submitВикликати дію—
wire:click.asyncВиконати дію паралельнотак
wire:click.renderlessПропустити перемальовуваннятак
wire:islandНацілити дію на острівтак
wire:island.appendДописати в острів замість замінитак
wire:showПеремкнути видимість на клієнтітак
wire:textПрив’язати текст на клієнтітак
wire:bindРеактивно прив’язати атрибуттак
wire:sortЗміна порядку перетягуваннямтак
wire:intersectДія при потраплянні у в’юпорттак
wire:refІм’я елемента чи компонента для JSтак
wire:navigateНавігація в стилі SPA—
wire:navigate:scrollЗберегти скрол контейнераперейменовано
wire:loading / wire:dirty / wire:offlineСтан запиту—
wire:pollПеріодичне оновленняу межах острова
wire:keyІдентичність елемента в циклірозумні ключі за замовчуванням
wire:ignoreВиключити піддерево з морфінгу—

Директиви Blade

ДирективаПризначення
@island … @endislandНезалежно рендерована ділянка
@placeholder … @endplaceholderЗаглушка для лінивого острова
<wire:slot name="…">Вміст іменованого слота
@script … @endscriptJavaScript у межах компонента
@assets … @endassetsАссети сторінки, що завантажуються один раз
@persist … @endpersistЗберегти елемент між переходами
<style> / <style global>Скоуплений / глобальний CSS компонента

PHP-атрибути

АтрибутПризначення
#[Validate]Правило валідації
#[Locked]Заборонити запис із фронтенду
#[Computed]Кешоване похідне значення
#[Url] / #[Session]Збереження стану
#[On]Слухач події
#[Prop]Оголосити prop, що передає батько
#[Reactive] / #[Modelable]Зв’язування батька й дочірнього компонента
#[Async]Виконати дію паралельно
#[Renderless]Пропустити перемальовування
#[Json]Повернути дані прямо в JavaScript
#[Lazy] / #[Layout] / #[Title]Завантаження та обрамлення сторінки

Команди Artisan

Bash
php artisan make:livewire post.create              # однофайловий
php artisan make:livewire post.create --mfc        # багатофайловий
php artisan make:livewire post.create --test       # з тестом
php artisan make:livewire pages::dashboard         # з namespace
php artisan livewire:publish --config
php artisan optimize:clear

Офіційні ресурси

Livewire 4 активно розвивається. Якщо ця документація й офіційна розходяться, має рацію офіційна — звіряйтеся з нею, перш ніж покладатися на деталь у продакшені.
Адаптовано за офіційною документацією

Якщо ця документація розходиться з офіційним джерелом, має рацію офіційне джерело. Знайшли помилку? Повідомте нам.