Livewire 4 — полное руководство
Livewire 4 сохраняет знакомую модель компонента и перестраивает всё вокруг неё: однофайловые компоненты, острова с независимым рендерингом, слоты в контексте родителя, встроенный drag-and-drop и клиентские директивы, которые вообще не ходят на сервер. Руководство покрывает фреймворк целиком, с явным указанием отличий четвёртой версии.
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 остаётся первоисточником.
wire:model, острова и слоты. Остальная модель компонента
покажется знакомой.
2. Установка и требования
composer require livewire/livewire:^4.0
php artisan optimize:clear
Как и в третьей версии, ассеты подключаются автоматически. @livewireStyles и
@livewireScripts нужно ставить руками только при нестандартном layout или строгой
Content Security Policy.
/livewire/ стал /livewire-{hash}/. Если у вас есть правила файрвола,
исключения WAF, обходы CDN или location-блоки nginx, привязанные к буквальному пути
/livewire/, их нужно расширить под новый шаблон — иначе на проде приложение будет
выглядеть сломанным, идеально работая на ноутбуке.
Публикация конфигурации
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. |
Выбор стиля компонентов по умолчанию
// Продолжать генерировать классовые компоненты вместо однофайловых
'make_command' => [
'type' => 'class',
],3. Однофайловые компоненты
Главное изменение в повседневной работе. Компонент — это один Blade-файл, который начинается с PHP-блока с анонимным классом, а дальше идёт разметка.
php artisan make:livewire post.create
# 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 его можно отключить, если так
удобнее инструментам, терминалу или команде.
Многофайловые компоненты
Когда компонент перерастает комфортный размер одного файла, сгенерируйте его многофайловым — класс, шаблон и тест снова окажутся раздельно.
php artisan make:livewire post.create --mfcКлассовые компоненты продолжают работать
<?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 />Вывод
{{-- По имени --}}
<livewire:counter />
{{-- Вложенные каталоги через точку --}}
<livewire:post.create />
{{-- С namespace --}}
<livewire:pages::post.create />
{{-- С параметрами --}}
<livewire:post.create :title="$initialTitle" :author="$user" /><livewire:some-component> просто не рендерится — без ошибки, без вывода. Всегда
самозакрывайте: <livewire:some-component />. Это самая частая неожиданность при
переносе Blade-файлов из v3.
Маршрут на компонент
Для full-page компонентов появился отдельный макрос. Старая форма
Route::get('/dashboard', Dashboard::class) заменяется на:
use App\Livewire\Dashboard;
// По классу
Route::livewire('/dashboard', Dashboard::class);
// По имени компонента — обычный выбор для view-based компонентов
Route::livewire('/dashboard', 'pages::dashboard')
->middleware('auth')
->name('dashboard');Layout
'component_layout' => 'layouts::app',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.
new class extends Component {
public string $name = '';
public ?int $age = null;
public array $tags = [];
public bool $isPublic = false;
};Props от родителя: #[Prop]
Значения, приходящие из родительского шаблона, можно объявить явно. Так публичный интерфейс компонента читается с одного взгляда, а «пришло снаружи» отделяется от «внутреннее состояние».
<?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><livewire:alert type="warning" dismissible class="mt-4">
Счёт просрочен.
</livewire:alert>mount() по-прежнему первый
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] важен ровно как раньше
use Livewire\Attributes\Locked;
new class extends Component {
#[Locked]
public int $invoiceId; // клиент не подставит чужой id
public string $note = '';
};6. wire:model в четвёртой версии
wire:model ведут себя иначе, и оба ломаются молча, без ошибки.
Изменение 1: модификаторы управляют синхронизацией на клиенте
В v3 .blur и .change решали только, когда уходит сетевой запрос.
В v4 они решают, когда значение вообще синхронизируется — в том числе на клиенте. Чтобы
вернуть прежнее поведение, добавьте .live.
{{-- Livewire 3 --}}
<input wire:model.blur="title">
{{-- Эквивалент того же поведения в v4 --}}
<input wire:model.live.blur="title">Изменение 2: события больше не всплывают от детей
wire:model на обёртке больше не перехватывает события, всплывающие от вложенных полей.
Если вы на это полагались, добавьте .deep.
{{-- Livewire 3: обёртка ловила событие поля --}}
<div wire:model="value">
<input type="text">
</div>
{{-- Livewire 4: включаем явно --}}
<div wire:model.deep="value">
<input type="text">
</div>Остальное без изменений
{{-- По умолчанию отложенно --}}
<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
Действия объявляются как и раньше — публичные методы, вызываемые из разметки.
new class extends Component {
public function save()
{
$this->validate();
// …
}
};<button wire:click="save">Сохранить</button>
<form wire:submit="save">…</form>
<button wire:click="delete({{ $post->id }})"
wire:confirm="Удалить пост? Отменить будет нельзя.">Удалить</button>Действия без перерисовки
Когда действие не меняет ничего видимого — увеличивает счётчик просмотров, пишет строку аудита — пропуск рендера экономит полный проход по шаблону и диф DOM.
use Livewire\Attributes\Renderless;
new class extends Component {
#[Renderless]
public function incrementViewCount(): void
{
$this->post->increment('views');
}
};{{-- Или со стороны шаблона --}}
<button type="button" wire:click.renderless="incrementViewCount">Учесть</button>Императивный эквивалент внутри метода — по-прежнему $this->skipRender().
Асинхронные действия
#[Async] (или модификатор .async) выполняет действие
параллельно, в обход обычной очереди запросов. Идеально для «выстрелил и забыл»:
аналитика, логирование, прогрев кеша.
use Livewire\Attributes\Async;
new class extends Component {
#[Async]
public function logInteraction(string $element): void
{
Analytics::record($element, auth()->id());
}
};<button wire:click.async="logInteraction('cta-hero')">Начать</button>Магические действия
<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. Ответ: разметка + effectsnew 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-объекты.
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();
}
};Условные правила
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-объекты
<?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.
<div x-show="$errors.has('email')" x-text="$errors.first('email')"></div>10. Острова (islands)
Острова — визитная карточка Livewire 4 и та возможность, которая меняет представление о размере компонента. Остров — это область внутри компонента, которая перерисовывается независимо: при обновлении пересчитывается и патчится только этот фрагмент, а не весь компонент.
@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 | Пропускает первичный рендер. |
Нацеливание на остров из действия
@island(name: 'revenue')
Выручка: {{ $this->revenue }}
@endisland
<button wire:click="$refresh" wire:island="revenue">Обновить выручку</button>Append и prepend — бесконечная лента без обвязки
@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:
<button x-on:click="$wire.$island('feed', { mode: 'append' }).loadMore()">
Показать ещё
</button>Опрос в пределах острова
@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><?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.
<?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><livewire:alert type="danger" class="mt-6" data-testid="overdue-alert">
Счёт просрочен на 14 дней.
</livewire:alert>12. Вложенные компоненты и wire:key
Вложенность работает как в v3: каждый потомок — независимая единица со своим состоянием и своим циклом обновления.
<div>
@foreach ($orders as $order)
<livewire:order-row :order="$order" :key="'order-'.$order->id" />
@endforeach
</div>Умные ключи
В четвёртой версии smart_wire_keys включён по умолчанию, поэтому Livewire выводит ключи
сам во многих ситуациях с циклами, где раньше требовался явный. Это убирает шаблонный код, но не
отменяет ключи.
wire:key,
привязанный к id записи. Умные ключи — удобство для простых случаев, а не замена явному указанию,
чем на самом деле является строка.
@foreach ($rows as $row)
<div wire:key="row-{{ $row->id }}">…</div>
@endforeachРеактивные props
use Livewire\Attributes\Reactive;
new class extends Component {
#[Reactive]
public int $quantity;
};Обращение к родителю
<button wire:click="$parent.refreshList()">Обновить список</button>Двусторонняя привязка на компоненте
use Livewire\Attributes\Modelable;
new class extends Component {
#[Modelable]
public int $value = 0;
};<livewire:rating-input wire:model.live="review.rating" />13. События
API событий не изменился по сравнению с третьей версией.
// Отправка
$this->dispatch('post-created');
$this->dispatch('post-created', postId: $post->id, title: $post->title);
$this->dispatch('refresh')->to(OrderList::class);
$this->dispatch('recalculate')->self();use Livewire\Attributes\On;
new class extends Component {
#[On('post-created')]
public function onPostCreated(int $postId, string $title): void
{
// …
}
};<button wire:click="$dispatch('open-modal', { name: 'create-order' })">Новый заказ</button>События в браузер
$this->dispatch('notify', type: 'success', message: 'Заказ сохранён');<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>14. Вычисляемые свойства
#[Computed] работает как в v3 и особенно хорошо сочетается с островами: остров,
читающий вычисляемое свойство, пересчитывает его только при своём обновлении.
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);
}
};@island(name: 'totals')
<p>Сумма: {{ number_format($this->subtotal, 2, ',', ' ') }} €</p>
<p>Итого с НДС: {{ number_format($this->total, 2, ',', ' ') }} €</p>
@endislandКеш за пределами запроса
#[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();
}Сброс кеша
unset($this->products, $this->subtotal, $this->total);$this->products, никогда не $products.
15. Состояние в URL и сессии
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-сообщения
public function save(): void
{
$this->validate();
Setting::updateOrCreate(['key' => 'theme'], ['value' => $this->theme]);
session()->flash('status', 'Настройки сохранены');
}@if (session('status'))
<div class="alert alert-success">{{ session('status') }}</div>
@endif#[Url]-свойство при изменении дёргает History API. Для полей, меняющихся на
каждое нажатие клавиши, комбинируйте с wire:model.live.debounce.
16. Загрузка файлов
Загрузка работает как в третьей версии, через трейт WithFileUploads.
<?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>Точный индикатор прогресса
<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>'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. Пагинация
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),
];
}
};Острова превращают пагинацию в бесконечную ленту
<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 для стилизации.
<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
[data-loading] .btn {
opacity: 0.5;
pointer-events: none;
}Несохранённые изменения и связь
<input wire:model="title">
<span wire:dirty wire:target="title">Есть несохранённые изменения</span>
<div wire:offline class="banner banner--warn">
Нет соединения с сервером. Изменения не сохраняются.
</div>19. Оптимистичный UI на клиенте
Целому классу взаимодействий сервер никогда не был нужен: открыть панель, показать счётчик символов, подсветить поле красным при превышении длины. В четвёртой версии для них есть полноценные директивы, которые меняют DOM мгновенно, вообще без запроса.
wire:show
{{-- Переключает видимость через CSS, мгновенно, без round trip --}}
<div wire:show="showModal" class="modal">…</div>
<button wire:click="$toggle('showModal')">Открыть</button>wire:text
{{-- Текст следует за свойством на клиенте --}}
<span wire:text="title"></span>
<input wire:model="title">wire:bind
<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
<?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-действию и держать две модели в синхроне. В четвёртой версии это директива.
<ul wire:sort="reorder">
@foreach ($tasks as $task)
<li wire:sort:item="{{ $task->id }}" wire:key="task-{{ $task->id }}">
{{ $task->title }}
</li>
@endforeach
</ul>new class extends Component {
public function reorder(array $order): void
{
foreach ($order as $position => $id) {
Task::where('id', $id)->update(['position' => $position]);
}
}
};Ручки перетаскивания
<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>Перетаскивание между списками
<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>21. Скоупленный CSS и скрипты
Раз компонент теперь один файл, его стили и скрипты тоже принадлежат этому файлу. Livewire ограничивает CSS областью компонента, так что он не протекает на остальную страницу.
<?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>Осознанно глобальные стили
<style global>
:root { --brand: #f53004; }
</style>Скрипты компонента
@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{{-- Загрузить стороннюю библиотеку один раз на всю страницу --}}
@assets
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
@endassets23. Интеграция с JavaScript
Alpine.js по-прежнему в комплекте Livewire, а $wire остаётся мостом к состоянию
компонента из JavaScript.
<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>{{-- Двусторонняя привязка в Alpine --}}
<div x-data="{ query: $wire.entangle('search') }">
<input x-model="query">
</div>wire:ref — имена для элементов и компонентов
<livewire:modal wire:ref="modal" />
<button x-on:click="$refs.modal.open()">Открыть модалку</button>#[Json] — данные прямо в JavaScript
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchSuggestions(string $term): array
{
return Product::search($term)->take(5)->pluck('name')->all();
}
};<input x-on:input.debounce.300ms="suggestions = await $wire.searchSuggestions($event.target.value)">Перехватчики
Четвёртая версия заменяет хуки commit и request из v3 на
interceptMessage() и interceptRequest().
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);
});
});
});// Программный доступ
Livewire.dispatch('refresh-orders');
Livewire.find('component-id').call('save');
Livewire.all().forEach((component) => component.$refresh());Игнорирование поддерева
<div wire:ignore>
<select id="select2-field">…</select>
</div>
<div wire:ignore.self>…</div>24. Тестирование
Pest — рекомендованный способ тестировать компоненты Livewire 4. Тесты view-based компонентов могут лежать рядом с компонентом, а не в отдельном дереве.
php artisan make:livewire post.create --test
# resources/views/components/post/create.test.phpТест view-based компонента
Компонент указывается по имени через точку, а не классом:
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();
});Классовые компоненты
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-статус |
// Загрузка файлов
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. Безопасность и производительность
Чек-лист безопасности
$this->authorize()в каждом действии, меняющем данные.- Все идентификаторы помечены
#[Locked]. - Вспомогательные методы объявлены
protectedилиprivate, чтобы клиент не мог их вызвать. - Правила валидации не включают служебные поля (
user_id,role,price). - Ни токенов, ни ключей, ни лишних персональных данных в публичных свойствах — snapshot уезжает в браузер открытым текстом.
- Загрузка файлов ограничена по MIME-типу и размеру.
- Формы входа и обратной связи за rate limiter.
{!! !!}только для доверенной или санитизированной разметки.- Оптимистичный UI никогда не считается достоверным.
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 |
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}26. Частые ошибки и шпаргалка
Компонент вообще не рендерится
Тег не самозакрыт. В v4 <livewire:my-component> не выводит ничего — молча. Пишите
<livewire:my-component />.
На проде всё 404, локально работает
Эндпоинт Livewire переехал с /livewire/ на /livewire-{hash}/. Правило
файрвола, исключение WAF, правило CDN или location в nginx, привязанные к старому буквальному пути,
блокируют запросы обновления.
Поле перестало синхронизироваться после апгрейда
wire:model.blur и .change теперь управляют синхронизацией на клиенте, а не
только сетью. Добавьте .live: wire:model.live.blur="title".
Обёртка перестала подхватывать своё поле
Всплытие событий выключено по умолчанию. Используйте wire:model.deep.
Модификаторы wire:transition ничего не делают
.opacity, .scale и .duration.200ms убраны — v4 использует
нативный View Transitions API. Опишите анимацию в CSS.
Остров не обновляется
- У острова нет
name, а действие целится в имя. wire:island="…"указывает на имя, которого нет в отрендеренном выводе.- Область зависит от состояния родителя, которое фактически не менялось — добавьте
always: true.
Содержимое слота вызывает не тот метод
Так задумано: разметка слота вычисляется в контексте родителя. Чтобы обратиться к потомку,
идите через событие или wire:ref.
Компоненты Volt перестали работать
Volt вошёл в ядро. Замените Livewire\Volt\Component на Livewire\Component,
Volt::route() на Route::livewire(), Volt::test() на
Livewire::test(), удалите сервис-провайдер и пакет Volt.
Шпаргалка по директивам
| Директива | Назначение | Новое в v4 |
|---|---|---|
wire:model | Привязка поля к свойству | изменилась семантика |
wire:model.deep | Ловить события от дочерних элементов | да |
wire:click / wire:submit | Вызов действия | — |
wire:click.async | Выполнить действие параллельно | да |
wire:click.renderless | Пропустить перерисовку | да |
wire:island | Нацелиться на остров из действия | да |
wire:island.append | Дописать в остров вместо замены | да |
wire:show | Переключить видимость на клиенте | да |
wire:text | Привязать текст на клиенте | да |
wire:bind | Реактивно привязать атрибут | да |
wire:sort | Перетаскивание и сортировка | да |
wire:intersect | Действие при попадании во вьюпорт | да |
wire:ref | Имя элемента или компонента для JS | да |
wire:navigate | SPA-навигация | — |
wire:navigate:scroll | Сохранить скролл контейнера | переименовано |
wire:loading / wire:dirty / wire:offline | Состояние запроса | — |
wire:poll | Периодическое обновление | в пределах острова |
wire:key | Идентичность элемента в цикле | умные ключи по умолчанию |
wire:ignore | Исключить поддерево из morph | — |
Директивы Blade
| Директива | Назначение |
|---|---|
@island … @endisland | Независимо рендерящаяся область |
@placeholder … @endplaceholder | Заглушка для ленивого острова |
<wire:slot name="…"> | Содержимое именованного слота |
@script … @endscript | JavaScript в области компонента |
@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
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: livewire.laravel.com/docs
- Upgrade guide: livewire.laravel.com/docs/upgrading
- Документация Laravel: laravel.com/docs
- Alpine.js: alpinejs.dev