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.

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

Для full-page компонентов появился отдельный макрос. Старая форма 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('Панель управления')]
    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: события больше не всплывают от детей

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">Сохранить</button>

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

<button wire:click="delete({{ $post->id }})"
        wire:confirm="Удалить пост? Отменить будет нельзя.">Удалить</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">Учесть</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')">Начать</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);
    }

    // Для вложенного ключа 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: 'Нужно согласие на обработку данных.')]
    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">Сохранить</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>Выручка: {{ $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>Выручка за месяц</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">
            @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')
    Выручка: {{ $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>

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

Blade
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
    Показать ещё
</button>

Опрос в пределах острова

Blade
@island(name: 'queue')
    <div wire:poll.3s>
        Задач в очереди: {{ $this->queue }}
    </div>
@endisland

Опрос обновляет только остров. Сравните с v3, где wire:poll на дашборде каждые несколько секунд перезапускал все запросы страницы.

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

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>

В этом примере wire:submit="save" и wire:model="title" принадлежат родителю, а wire:click="toggle" — модальному окну. Такое разделение — именно то, что нужно: модалка владеет открытием и закрытием, родитель владеет формой.

Именованные слоты

Родительский шаблон
<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>
        <button wire:click="$parent.close()">Отмена</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">
    Счёт просрочен на 14 дней.
</livewire:alert>
При рендере full-page компонента именованные слоты, предназначенные для 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()">Обновить список</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' })">Новый заказ</button>

События в браузер

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. Вычисляемые свойства

#[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>Сумма: {{ number_format($this->subtotal, 2, ',', ' ') }} €</p>
    <p>Итого с НДС: {{ 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=ноутбук&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', 'Настройки сохранены');
}
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: 'Аватар обновлён');
    }
};

?>

<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-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">Показать ещё</button>
</div>
На больших таблицах paginate() стоит дополнительного COUNT(*). Если общее число страниц не показывается — а при загрузке «ещё» обычно так и есть — simplePaginate() заметно дешевле.

18. Состояния загрузки

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

Blade
<div wire:loading>Загрузка…</div>
<div wire:loading.remove>Содержимое</div>

<button wire:click="save">Сохранить</button>
<span wire:loading wire:target="save">Сохраняем…</span>

<span wire:loading wire:target="save,delete,publish">Обрабатываем…</span>
<div wire:loading wire:target.except="search">Обновляем…</div>

<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 мгновенно, вообще без запроса.

wire:show

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

<button wire:click="$toggle('showModal')">Открыть</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'">
    Становится длинно
</span>

Клиентские действия через $js

Blade
<?php

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

?>

<div>
    <button wire:click="save">Сохранить</button>

    {{-- Выполняется исключительно на клиенте --}}
    <button x-on:click="$wire.$js.showToast = true">Показать уведомление</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 }})">Удалить</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 и скрипты

Раз компонент теперь один файл, его стили и скрипты тоже принадлежат этому файлу. 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 }} € / месяц</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
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">Детали</button>

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

        <button x-on:click="$wire.title = 'Новый заголовок'">Переименовать</button>
        <button x-on:click="$wire.save()">Сохранить</button>
        <button x-on:click="await $wire.calculate(); open = false">Рассчитать</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()">Открыть модалку</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.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-based компонентов могут лежать рядом с компонентом, а не в отдельном дереве.

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

Тест view-based компонента

Компонент указывается по имени через точку, а не классом:

PHP
use Livewire\Livewire;

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

it('валидирует обязательные поля', function () {
    Livewire::test('post.create')
        ->set('title', '')
        ->set('body', 'слишком коротко')
        ->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');

    expect(Post::where('title', 'Вполне разумный заголовок')->exists())->toBeTrue();
});

Классовые компоненты

PHP
use App\Livewire\Counter;

it('увеличивает счётчик', function () {
    Livewire::test(Counter::class)
        ->assertSet('count', 0)
        ->call('increment')
        ->assertSet('count', 1);
});

Основные утверждения

МетодПроверяет
assertSet() / assertNotSet()Значения свойств
assertSee() / assertDontSee()Вывод
assertViewHas()Данные, переданные в шаблон
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() в разработке
Медленная первая отрисовка тяжёлого блока@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:navigateSPA-навигация
wire:navigate:scrollСохранить скролл контейнерапереименовано
wire:loading / wire:dirty / wire:offlineСостояние запроса
wire:pollПериодическое обновлениев пределах острова
wire:keyИдентичность элемента в циклеумные ключи по умолчанию
wire:ignoreИсключить поддерево из morph

Директивы 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]Объявить свойство как props от родителя
#[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 активно развивается. Если это руководство и официальная документация расходятся, прав официальный источник — сверяйтесь с ним, прежде чем полагаться на деталь в продакшене.