Livewire 3 — полное руководство

Livewire позволяет строить динамические интерфейсы в Laravel на PHP и Blade — без отдельного фронтенд-приложения и без API-слоя. Это руководство проводит от установки до продакшена: компоненты, состояние, формы, события, загрузка файлов, тестирование, безопасность и производительность.

Livewire 3.x Laravel 10 / 11 / 12 PHP 8.1+ 24 главы

1. Что такое Livewire

Livewire — это полнофункциональный фреймворк для Laravel, который позволяет строить динамические интерфейсы на PHP и Blade, не выходя из бэкенда. Вы не пишете отдельное SPA, не поднимаете REST/GraphQL-слой и не держите вторую модель данных в JavaScript.

Как это работает

Компонент Livewire — это PHP-класс и связанный с ним Blade-шаблон. При первой загрузке страницы компонент рендерится на сервере как обычный HTML. Дальше маленький JS-рантайм Livewire перехватывает взаимодействия пользователя (клик, ввод, submit), отправляет AJAX-запрос на сервер с текущим состоянием компонента, сервер заново рендерит компонент и возвращает новый HTML. Рантайм сравнивает старый и новый DOM и точечно патчит различия — страница не перезагружается.

Схема одного цикла
Браузер                          Сервер
   │                                │
   │  клик по wire:click="save"     │
   ├───────── POST /livewire/update ┤
   │  { snapshot, calls, updates }  │
   │                                ├── восстановить компонент из snapshot
   │                                ├── применить обновления свойств
   │                                ├── вызвать метод save()
   │                                ├── выполнить render()
   │  { snapshot, html, effects }   │
   ├◄───────────────────────────────┤
   ├── morph DOM (только различия)  │
   ▼                                ▼

Когда Livewire — правильный выбор

  • Админки, CRM, дашборды, внутренние панели — много форм и таблиц, мало сложной анимации.
  • Формы с зависимыми полями, многошаговые визарды, живая валидация.
  • Таблицы с поиском, фильтрами, сортировкой и пагинацией.
  • Команда сильна в PHP и не хочет содержать отдельный фронтенд-стек.
  • Нужен быстрый вывод функциональности без дублирования логики между PHP и JS.

Когда лучше взять что-то другое

  • Интерфейсы с высокой частотой обновлений: онлайн-редакторы, канвасы, drag-and-drop-конструкторы, игры.
  • Offline-first приложения и PWA со сложной локальной синхронизацией.
  • Мобильные приложения — там нужен API, а не HTML-over-the-wire.
  • Плохой или высоколатентный канал: каждый цикл — это сетевой round-trip.
Правило большого пальца: если взаимодействию нужны данные с сервера — это Livewire. Если взаимодействие чисто визуальное (открыть меню, переключить таб, показать тултип) — это Alpine.js на клиенте, без обращения к серверу.

Версии

Это руководство описывает Livewire 3.x. Требования: PHP 8.1+, Laravel 10 и выше. В третьей версии Alpine.js входит в комплект, изменились имена событий (dispatch вместо emit), появились PHP-атрибуты (#[Computed], #[Validate], #[Url]), а wire:model по умолчанию стал отложенным.

2. Установка и настройка

Установка в существующий Laravel-проект занимает одну команду.

Bash
composer require livewire/livewire

В Livewire 3 отдельно подключать ассеты не обязательно: пакет сам вставляет @livewireStyles и @livewireScripts в layout. Но если вы используете нестандартный layout или CSP, директивы можно проставить вручную.

resources/views/layouts/app.blade.php
<!DOCTYPE html>
<html lang="ru">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{ $title ?? 'Приложение' }}</title>

    <link rel="stylesheet" href="{{ asset('css/app.css') }}">
    @livewireStyles
</head>
<body class="antialiased">

    {{ $slot }}

    @livewireScripts
</body>
</html>
Частая ошибка. Если компонент рендерится, но не реагирует на клики — почти всегда в layout отсутствует @livewireScripts либо он стоит до подключения вашего собственного Alpine.js. Livewire 3 уже содержит Alpine — второй экземпляр Alpine ломает реактивность.

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

Bash
php artisan livewire:publish --config

Основные параметры config/livewire.php:

ПараметрНазначение
class_namespaceNamespace классов компонентов. По умолчанию App\Livewire.
view_pathКаталог Blade-шаблонов компонентов.
layoutLayout для full-page компонентов.
temporary_file_uploadДиск, срок жизни и правила для временных загрузок.
inject_assetsАвтоматическая вставка CSS/JS. Отключите, если ставите директивы вручную.
navigate.show_progress_barПолоса прогресса для wire:navigate.

Проверка установки

Bash
php artisan livewire:make Counter
# CLASS: app/Livewire/Counter.php
# VIEW:  resources/views/livewire/counter.blade.php

3. Первый компонент

Компонент состоит из двух файлов: класса и шаблона.

app/Livewire/Counter.php
<?php

namespace App\Livewire;

use Livewire\Component;

class Counter extends Component
{
    public int $count = 0;

    public function increment(): void
    {
        $this->count++;
    }

    public function decrement(): void
    {
        $this->count--;
    }

    public function reset(): void
    {
        $this->count = 0;
    }

    public function render()
    {
        return view('livewire.counter');
    }
}
resources/views/livewire/counter.blade.php
<div class="flex items-center gap-3">
    <button type="button" wire:click="decrement" class="btn">−</button>

    <span class="text-2xl font-bold">{{ $count }}</span>

    <button type="button" wire:click="increment" class="btn">+</button>

    <button type="button" wire:click="reset" class="btn btn-ghost">Сброс</button>
</div>
Один корневой элемент. Шаблон компонента обязан иметь ровно один корневой HTML-элемент. Комментарии и текст на верхнем уровне тоже ломают morph-алгоритм. Если нужно несколько блоков — оберните их в <div>.

Три способа вывести компонент

Blade
{{-- 1. Тег-синтаксис (рекомендуется) --}}
<livewire:counter />

{{-- 2. Директива --}}
@livewire('counter')

{{-- 3. С параметрами --}}
<livewire:counter :start="10" title="Счётчик заказов" />

Full-page компоненты

Компонент можно повесить прямо на маршрут — тогда он рендерится как самостоятельная страница.

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

Route::get('/dashboard', Dashboard::class)->middleware('auth')->name('dashboard');
app/Livewire/Dashboard.php
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;

class Dashboard extends Component
{
    #[Layout('layouts.app')]
    #[Title('Панель управления')]
    public function render()
    {
        return view('livewire.dashboard');
    }
}

Inline-компоненты

