Livewire 4 — повний посібник

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

Livewire 4.x Laravel 11 / 12 PHP 8.2+ 26 розділів

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Збірка, сумісна із суворою CSP.

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

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: 'Допис створено');
    }
};

?>

<div>
    <form wire:submit="save">
        <input type="text" wire:model="title" placeholder="Заголовок">
        @error('title') <span class="error">{{ $message }}</span> @enderror

        <textarea wire:model="body" placeholder="Текст"></textarea>
        @error('body') <span class="error">{{ $message }}</span> @enderror

        <button type="submit">Зберегти</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                 (звичайний Blade-компонент)
└── pages/
    └── ⚡dashboard.blade.php            → <livewire:pages::dashboard />

Виведення

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

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

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

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

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

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('Панель керування')]
    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">
    Рахунок прострочено.
</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 поводяться інакше, і обидва ламаються мовчки, без помилки.

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

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

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

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

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

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

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

Blade
<button wire:click="save">Зберегти</button>

<form wire:submit="save">…</form>

<button wire:click="delete({{ $post->id }})"
        wire:confirm="Видалити допис? Скасувати буде неможливо.">Видалити</button>

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

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">Врахувати</button>

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

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

Магічні дії

Blade
<button wire:click="$refresh">Оновити</button>
<button wire:click="$set('tab', 'settings')">Налаштування</button>
<button wire:click="$toggle('showFilters')">Фільтри</button>
<button wire:click="$dispatch('open-modal', { name: 'create' })">Створити</button>
<button wire:click="$parent.closeModal()">Закрити</button>
Безпека не зрушила. Авторизуйте кожен параметр на сервері, а допоміжні методи позначайте protected або private, щоб їх не можна було викликати з клієнта.

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

Життєвий цикл не змінився порівняно з третьою версією.

Перший рендер
1. Створюється екземпляр компонента
2. boot()
3. mount($params)
4. booted()
5. render()
Подальше оновлення
 1. Надходить POST /livewire-{hash}/update зі snapshot стану
 2. Перевіряється контрольна сума snapshot
 3. Створюється екземпляр компонента
 4. boot()
 5. Стан відновлюється зі snapshot
 6. hydrate() / hydrateFoo()
 7. booted()
 8. updating($prop, $value) / updatingFoo($value)
 9. Властивості отримують нові значення
10. updated($prop, $value) / updatedFoo($value)
11. Виконуються дії з черги
12. rendering() → render() → rendered($view, $html)
13. dehydrate() — стан пакується назад у snapshot
14. Відповідь: розмітка + 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);
    }

    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: 'Потрібна згода на обробку даних.')]
    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 store(): Post
    {
        $this->validate();

        return Post::create($this->except('post'));
    }

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

        $this->post->update($this->except('post'));
    }
}

Помилки в JavaScript

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

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

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

Blade
@island
    <div>Виручка: {{ $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();
    }
};

?>

<div>
    @island(name: 'revenue')
        <div class="card">
            <h3>Виручка за місяць</h3>
            <p class="stat">{{ number_format($this->revenue, 2, ',', ' ') }} €</p>
        </div>
    @endisland

    @island(name: 'queue', poll: '5s')
        <div class="card">
            <h3>Завдань у черзі</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">…</div>
    @endisland
</div>

Опції

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

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

Blade
@island(name: 'revenue')
    Виручка: {{ $this->revenue }}
@endisland

<button wire:click="$refresh" wire:island="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">Показати ще</button>
Blade
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
    Показати ще
</button>

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

Blade
@island(name: 'queue')
    <div wire:poll.3s>
        Завдань у черзі: {{ $this->queue }}
    </div>
@endisland
Острів чи дочірній компонент. Острів — коли ділянка використовує стан батька і потребує лише ізольованого рендерингу. Дочірній компонент — коли ділянці потрібен власний стан, власний життєвий цикл або перевикористання в кількох місцях.

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

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

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

Батьківський шаблон
<livewire:modal>
    <h2>Створити допис</h2>

    <form wire:submit="save">
        <input wire:model="title">
        <button type="submit">Зберегти</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>

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

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

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

    <wire:slot name="footer">
        <button wire:click="save">Зберегти</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>

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

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">
    Рахунок прострочено на 14 днів.
</livewire:alert>

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

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()">Оновити список</button>

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

PHP
use Livewire\Attributes\Modelable;

new class extends Component {
    #[Modelable]
    public int $value = 0;
};
З появою островів дочірні компоненти потрібні рідше. Якщо єдиною причиною розбиття було «щоб перемальовувалося окремо», острів легший.

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
    {
        // …
    }
};
PHP
$this->dispatch('notify', type: 'success', message: 'Замовлення збережено');
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. Обчислювані властивості

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]
        );
    }
};
Blade
@island(name: 'totals')
    <p>Сума: {{ number_format($this->subtotal, 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'),
    ];
}
У шаблоні обчислювана властивість — це $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;

    #[Session]
    public bool $sidebarCollapsed = false;
};
PHP
public function save(): void
{
    $this->validate();

    session()->flash('status', 'Налаштування збережено');
}
Кожна #[Url]-властивість при зміні смикає History API. Для полів, що змінюються на кожне натискання клавіші, комбінуйте з wire:model.live.debounce.

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

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

?>

<div>
    <form wire:submit="save">
        <input type="file" wire:model="avatar" accept="image/*">

        <div wire:loading wire:target="avatar">Завантажуємо…</div>

        @if ($avatar)
            <img src="{{ $avatar->temporaryUrl() }}" alt="Прев’ю" class="preview">
        @endif

        @error('avatar') <p class="error">{{ $message }}</p> @enderror

        <button type="submit" wire:loading.attr="disabled">Зберегти</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-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>