Для очень маленьких компонентов шаблон можно вернуть строкой — отдельный Blade-файл не нужен.

PHP
public function render()
{
    return <<<'BLADE'
        <div>
            <button wire:click="$refresh">Обновить</button>
        </div>
    BLADE;
}

4. Свойства компонента

Публичные свойства класса автоматически доступны в Blade-шаблоне и сохраняются между запросами. Именно они образуют состояние компонента.

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

    public function render()
    {
        return view('livewire.user-profile');
    }
}

Какие типы можно хранить

Между запросами состояние сериализуется в JSON, поэтому набор допустимых типов ограничен.

МожноНельзя
string, int, float, bool, null Замыкания (Closure)
array из скалярных значений Ресурсы (resource), потоки
Eloquent-модели и Collection моделей Произвольные объекты без поддержки Wireable/Synth
Carbon, DateTime, Stringable, enum Объекты со ссылками на подключения, PDO и т. п.
Eloquent-модели в свойствах. Модель хранится как идентификатор и заново загружается из базы на каждом запросе. Это удобно, но каждый цикл добавляет SQL-запрос. Если нужно только несколько полей — храните скаляры, а не модель целиком.

Инициализация: mount()

Метод mount() — конструктор компонента. Он выполняется один раз, при первом рендере, и получает параметры, переданные из Blade.

PHP
use App\Models\Order;

class OrderCard extends Component
{
    public Order $order;
    public string $mode;

    public function mount(Order $order, string $mode = 'compact'): void
    {
        $this->order = $order;
        $this->mode  = $mode;
    }
}
Blade
<livewire:order-card :order="$order" mode="full" :key="$order->id" />

Защита свойств: #[Locked]

По умолчанию клиент может подменить значение любого публичного свойства. Для идентификаторов и всего, что влияет на права доступа, это дыра. Атрибут #[Locked] запрещает изменение свойства с фронтенда.

PHP
use Livewire\Attributes\Locked;

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

    public string $comment = '';
}

Скрытые от JSON свойства

Свойства с модификаторами protected и private не сериализуются и сбрасываются между запросами. Используйте их только для значений, вычисляемых заново в каждом цикле.

PHP
class Report extends Component
{
    public string $period = 'month';

    // Сбросится после каждого запроса — не храните тут состояние.
    protected array $cache = [];
}

5. wire:model и привязка данных

wire:model связывает поле формы со свойством компонента. В Livewire 3 привязка по умолчанию отложенная: значение уезжает на сервер не на каждое нажатие клавиши, а вместе со следующим действием (клик, submit).

Blade
{{-- Отложенно: значение уйдёт со следующим запросом --}}
<input type="text" wire:model="name">

{{-- Немедленно: запрос на каждое изменение --}}
<input type="text" wire:model.live="search">

{{-- С задержкой 500 мс после окончания ввода --}}
<input type="text" wire:model.live.debounce.500ms="search">

{{-- Не чаще раза в 2 с, пока пользователь печатает --}}
<input type="text" wire:model.live.throttle.2s="search">

{{-- Только при потере фокуса --}}
<input type="text" wire:model.blur="email">

{{-- Свойство меняется мгновенно на клиенте, сервер узнаёт позже --}}
<input type="text" wire:model.lazy="draft">

Модификаторы

МодификаторПоведениеКогда применять
Отложенная отправкаОбычные поля формы
.liveЗапрос на каждое изменениеЖивой поиск, зависимые селекты
.blurЗапрос при потере фокусаВалидация поля по выходу
.debounce.XmsЖдать паузу в вводеПоиск по мере набора
.throttle.XsНе чаще одного раза за интервалТяжёлые запросы
.numberПриводить к числуЧисловые поля
.booleanПриводить к булевуСелекты «да/нет»
.fillВзять начальное значение из HTMLПредзаполненные формы

Все типы полей

Blade
{{-- Текст, textarea --}}
<input type="text" wire:model="title">
<textarea wire:model="body"></textarea>

{{-- Один чекбокс: bool --}}
<input type="checkbox" wire:model="agreed">

{{-- Группа чекбоксов: array --}}
<input type="checkbox" value="php"  wire:model="skills">
<input type="checkbox" value="js"   wire:model="skills">
<input type="checkbox" value="sql"  wire:model="skills">

{{-- Радиокнопки --}}
<input type="radio" value="card" wire:model="payment">
<input type="radio" value="cash" wire:model="payment">

{{-- Select --}}
<select wire:model.live="categoryId">
    <option value="">Все категории</option>
    @foreach ($categories as $category)
        <option value="{{ $category->id }}">{{ $category->name }}</option>
    @endforeach
</select>

{{-- Множественный select --}}
<select wire:model="tagIds" multiple>
    @foreach ($tags as $tag)
        <option value="{{ $tag->id }}">{{ $tag->name }}</option>
    @endforeach
</select>

Вложенные данные

Точечная нотация работает и для массивов, и для свойств моделей.

PHP
public array $form = [
    'name'    => '',
    'address' => ['city' => '', 'street' => ''],
];

public Post $post;
Blade
<input wire:model="form.name">
<input wire:model="form.address.city">
<input wire:model="post.title">
Привязка к модели. Чтобы wire:model="post.title" работало, поле должно быть разрешено правилом валидации (rules или #[Validate]) — иначе Livewire выбросит исключение. Это защита от массового присвоения.

6. Действия (actions)

Действие — публичный метод компонента, вызываемый из шаблона. Это замена привычному fetch() + обработчик на сервере.

Blade
{{-- Клик --}}
<button wire:click="save">Сохранить</button>

{{-- Отправка формы (submit перехватывается) --}}
<form wire:submit="save">
    <input wire:model="title">
    <button type="submit">Отправить</button>
</form>

{{-- Клавиши --}}
<input wire:keydown.enter="search" wire:keydown.escape="clear">

{{-- Другие события DOM --}}
<div wire:mouseenter="preload">…</div>
<select wire:change="applyFilter">…</select>

Параметры

Blade
<button wire:click="delete({{ $post->id }})">Удалить</button>
<button wire:click="setStatus('published')">Опубликовать</button>
<button wire:click="move({{ $item->id }}, 'up')">Вверх</button>
PHP
public function delete(int $postId): void
{
    $post = Post::findOrFail($postId);

    $this->authorize('delete', $post);   // авторизацию проверяем всегда

    $post->delete();

    $this->dispatch('notify', message: 'Пост удалён');
}
Никогда не доверяйте параметрам. Любой метод компонента доступен как публичный HTTP-эндпоинт. Клиент может вызвать delete(999) с произвольным id. Проверка прав внутри метода обязательна.

Model binding в параметрах

Livewire умеет разрешать модели по id, как это делает роутер Laravel.

PHP
public function archive(Post $post): void
{
    $this->authorize('update', $post);

    $post->update(['archived_at' => now()]);
}

Модификаторы действий

Blade
{{-- Подтверждение в браузере до отправки --}}
<button wire:click="delete" wire:confirm="Точно удалить? Отменить будет нельзя.">Удалить</button>

{{-- preventDefault / stopPropagation --}}
<a href="#" wire:click.prevent="open">Открыть</a>
<div wire:click.stop="select">…</div>

{{-- Сработает один раз --}}
<button wire:click.once="init">Инициализировать</button>

{{-- Только если кликнули именно по этому элементу --}}
<div wire:click.self="close">…</div>

Магические действия

ДействиеЧто делает
$refreshПеререндерить компонент без изменения состояния
$set('prop', value)Присвоить свойству значение
$toggle('prop')Инвертировать булево свойство
$dispatch('event')Отправить событие Livewire
$parent.method()Вызвать метод родительского компонента
Blade
<button wire:click="$refresh">Обновить</button>
<button wire:click="$set('tab', 'settings')">Настройки</button>
<button wire:click="$toggle('showFilters')">Фильтры</button>
<button wire:click="$parent.closeModal()">Закрыть</button>

Редиректы

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

    $post = Post::create($this->only('title', 'body'));

    session()->flash('status', 'Пост создан');

    $this->redirect(route('posts.show', $post), navigate: true);
}

7. Жизненный цикл запроса

Понимание порядка выполнения снимает 90% «мистических» багов. Различают первичный рендер (обычный HTTP-запрос страницы) и последующие обновления (AJAX-запросы Livewire).

Первичный рендер

Порядок
1. Создаётся экземпляр компонента
2. boot()
3. mount($params)
4. booted()
5. hydrate-хуки НЕ вызываются
6. render()
7. HTML вставляется в страницу

Последующее обновление

Порядок
 1. Приходит POST /livewire/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. Вызываются методы из очереди calls (действия)
12. rendering()
13. render()
14. rendered($view, $html)
15. dehydrate() — состояние упаковывается обратно в snapshot
16. Ответ: новый HTML + effects (события, редиректы, dispatch в браузер)

Snapshot и контрольная сумма

Всё состояние компонента ездит на клиент и обратно в атрибуте wire:snapshot. Чтобы клиент не подменил данные, Livewire подписывает snapshot HMAC-подписью на основе APP_KEY. При расхождении подписи запрос отклоняется с ошибкой «Livewire encountered corrupt data».

Практическое следствие. Чем больше состояния в публичных свойствах, тем больше данных ездит по сети в каждом цикле. Коллекция из 500 моделей в публичном свойстве — это мегабайты трафика на каждый клик. Держите в свойствах минимум, а списки получайте в render() или через #[Computed].

Что происходит с DOM

Livewire не заменяет узел целиком, а выполняет morph: обходит старое и новое дерево и меняет только различия. Поэтому фокус в поле ввода, позиция скролла и состояние Alpine сохраняются. Если структура списка меняется, morph-алгоритму нужны подсказки — см. wire:key в главе 10.

8. Хуки жизненного цикла

Хуки позволяют вклиниться в любую фазу цикла.

PHP
class ProductEditor extends Component
{
    public Product $product;
    public string $name = '';
    public float $price = 0;

    // Выполняется в начале КАЖДОГО запроса, до восстановления состояния.
    public function boot(): void
    {
        // Хорошее место для зависимостей, которые нельзя сериализовать.
    }

    // Только при первом рендере.
    public function mount(Product $product): void
    {
        $this->product = $product;
        $this->name    = $product->name;
        $this->price   = $product->price;
    }

    // После восстановления состояния, в каждом последующем запросе.
    public function hydrate(): void
    {
    }

    // После boot() и восстановления состояния.
    public function booted(): void
    {
    }

    // Перед изменением любого свойства.
    public function updating(string $property, mixed $value): void
    {
    }

    // После изменения любого свойства.
    public function updated(string $property, mixed $value): void
    {
        $this->validateOnly($property);
    }

    // Только для свойства $price.
    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));
    }

    public function rendering(): void
    {
    }

    public function rendered(mixed $view, string $html): void
    {
    }

    // Перед упаковкой состояния в snapshot.
    public function dehydrate(): void
    {
    }

    public function render()
    {
        return view('livewire.product-editor');
    }
}

Правила именования

СвойствоХук
$priceupdatedPrice()
$isActiveupdatedIsActive()
$form['email']updatedFormEmail()
$post->titleupdatedPostTitle()
Типовой приём. Сброс пагинации при изменении фильтра делается именно в хуке: public function updatedSearch() { $this->resetPage(); }

9. Валидация и Form-объекты

Livewire использует валидатор Laravel — правила, сообщения и локализация те же.

Способ 1: атрибуты (Livewire 3)

PHP
use Livewire\Attributes\Validate;

class ContactForm extends Component
{
    #[Validate('required|string|min:2|max:80')]
    public string $name = '';

    #[Validate('required|email:rfc,dns')]
    public string $email = '';

    #[Validate('required|string|min:20|max:2000')]
    public string $message = '';

    #[Validate('accepted', message: 'Нужно согласие на обработку данных.')]
    public bool $consent = false;

    public function submit(): void
    {
        $data = $this->validate();

        Contact::create($data);

        $this->reset();

        session()->flash('status', 'Сообщение отправлено');
    }
}

Способ 2: метод rules()

Нужен, когда правила зависят от состояния.

PHP
protected function rules(): array
{
    return [
        'email' => [
            'required',
            'email',
            Rule::unique('users', 'email')->ignore($this->userId),
        ],
        'password' => $this->userId ? 'nullable|min:8|confirmed' : 'required|min:8|confirmed',
    ];
}

protected function messages(): array
{
    return [
        'email.unique' => 'Такой e-mail уже зарегистрирован.',
    ];
}

protected function validationAttributes(): array
{
    return [
        'email' => 'адрес электронной почты',
    ];
}

Живая валидация

PHP
public function updated(string $property): void
{
    $this->validateOnly($property);   // проверяем только изменённое поле
}
Blade
<form wire:submit="submit" novalidate>
    <label for="email">E-mail</label>
    <input id="email" type="email" wire:model.blur="email"
           class="@error('email') border-red-500 @enderror">

    @error('email')
        <p class="text-sm text-red-600">{{ $message }}</p>
    @enderror

    <button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove wire:target="submit">Отправить</span>
        <span wire:loading wire:target="submit">Отправляем…</span>
    </button>