upload_max_filesize, post_max_size у PHP і client_max_body_size у nginx мають перевищувати ваше правило max:.

17. Пагінація

PHP
use Livewire\WithPagination;

new class extends Component {
    use WithPagination;

    public string $search = '';

    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function with(): array
    {
        return [
            'orders' => Order::query()
                ->with('customer')
                ->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
                ->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">Показати ще</button>
</div>
На великих таблицях paginate() коштує додаткового COUNT(*). simplePaginate() помітно дешевший.

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

Blade
<div wire:loading>Завантаження…</div>
<div wire:loading.remove>Вміст</div>

<button wire:click="save">Зберегти</button>
<span wire:loading wire:target="save">Зберігаємо…</span>

<button wire:click="save" wire:loading.attr="disabled">Зберегти</button>
<button wire:click="save" wire:loading.class="opacity-50 cursor-wait">Зберегти</button>

<div wire:loading.delay>Завантаження…</div>
<div wire:loading.delay.long>Завантаження…</div>

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

CSS
[data-loading] .btn {
    opacity: 0.5;
    pointer-events: none;
}
Blade
<input wire:model="title">

<span wire:dirty wire:target="title">Є незбережені зміни</span>

<div wire:offline class="banner banner--warn">
    Немає з’єднання із сервером.
</div>

19. Оптимістичний UI на клієнті

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

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

<button wire:click="$toggle('showModal')">Відкрити</button>
Blade
{{-- Текст слідує за властивістю на клієнті --}}
<span wire:text="title"></span>

<input wire:model="title">
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'">
    Стає задовго
</span>
Оптимістично не означає достовірно. Ці директиви змінюють те, що бачить користувач, до того як сервер погодився. Усе, що впливає на дані, права або гроші, все одно має валідуватися і застосовуватися на сервері.

20. Drag and drop

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 }})">Видалити</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 оптимістично і віддає вам нову послідовність — записати її в базу ваша задача.

21. Скоуплений 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 }} € / місяць</p>
</div>

<style>
    /* Скоупиться цим компонентом — жодних глобальних колізій по .title */
    .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
Скоуплений CSS — зручність для локальної стилізації компонента, а не заміна дизайн-системі.

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

Blade
<div x-data="{ open: false }">
    <button x-on:click="open = !open">Деталі</button>

    <div x-show="open">
        <p x-text="$wire.title"></p>

        <button x-on:click="$wire.save()">Зберегти</button>
        <button x-on:click="await $wire.calculate(); open = false">Розрахувати</button>
    </div>
</div>

wire:ref

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

<button x-on:click="$refs.modal.open()">Відкрити модалку</button>

#[Json]

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

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

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

    Livewire.interceptMessage(({ component, succeed }) => {
        succeed(() => {
            console.debug('компонент оновлено', component.name);
        });
    });
});
Blade
<div wire:ignore>
    <select id="select2-field">…</select>
</div>

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

Pest — рекомендований спосіб тестувати компоненти Livewire 4.

Bash
php artisan make:livewire post.create --test
# resources/views/components/post/create.test.php
PHP
use Livewire\Livewire;

it('рендериться', function () {
    Livewire::test('post.create')
        ->assertStatus(200);
});

it('валідує обов’язкові поля', function () {
    Livewire::test('post.create')
        ->set('title', '')
        ->call('save')
        ->assertHasErrors(['title' => 'required']);
});

it('зберігає коректний допис', function () {
    Livewire::actingAs($user)
        ->test('post.create')
        ->set('title', 'Цілком розумний заголовок')
        ->set('body', str_repeat('Достатньо довгий текст допису. ', 3))
        ->call('save')
        ->assertHasNoErrors()
        ->assertDispatched('notify');
});
МетодПеревіряє
assertSet() / assertNotSet()Значення властивостей
assertSee() / assertDontSee()Вивід
assertHasErrors() / assertHasNoErrors()Валідацію
assertDispatched()Події
assertRedirect()Редиректи
assertForbidden()Авторизацію
Тестуйте насамперед авторизацію в діях, правила валідації та переходи станів.

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

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

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

  • $this->authorize() у кожній дії, що змінює дані.
  • Усі ідентифікатори позначені #[Locked].
  • Допоміжні методи оголошені protected або private.
  • Правила валідації не містять службових полів (user_id, role, price).
  • Ні токенів, ні ключів у публічних властивостях — snapshot їде у браузер відкритим текстом.
  • Завантаження файлів обмежене за MIME-типом і розміром.
  • Форми входу за rate limiter.
  • Оптимістичний UI ніколи не вважається достовірним.

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

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

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

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

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

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

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

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

Додайте .live: wire:model.live.blur="title".

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

Використовуйте wire:model.deep.

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

Їх прибрано — v4 використовує нативний View Transitions API. Опишіть анімацію в CSS.

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

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

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

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

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

ДирективаПризначенняНове у v4
wire:modelПрив’язка поля до властивостізмінилася семантика
wire:model.deepЛовити події від дочірніх елементівтак
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:scrollЗберегти скрол контейнераперейменовано
wire:pollПеріодичне оновленняу межах острова
wire:keyІдентичність елемента в циклірозумні ключі за замовчуванням

Директиви Blade

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

PHP-атрибути

АтрибутПризначення
#[Validate] / #[Locked]Валідація і захист від запису
#[Computed]Кешоване похідне значення
#[Url] / #[Session]Збереження стану
#[On] / #[Prop]Події та props
#[Async] / #[Renderless]Паралельність і пропуск рендеру
#[Json]Дані прямо в JavaScript
Bash
php artisan make:livewire post.create              # однофайловий
php artisan make:livewire post.create --mfc        # багатофайловий
php artisan make:livewire post.create --test       # з тестом
php artisan livewire:publish --config
php artisan optimize:clear

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

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