</form>

Ручное управление ошибками

PHP
$this->addError('email', 'Домен в чёрном списке.');
$this->resetValidation('email');
$this->resetValidation();               // сбросить все
$this->validateOnly('email');

// Проброс ошибки как в обычном Laravel-контроллере
throw ValidationException::withMessages([
    'code' => 'Неверный код подтверждения.',
]);

Form-объекты

Когда форма разрастается, выносите её в отдельный класс. Компонент остаётся тонким, а правила и данные формы можно переиспользовать между «создать» и «редактировать».

Bash
php artisan livewire:form PostForm
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 = '';

    #[Validate('nullable|date|after_or_equal:today')]
    public ?string $publishAt = null;

    public function setPost(Post $post): void
    {
        $this->post      = $post;
        $this->title     = $post->title;
        $this->body      = $post->body;
        $this->publishAt = $post->publish_at?->toDateString();
    }

    public function store(): Post
    {
        $this->validate();

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

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

        $this->post->update($this->except('post'));
    }
}
app/Livewire/PostEditor.php
class PostEditor extends Component
{
    public PostForm $form;

    public function mount(?Post $post = null): void
    {
        if ($post?->exists) {
            $this->form->setPost($post);
        }
    }

    public function save(): void
    {
        $this->form->post
            ? $this->form->update()
            : $this->form->store();

        $this->redirect(route('posts.index'), navigate: true);
    }
}
Blade
<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>

10. Вложенные компоненты и wire:key

Компоненты вкладываются друг в друга. Каждый вложенный компонент — независимая единица со своим состоянием и своим циклом обновления: клик внутри дочернего компонента не перерендеривает родителя.

Blade
<div>
    <h1>Заказы</h1>

    @foreach ($orders as $order)
        <livewire:order-row :order="$order" :key="'order-'.$order->id" />
    @endforeach
</div>
:key обязателен в циклах. Без уникального ключа morph-алгоритм перепутает элементы при сортировке, фильтрации или удалении: значения полей «переедут» на соседние строки. Ключ должен быть стабильным — $loop->index не подходит, используйте id записи.

Передача данных вниз

Параметры передаются один раз, в mount(). При последующих рендерах родителя дочерний компонент не обновляется автоматически — он живёт своей жизнью.

PHP
use Livewire\Attributes\Reactive;

class OrderTotal extends Component
{
    // С #[Reactive] значение будет приезжать из родителя при каждом его рендере.
    #[Reactive]
    public int $quantity;
}

Обращение к родителю

Blade
<button wire:click="$parent.refreshList()">Обновить список</button>

Двусторонняя связь: #[Modelable]

Позволяет использовать wire:model на самом компоненте — удобно для полей-виджетов.

app/Livewire/RatingInput.php
use Livewire\Attributes\Modelable;

class RatingInput extends Component
{
    #[Modelable]
    public int $value = 0;

    public function set(int $value): void
    {
        $this->value = $value;
    }
}
Blade
{{-- В родительском шаблоне --}}
<livewire:rating-input wire:model.live="review.rating" />

Условный рендер вложенных компонентов

Blade
@if ($showDetails)
    <livewire:order-details :order-id="$orderId" :key="'details-'.$orderId" />
@endif
Когда дробить на компоненты. Выделяйте вложенный компонент, если у блока есть собственное состояние или если он обновляется независимо (строка таблицы, модальное окно, виджет). Если блок — просто разметка, используйте обычный Blade-partial: он дешевле.

11. События

События — способ связать компоненты, которые не находятся в отношениях «родитель — потомок». В Livewire 3 отправка — это dispatch() (в версии 2 было emit()).

Отправка

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();
Blade
{{-- Прямо из шаблона --}}
<button wire:click="$dispatch('open-modal', { name: 'create-order' })">Новый заказ</button>

Приём

PHP
use Livewire\Attributes\On;

class OrderList extends Component
{
    public array $orders = [];

    #[On('post-created')]
    public function onPostCreated(int $postId, string $title): void
    {
        $this->orders[] = ['id' => $postId, 'title' => $title];
    }

    // Динамическое имя события
    #[On('order-updated.{orderId}')]
    public function onOrderUpdated(): void
    {
        $this->refreshList();
    }
}

Альтернатива — массив $listeners (совместимо с версией 2):

PHP
protected $listeners = [
    'post-created' => 'onPostCreated',
    'refresh'      => '$refresh',
];

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

Livewire может отправить обычное DOM-событие — его поймает Alpine.js или ваш собственный JS. Это правильный способ показывать тосты и открывать модальные окна.

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>
JavaScript
document.addEventListener('notify', (event) => {
    console.log(event.detail.message);
});
Не злоупотребляйте событиями. Событие — это лишний цикл запроса для каждого слушающего компонента. Если два блока всегда меняются вместе, часто дешевле объединить их в один компонент.

12. Вычисляемые свойства

#[Computed] — способ получать производные данные без хранения их в состоянии. Результат кэшируется на время одного запроса, поэтому обращение к нему трижды в шаблоне не даст трёх SQL-запросов.

PHP
use Livewire\Attributes\Computed;
use Illuminate\Support\Collection;

class Cart extends Component
{
    public array $items = [];

    #[Computed]
    public function products(): Collection
    {
        return Product::whereIn('id', array_keys($this->items))->get();
    }

    #[Computed]
    public function subtotal(): float
    {
        return $this->products->sum(
            fn (Product $product) => $product->price * $this->items[$product->id]
        );
    }

    #[Computed]
    public function total(): float
    {
        return round($this->subtotal * 1.2, 2);   // с НДС
    }
}
Blade
<div>
    @foreach ($this->products as $product)
        <div>{{ $product->name }} — {{ $this->items[$product->id] }} шт.</div>
    @endforeach

    <p>Сумма: {{ number_format($this->subtotal, 2, ',', ' ') }} €</p>
    <p>Итого с НДС: {{ number_format($this->total, 2, ',', ' ') }} €</p>
</div>
Обращение через $this. В шаблоне вычисляемое свойство доступно как $this->products, а не $products. Это отличает его от обычных публичных свойств.

Кэширование между запросами

PHP
// Кэш на 5 минут в общем кэше приложения
#[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
public function addItem(int $productId): void
{
    $this->items[$productId] = ($this->items[$productId] ?? 0) + 1;

    unset($this->products, $this->subtotal, $this->total);   // сбросить кэш
}

Данные в render() vs #[Computed]

Оба варианта не хранят данные в состоянии. Разница в области видимости: render() отдаёт переменные только шаблону, а #[Computed] доступно и в PHP-методах, и в шаблоне, и кэшируется.

PHP
public function render()
{
    return view('livewire.orders', [
        'orders' => Order::query()
            ->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
            ->latest()
            ->paginate(20),
    ]);
}

13. Состояние в URL и сессии

Фильтры, поиск и активная вкладка должны переживать перезагрузку страницы и попадать в ссылку, которой можно поделиться. Атрибут #[Url] синхронизирует свойство со строкой запроса.

PHP
use Livewire\Attributes\Url;

class ProductCatalog extends Component
{
    #[Url]
    public string $search = '';

    // Другое имя параметра в URL
    #[Url(as: 'cat')]
    public ?int $categoryId = null;

    // Не показывать в URL, пока значение совпадает с начальным
    #[Url(except: '')]
    public string $sort = 'popular';

    // Сохранять между переходами wire:navigate
    #[Url(keep: true)]
    public int $perPage = 24;

    // Использовать history.pushState вместо replaceState
    #[Url(history: true)]
    public string $tab = 'all';
}

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

Хранение в сессии

PHP
use Livewire\Attributes\Session;

class Sidebar extends Component
{
    #[Session]
    public bool $collapsed = false;

    #[Session(key: 'admin.table.density')]
    public string $density = 'comfortable';
}

Ручная работа с сессией и flash

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

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

    session()->flash('status', 'Настройки сохранены');
}
Blade
@if (session('status'))
    <div class="docs-note docs-note--tip">{{ session('status') }}</div>
@endif
Не кладите в URL всё подряд. Каждое #[Url]-свойство при изменении дёргает History API. Для полей, которые меняются на каждое нажатие клавиши, комбинируйте с wire:model.live.debounce.

14. Загрузка файлов

Трейт WithFileUploads добавляет полноценную загрузку: файл уходит на сервер сразу после выбора, кладётся во временное хранилище и становится доступен как объект TemporaryUploadedFile.

PHP
use Livewire\WithFileUploads;
use Livewire\Attributes\Validate;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;

class AvatarUploader extends Component
{
    use WithFileUploads;

    #[Validate('required|image|mimes:jpg,jpeg,png,webp|max:4096')]   // до 4 МБ
    public $avatar;

    #[Validate(['documents.*' => 'file|mimes:pdf,docx|max:10240'])]
    public array $documents = [];

    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: 'Аватар обновлён');
    }

    public function removeDocument(int $index): void
    {
        unset($this->documents[$index]);

        $this->documents = array_values($this->documents);
    }
}
Blade
<form wire:submit="save">
    <input type="file" wire:model="avatar" accept="image/*">

    {{-- Прогресс загрузки --}}
    <div wire:loading wire:target="avatar" class="text-sm">Загружаем файл…</div>

    {{-- Превью до сохранения --}}
    @if ($avatar)
        <img src="{{ $avatar->temporaryUrl() }}" alt="Превью" class="w-32 h-32 object-cover rounded">
    @endif

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

    <button type="submit" wire:loading.attr="disabled">Сохранить</button>
</form>

Точный индикатор прогресса

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="h-2 bg-gray-200 rounded">
        <div class="h-2 bg-green-600 rounded" :style="`width: ${progress}%`"></div>
    </div>
</div>

Настройка временного хранилища

config/livewire.php
'temporary_file_upload' => [
    'disk'       => 's3',            // или null → диск по умолчанию
    'rules'      => ['file', 'max:12288'],
    'directory'  => 'livewire-tmp',
    'middleware' => 'throttle:60,1',
    'preview_mimes' => ['png', 'jpeg', 'jpg', 'webp', 'gif', 'mp4', 'pdf'],
    'max_upload_time' => 5,          // минут до автоочистки
],
Проверьте лимиты PHP. upload_max_filesize, post_max_size и max_execution_time в php.ini, а также client_max_body_size в nginx должны быть не меньше вашего max: в правилах. Иначе загрузка обрывается без внятной ошибки.

15. Пагинация

Трейт WithPagination подключает пагинацию Laravel без перезагрузки страницы.

PHP
use Livewire\WithPagination;
use Livewire\Attributes\Url;

class OrderTable extends Component
{
    use WithPagination;

    #[Url]
    public string $search = '';

    #[Url]
    public string $status = '';

    public string $sortField = 'created_at';
    public string $sortDirection = 'desc';
    public int $perPage = 25;

    // Сбрасываем на первую страницу при смене фильтров.
    public function updatedSearch(): void
    {
        $this->resetPage();
    }

    public function updatedStatus(): 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 render()
    {
        return view('livewire.order-table', [
            'orders' => Order::query()
                ->with('customer')
                ->when($this->search, fn ($q) => $q->where('number', 'like', "%{$this->search}%"))
                ->when($this->status, fn ($q) => $q->where('status', $this->status))
                ->orderBy($this->sortField, $this->sortDirection)
                ->paginate($this->perPage),
        ]);
    }
}
Blade
<div>
    <input type="search" wire:model.live.debounce.400ms="search" placeholder="Поиск по номеру">

    <table>
        <thead>
            <tr>
                <th wire:click="sortBy('number')" style="cursor:pointer">Номер</th>
                <th wire:click="sortBy('created_at')" style="cursor:pointer">Дата</th>
                <th wire:click="sortBy('total')" style="cursor:pointer">Сумма</th>
            </tr>
        </thead>
        <tbody>
            @forelse ($orders as $order)
                <tr wire:key="order-{{ $order->id }}">
                    <td>{{ $order->number }}</td>
                    <td>{{ $order->created_at->format('d.m.Y') }}</td>
                    <td>{{ number_format($order->total, 2, ',', ' ') }}</td>
                </tr>
            @empty
                <tr><td colspan="3">Заказы не найдены</td></tr>
            @endforelse
        </tbody>
    </table>

    {{ $orders->links() }}
</div>

Полезные детали

PHP
// Своё имя параметра страницы — нужно, если на странице два пагинатора
protected string $paginationTheme = 'tailwind';   // или 'bootstrap'

public function render()
{
    return view('livewire.dashboard', [
        'orders'   => Order::paginate(10, pageName: 'orders-page'),
        'invoices' => Invoice::paginate(10, pageName: 'invoices-page'),
    ]);
}
Производительность. На больших таблицах paginate() выполняет дополнительный COUNT(*). Если общее число страниц не нужно, используйте simplePaginate() — он заметно дешевле.

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

Каждое действие — это сетевой запрос. Без индикации интерфейс кажется зависшим. Livewire даёт декларативные директивы, которые не требуют ни строчки JS.

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>

{{-- Для конкретного свойства --}}
<span wire:loading wire:target="search">Ищем…</span>

{{-- Исключить цель --}}
<div wire:loading wire:target.except="search">Обновляем…</div>

Модификаторы

Blade
{{-- Заблокировать кнопку --}}
<button wire:click="save" wire:loading.attr="disabled">Сохранить</button>

{{-- Добавить CSS-класс --}}
<button wire:click="save" wire:loading.class="opacity-50 cursor-wait">Сохранить</button>

{{-- Убрать класс --}}
<div wire:loading.class.remove="bg-white">…</div>

{{-- Задержка: показать индикатор, только если запрос дольше 300 мс --}}
<div wire:loading.delay>Загрузка…</div>

{{-- Точные пороги: shortest 50ms, shorter 100ms, short 150ms,
     default 200ms, long 300ms, longer 500ms, longest 1s --}}
<div wire:loading.delay.long>Загрузка…</div>

{{-- Управление отображением --}}
<div wire:loading.flex>…</div>
<div wire:loading.grid>…</div>
<div wire:loading.inline-flex>…</div>

Несохранённые изменения: wire:dirty

Blade
<input wire:model="title">

<span wire:dirty wire:target="title" class="text-amber-600">Есть несохранённые изменения</span>

<button wire:click="save" wire:dirty.class="ring-2 ring-amber-400">Сохранить</button>

Потеря соединения: wire:offline

Blade
<div wire:offline class="banner banner--warn">
    Нет соединения с сервером. Изменения не сохраняются.
</div>

Скелетон при первой загрузке

Blade
<div wire:init="loadHeavyData">
    @if ($loaded)
        {{-- реальные данные --}}
    @else
        <div class="skeleton h-40 w-full animate-pulse bg-gray-200"></div>
    @endif
</div>

18. Ленивая загрузка и polling

Ленивая загрузка компонента

Тяжёлый компонент можно не рендерить в первом ответе: страница отдаётся мгновенно с заглушкой, а содержимое подгружается вторым запросом.

Blade
<livewire:revenue-chart lazy />
PHP
use Livewire\Attributes\Lazy;

#[Lazy]
class RevenueChart extends Component
{
    public function placeholder(): string
    {
        return <<<'BLADE'
            <div class="skeleton h-64 w-full animate-pulse rounded bg-gray-200"></div>
        BLADE;
    }

    public function render()
    {
        return view('livewire.revenue-chart', [
            'points' => app(RevenueService::class)->monthly(),   // тяжёлый запрос
        ]);
    }
}

По умолчанию ленивый компонент грузится сразу после отрисовки страницы. Вариант #[Lazy(isolate: false)] объединяет запросы нескольких ленивых компонентов в один, а lazy="on-load" / lazy="on-scroll" управляют моментом загрузки.

Blade
{{-- Подгрузить, когда блок попадёт во вьюпорт --}}
<livewire:revenue-chart lazy="on-scroll" />

Отложенная инициализация: wire:init

Blade
<div wire:init="loadStats">
    @if ($stats)
        …
    @else
        <div class="skeleton"></div>
    @endif
</div>

Опрос сервера: wire:poll

Blade
{{-- Обновлять каждые 2 с (значение по умолчанию — 2500 мс) --}}
<div wire:poll>…</div>

{{-- Свой интервал --}}
<div wire:poll.5s>…</div>
<div wire:poll.750ms>…</div>

{{-- Вызывать конкретный метод --}}
<div wire:poll.10s="refreshQueue">…</div>

{{-- Останавливать опрос, когда вкладка неактивна --}}
<div wire:poll.visible.5s="refreshQueue">…</div>

{{-- Останавливать через 5 минут неактивности пользователя --}}
<div wire:poll.keep-alive.5s>…</div>
Стоимость опроса. wire:poll.2s на странице, открытой у 100 сотрудников, это 3000 запросов в минуту к вашему PHP-процессу. Всегда добавляйте .visible, увеличивайте интервал до разумного и рассматривайте websockets (Laravel Echo + Reverb) для по-настоящему живых данных.

19. Alpine.js, $wire и JS-хуки

Alpine.js входит в поставку Livewire 3. Внутри компонента доступен объект $wire — прокси к состоянию и методам PHP-компонента прямо из JavaScript.

Blade
<div x-data="{ open: false }">
    {{-- Чисто клиентское состояние: сервер не участвует --}}
    <button x-on:click="open = !open">Детали</button>

    <div x-show="open" x-transition>
        {{-- Чтение свойства компонента --}}
        <p x-text="$wire.title"></p>

        {{-- Запись свойства --}}
        <button x-on:click="$wire.title = 'Новый заголовок'">Переименовать</button>

        {{-- Вызов метода (возвращает Promise) --}}
        <button x-on:click="$wire.save()">Сохранить</button>

        {{-- Метод с ожиданием результата --}}
        <button x-on:click="await $wire.calculate(); open = false">Рассчитать</button>

        {{-- Вызов без перерисовки --}}
        <button x-on:click="$wire.$set('tab', 'stats', false)">Статистика</button>
    </div>
</div>

Двусторонняя связь: $wire.entangle

Blade
<div x-data="{ query: $wire.entangle('search') }">
    <input x-model="query">
    <p x-show="query.length > 0">Ищем: <span x-text="query"></span></p>
</div>

{{-- Отложенная синхронизация: не гонять запрос на каждый символ --}}
<div x-data="{ query: $wire.entangle('search').live }">…</div>

Собственные скрипты внутри компонента

Blade
@script
<script>
    // Выполнится один раз при инициализации компонента.
    const chart = new Chart(document.getElementById('sales'), {
        type: 'line',
        data: @json($chartData),
    });

    // $wire доступен и здесь.
    $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

Игнорирование поддерева

Если сторонний виджет сам управляет DOM, morph-алгоритм ему только мешает.

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

{{-- Игнорировать только детей, но обновлять атрибуты самого элемента --}}
<div wire:ignore.self>…</div>

Глобальные JS-хуки

JavaScript
document.addEventListener('livewire:init', () => {

    // Перед отправкой каждого запроса
    Livewire.hook('request', ({ uri, options, payload, respond, succeed, fail }) => {
        options.headers['X-Tenant'] = window.tenantId;

        succeed(({ status, json }) => {
            console.debug('Livewire ответил', status);
        });

        fail(({ status, preventDefault }) => {
            if (status === 419) {
                preventDefault();
                window.location.reload();   // истёк CSRF-токен
            }
        });
    });

    // Перед и после morph отдельного элемента
    Livewire.hook('morph.updated', ({ el, component }) => {});

    // Компонент инициализирован
    Livewire.hook('component.init', ({ component }) => {});
});

// Программный доступ к компонентам
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());

20. Тестирование

Livewire даёт собственный тестовый API поверх Laravel. Тесты быстрые — браузер не нужен, работает обычный PHPUnit или Pest.

tests/Feature/CounterTest.php
<?php

use App\Livewire\Counter;
use Livewire\Livewire;

it('увеличивает счётчик', function () {
    Livewire::test(Counter::class)
        ->assertSet('count', 0)
        ->call('increment')
        ->assertSet('count', 1)
        ->call('increment')
        ->assertSet('count', 2)
        ->call('decrement')
        ->assertSet('count', 1);
});
tests/Feature/ContactFormTest.php
use App\Livewire\ContactForm;
use App\Models\Contact;
use Livewire\Livewire;

it('валидирует обязательные поля', function () {
    Livewire::test(ContactForm::class)
        ->set('name', '')
        ->set('email', 'не-email')
        ->call('submit')
        ->assertHasErrors([
            'name'  => 'required',
            'email' => 'email',
        ])
        ->assertNoRedirect();
});

it('сохраняет корректную заявку', function () {
    Livewire::test(ContactForm::class)
        ->set('name', 'Ирина Ковач')
        ->set('email', 'irina@example.com')
        ->set('message', str_repeat('Нужен сайт для клиники. ', 3))
        ->set('consent', true)
        ->call('submit')
        ->assertHasNoErrors()
        ->assertDispatched('notify');

    expect(Contact::where('email', 'irina@example.com')->exists())->toBeTrue();
});

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

МетодПроверяет
assertSet('prop', $value)Значение свойства
assertNotSet('prop', $value)Значение отличается
assertSee('текст')Текст присутствует в HTML
assertDontSee('текст')Текста нет
assertSeeHtml('<b>')Разметка присутствует
assertHasErrors(['email'])Ошибки валидации
assertHasNoErrors()Ошибок нет
assertDispatched('event')Событие отправлено
assertRedirect(route(…))Выполнен редирект
assertStatus(403)HTTP-статус
assertForbidden()Доступ запрещён
assertCount('items', 3)Размер массива/коллекции

Аутентификация, параметры, события

PHP
// От имени пользователя
Livewire::actingAs($admin)
    ->test(OrderTable::class)
    ->assertSee('Все заказы');

// С параметрами mount()
Livewire::test(OrderCard::class, ['order' => $order, 'mode' => 'full'])
    ->assertSee($order->number);

// Приём события
Livewire::test(OrderList::class)
    ->dispatch('order-created', orderId: 42)
    ->assertSee('Заказ №42');

// Загрузка файла
use Illuminate\Http\UploadedFile;

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

// Компонент внутри страницы
$this->get('/dashboard')
    ->assertSeeLivewire(Dashboard::class)
    ->assertDontSeeLivewire(AdminPanel::class);
Что тестировать в первую очередь. Авторизацию в действиях, правила валидации и переходы состояний. Именно там ломается функциональность и именно там прячутся дыры в безопасности.

21. Безопасность

Главный принцип. Каждый публичный метод компонента — это открытый HTTP-эндпоинт. Каждое публичное свойство клиент может изменить. Относитесь к компоненту как к контроллеру, а не как к внутреннему классу.

1. Авторизация внутри каждого действия

PHP
public function delete(int $postId): void
{
    $post = Post::findOrFail($postId);

    $this->authorize('delete', $post);   // Policy — обязательно

    $post->delete();
}
PHP
// Авторизация всего компонента
public function mount(Project $project): void
{
    $this->authorize('view', $project);

    $this->project = $project;
}

2. #[Locked] для идентификаторов

PHP
use Livewire\Attributes\Locked;

class InvoiceEditor extends Component
{
    #[Locked]
    public int $invoiceId;      // подменить с фронтенда нельзя

    public string $note = '';
}

Без #[Locked] достаточно одной строчки в консоли браузера:

JavaScript (атака)
Livewire.find('...').set('invoiceId', 999)   // чужой счёт

3. Правила для привязки к моделям

wire:model="post.title" работает, только если поле разрешено правилом валидации — так Livewire защищает от массового присвоения.

PHP
protected function rules(): array
{
    return [
        'post.title' => 'required|string|max:180',
        'post.body'  => 'required|string',
        // 'post.user_id' сознательно НЕ включаем — иначе пост можно переписать на другого автора
    ];
}

4. Экранирование вывода

Blade
{{-- Безопасно: экранируется --}}
{{ $comment->body }}

{{-- Опасно: сырой HTML от пользователя --}}
{!! $comment->body !!}

{{-- Если HTML нужен — санитизируйте на сервере --}}
{!! clean($comment->body) !!}

5. Ограничение частоты

PHP
use Illuminate\Support\Facades\RateLimiter;

public function login(): void
{
    $key = 'login:' . request()->ip();

    if (RateLimiter::tooManyAttempts($key, 5)) {
        throw ValidationException::withMessages([
            'email' => 'Слишком много попыток. Повторите через минуту.',
        ]);
    }

    RateLimiter::hit($key, 60);

    // …
}

6. Не храните секреты в свойствах

PHP
class PaymentForm extends Component
{
    public string $cardLast4 = '';        // ок

    // НЕЛЬЗЯ: уедет в браузер в открытом виде внутри wire:snapshot
    // public string $apiSecret = '';
    // public string $fullCardNumber = '';

    protected function gateway(): PaymentGateway
    {
        return app(PaymentGateway::class);   // секреты остаются на сервере
    }
}

Чек-лист перед релизом

  • $this->authorize() есть в каждом изменяющем данные действии.
  • Все идентификаторы помечены #[Locked].
  • Правила валидации не включают служебные поля (user_id, role, price).
  • Публичные свойства не содержат токенов, ключей и персональных данных сверх необходимого.
  • Загрузка файлов ограничена по MIME-типам и размеру.
  • Формы входа и отправки сообщений защищены rate limiting.
  • {!! !!} используется только для доверенного или санитизированного HTML.

22. Производительность

1. Минимум состояния в публичных свойствах

Плохо
public Collection $products;   // 500 моделей ездят на клиент и обратно

public function mount(): void
{
    $this->products = Product::with('category', 'images')->get();
}
Хорошо
public function render()
{
    return view('livewire.catalog', [
        'products' => Product::with('category')->paginate(24),
    ]);
}

2. Не гоняйте запросы на каждое нажатие клавиши

Blade
{{-- Плохо: запрос на каждый символ --}}
<input wire:model.live="search">

{{-- Хорошо --}}
<input wire:model.live.debounce.400ms="search">

3. Дробите тяжёлые страницы на компоненты

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

4. Кэшируйте дорогие вычисления

PHP
#[Computed(persist: true, seconds: 600)]
public function monthlyRevenue(): array
{
    return app(RevenueService::class)->byMonth();
}

5. Следите за N+1

PHP
// Плохо — запрос на каждую строку в шаблоне
$orders = Order::paginate(50);

// Хорошо
$orders = Order::with(['customer', 'items.product'])->paginate(50);

Включите строгий режим в разработке, чтобы N+1 падал с ошибкой:

app/Providers/AppServiceProvider.php
public function boot(): void
{
    Model::preventLazyLoading(! app()->isProduction());
}

6. wire:key в списках

Без ключей morph-алгоритм перестраивает больше узлов, чем нужно, — и делает это неверно.

7. Ленивая загрузка тяжёлых блоков

Blade
<livewire:revenue-chart lazy="on-scroll" />

8. Аккуратно с polling

wire:poll.visible.10s вместо wire:poll экономит серверу порядок величины запросов.

СимптомВероятная причина
Задержка 300–800 мс на любое действиеТяжёлый render() или N+1
Огромный HTML страницыКоллекции моделей в публичных свойствах
Растёт нагрузка на PHP-FPMАгрессивный wire:poll или .live без debounce
Поля «прыгают» при обновлении спискаОтсутствует wire:key
Перерендеривается вся страницаВсё в одном компоненте, нет разбиения

23. Частые ошибки и решения

«Component must have a single root element»

В шаблоне несколько корневых узлов или текст/комментарий на верхнем уровне. Оберните всё в один <div>.

Плохо
<h1>Заголовок</h1>
<p>Текст</p>
Хорошо
<div>
    <h1>Заголовок</h1>
    <p>Текст</p>
</div>

«Livewire encountered corrupt data»

Не совпала подпись snapshot. Причины: изменился APP_KEY, страница открыта из кэша после деплоя, две вкладки с разными сессиями. Обычно лечится перезагрузкой страницы; после деплоя — php artisan optimize:clear.

Клики не работают, консоль пустая

  • Нет @livewireScripts в layout.
  • Подключён второй экземпляр Alpine.js (в Livewire 3 он уже внутри).
  • Ошибка JS выше по странице оборвала выполнение.
  • Элемент внутри wire:ignore.

Значения полей «переезжают» между строками

Отсутствует wire:key/:key в цикле. Ключ должен быть стабильным и уникальным.

Blade
@foreach ($rows as $row)
    <div wire:key="row-{{ $row->id }}">…</div>
@endforeach

«Unable to set component data. Public property not found»

Вы привязали wire:model к несуществующему или не-публичному свойству. Проверьте написание и модификатор доступа.

«Cannot bind to model data without validation rules»

Привязка вида wire:model="post.title" требует правила для post.title в rules() или #[Validate].

Сторонний виджет ломается после обновления

Select2, Flatpickr, TinyMCE и подобные сами меняют DOM. Оберните в wire:ignore и синхронизируйте вручную.

Blade
<div wire:ignore x-data x-init="
    const picker = flatpickr($refs.input, {
        onChange: (dates, str) => $wire.set('date', str),
    });
">
    <input x-ref="input" type="text">
</div>

Загрузка файла молча падает

Проверьте upload_max_filesize и post_max_size в PHP, а также client_max_body_size в nginx. Значения должны превышать лимит в правиле max:.

Модальное окно не закрывается после сохранения

Отправьте браузерное событие и обработайте его в Alpine, а не полагайтесь на перерисовку.

PHP
$this->dispatch('close-modal', name: 'order-form');

Скрипты не работают после wire:navigate

Перенесите инициализацию из DOMContentLoaded в livewire:navigated.

Ошибка 419 (Page Expired)

Истекла сессия. Увеличьте SESSION_LIFETIME, либо перехватите статус в хуке request и перезагрузите страницу — пример есть в главе 19.

24. Шпаргалка

Директивы Blade

ДирективаНазначение
wire:modelПривязка поля к свойству (отложенная)
wire:model.liveПривязка с запросом на каждое изменение
wire:model.blurОтправка при потере фокуса
wire:clickВызов метода по клику
wire:submitОбработка отправки формы
wire:keydown.enterРеакция на клавишу
wire:changeРеакция на change
wire:confirmПодтверждение перед действием
wire:loadingИндикатор загрузки
wire:targetСузить индикатор до действия
wire:dirtyЕсть несохранённые изменения
wire:offlineНет соединения
wire:pollПериодическое обновление
wire:initВызвать метод сразу после рендера
wire:navigateSPA-переход по ссылке
wire:keyИдентификатор элемента в цикле
wire:ignoreИсключить поддерево из morph
wire:transitionАнимация появления/исчезновения
wire:streamПотоковая отдача содержимого

PHP-атрибуты

АтрибутНазначение
#[Validate]Правило валидации свойства
#[Locked]Запрет изменения с фронтенда
#[Computed]Вычисляемое свойство с кэшем
#[Url]Синхронизация со строкой запроса
#[Session]Хранение значения в сессии
#[On]Слушатель события
#[Reactive]Обновление параметра из родителя
#[Modelable]Поддержка wire:model на компоненте
#[Lazy]Ленивая загрузка компонента
#[Layout]Layout для full-page компонента
#[Title]Заголовок страницы
#[Renderless]Метод без перерисовки

Полезные методы компонента

PHP
$this->reset();                       // сбросить все свойства к начальным
$this->reset('search', 'page');       // сбросить выбранные
$this->only('title', 'body');         // массив из части свойств
$this->except('password');            // всё, кроме указанного
$this->fill(['title' => 'Новый']);    // массовое присвоение
$this->pull('draft');                 // получить и сбросить

$this->validate();
$this->validateOnly('email');
$this->resetValidation();
$this->addError('email', 'Сообщение');

$this->dispatch('saved', id: $post->id);
$this->redirect('/orders', navigate: true);
$this->redirectRoute('orders.index', navigate: true);

$this->skipRender();                  // не перерисовывать в этом цикле
$this->js('alert("Готово")');         // выполнить JS на клиенте
$this->stream(to: 'answer', content: $chunk);

Artisan-команды

Bash
php artisan livewire:make Orders/OrderTable
php artisan livewire:make Counter --inline
php artisan livewire:form PostForm
php artisan livewire:attribute ValidPhone
php artisan livewire:publish --config
php artisan livewire:publish --assets

Официальные ресурсы