Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді з Livewire і Filament

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

113 питань

Relation manager - таблиця пов'язаних записів, що показується під формою редагування чи переглядом запису ресурсу: коментарі поста, товари категорії, ролі користувача.

php artisan make:filament-relation-manager CategoryResource posts title

Після створення його реєструють у ресурсі:

public static function getRelations(): array
{
    return [PostsRelationManager::class];
}

Attach чи associate - залежить від типу зв'язку:

Дії Зв'язок Що відбувається
AttachAction, DetachAction, DetachBulkAction BelongsToMany, MorphToMany рядок у pivot-таблиці додається чи видаляється, сам запис лишається
AssociateAction, DissociateAction HasMany, MorphMany у пов'язаного запису змінюється зовнішній ключ (чи стає null)
CreateAction, EditAction, DeleteAction будь-який створення, редагування й видалення самих пов'язаних записів

Прапорці --attach і --associate команди генерації одразу додають відповідні дії.

Detach проти Delete: DetachAction лише розриває зв'язок, а DeleteAction видаляє сам запис - для всіх, хто з ним пов'язаний. Плутанина тут коштує даних.

Режим лише для читання. На сторінці перегляду (ViewRecord) relation manager-и за замовчуванням ховають дії зміни - щоб сторінка перегляду лишалася переглядом. Вимкнути можна для одного менеджера (перевизначити isReadOnly() і повернути false) або для всієї панелі через ->readOnlyRelationManagersOnResourceViewPagesByDefault(false).

Нестандартна назва зворотного зв'язку: Filament обмежує запити через зворотний зв'язок (від поста до категорії). Якщо він називається не за правилами Laravel - ->inverseRelationship('section') у table().

Бейджі на вкладці:

public static function getBadge(Model $ownerRecord, string $pageClass): ?string
{
    return (string) $ownerRecord->posts()->count();
}

Пам'ятайте, що це запит на кожне відкриття сторінки - для важких підрахунків є відкладене завантаження бейджів.

Доступ: canViewForRecord() за замовчуванням перевіряє політику viewAny пов'язаної моделі. CreateAction, EditAction, DeleteAction перевіряють відповідні методи політики (create, update, delete). А от AttachAction, DetachAction, AssociateAction і DissociateAction політику за замовчуванням не перевіряють - лише режим «тільки для читання». Якщо прив'язувати записи можна не всім, обмежте ці дії явно (->authorize(...) чи ->visible(...)), інакше будь-хто з доступом до сторінки редагування може змінювати зв'язки.

М'яке видалення: прапорець --soft-deletes додає фільтр видалених, відновлення й остаточне видалення.

Докладніше в документації: Filament: керування зв'язками

Filament має два рівні налаштування вигляду: CSS (як усе виглядає) і render hooks (що ще вивести в певному місці інтерфейсу).

1. Власна тема. Стандартна панель використовує вже скомпільований CSS Filament - власні класи Tailwind у ваших Blade-шаблонах сторінок і віджетів у ньому відсутні й просто не працюють. Для цього потрібна тема:

php artisan make:filament-theme admin

Команда встановлює залежності Tailwind CSS, створює resources/css/filament/admin/theme.css, додає файл у input плагіна Laravel у vite.config.js і реєструє тему в провайдері панелі:

return $panel->viteTheme('resources/css/filament/admin/theme.css');

Після цього тему збирає Vite (npm run build для продакшену). У CSS-файлі теми можна додати власні стилі й джерела класів (@source для ваших шаблонів).

Простіші налаштування без теми: кольори панелі - ->colors([...]), шрифт - ->font(...), логотип - ->brandLogo(...). CSS hooks - класи на кшталт fi-sidebar чи fi-btn, на які Filament розраховує для перевизначення стилів у вашій темі.

2. Render hooks - точки в шаблонах Filament, куди можна вставити свій HTML: банер, лічильник, скрипт аналітики, кнопку в шапці.

use Filament\Support\Facades\FilamentView;
use Filament\View\PanelsRenderHook;
use Illuminate\Contracts\View\View;

FilamentView::registerRenderHook(
    PanelsRenderHook::BODY_START,
    fn (): View => view('filament.maintenance-banner'),
);

// або в конфігурації конкретної панелі
$panel->renderHook(PanelsRenderHook::USER_MENU_BEFORE, fn () => view('filament.env-badge'));

Область дії (scopes) - хук лише на певних сторінках:

FilamentView::registerRenderHook(
    PanelsRenderHook::PAGE_START,
    fn (): View => view('warning-banner'),
    scopes: [EditUser::class, CreateUser::class],
);

Що варто знати:

  • хук виконується на кожному рендері - важкі запити в замиканні сповільнюють усю панель;
  • через $panel->renderHook() хук прив'язаний до однієї панелі, через FilamentView - до всіх;
  • перевизначення шаблонів пакета (vendor:publish з видами Filament) ламається при оновленнях - render hooks і CSS hooks існують саме для того, щоб цього не робити;
  • власні Blade-шаблони (сторінки, віджети) з класами Tailwind без теми виглядатимуть «голими» - це найчастіша причина питання «чому мої класи не працюють».

Докладніше в документації: Filament: render hooks

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

Де морфінг помиляється. Алгоритм порівнює елементи за позицією. Якщо між ними з'являється новий елемент, він може «зсунути» відповідність:

<div><input wire:model="title"></div>
@if ($errors->has('title'))
    <div>{{ $errors->first('title') }}</div>
@endif
<div><button>Зберегти</button></div>

Коли з'являється помилка, Livewire бачить на другому місці новий div і може вирішити, що це змінений старий, - результат: зайві перерисовки, втрачений стан сторонніх елементів. Livewire додає в шаблон службові маркери навколо @if і @foreach, щоб це згладити, але найнадійніший інструмент - ключі.

wire:key - ідентичність елемента:

@foreach ($todos as $todo)
    <li wire:key="todo-{{ $todo->id }}">...</li>
@endforeach

Morph зіставляє елементи за ключем, а не за позицією: видалення першого рядка не змушує «перейменовувати» всі наступні. Для дочірніх компонентів у циклі ключ обов'язковий.

wire:ignore - не чіпати вміст елемента взагалі:

<div wire:ignore>
    <div x-init="new Chart($el, ...)"></div>
</div>

Для сторонніх бібліотек (редактори, графіки, карти), які самі керують своїм DOM: інакше Livewire «виправить» їхні зміни назад до HTML з сервера. wire:ignore.self - ігнорувати атрибути самого елемента, але оновлювати дітей.

wire:replace - протилежне: не морфити, а замінити вміст повністю:

<div wire:replace>
    <json-viewer>@json($payload)</json-viewer>
</div>

Для веб-компонентів і елементів зі своїм внутрішнім станом, який треба скинути при кожному оновленні. wire:replace.self - замінити й сам елемент.

Типові симптоми й причини:

Симптом Причина
введене «переїжджає» в інше поле немає wire:key у циклі
сторонній віджет зникає чи ламається після дії немає wire:ignore
елемент зберігає старий стан, хоча дані змінилися морф перевикористав елемент - wire:key зі зміною ключа чи wire:replace
дочірній компонент не перестворюється той самий wire:key - змінити ключ, щоб Livewire створив новий екземпляр

Продуктивність: морфінг великого HTML (таблиця на тисячі рядків) коштує і на сервері (рендер), і в браузері (порівняння). Острівці, ліниві компоненти й пагінація зменшують обсяг, який морфиться за одну дію.

Докладніше в документації: Livewire: морфінг

Filament будує таблицю з Eloquent-запиту, тож усі звичайні проблеми продуктивності лишаються - але ховаються за декларативним синтаксисом.

N+1 у колонках. Колонка зі звʼязком робить запит на рядок:

TextColumn::make('company.name')   // + 1 запит на кожен рядок

Лікується модифікацією запиту таблиці:

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()->with(['company', 'technologies']);
}

Лічильники. ->counts('vacancies') замість завантаження всієї колекції заради count().

Дорогі обчислення в state(). Замикання виконується для кожного рядка, тож звернення до бази всередині - те саме N+1, лише написане інакше.

Сортування й пошук за звʼязком роблять join, і без індексу на зовнішньому ключі це помітно:

TextColumn::make('company.name')
    ->searchable()      // where по приєднаній таблиці
    ->sortable()

Що ще сповільнює адмінку:

  • ->paginated([10, 25, 50, 'all']) з опцією all на великій таблиці вивантажує все в памʼять одним кліком користувача.
  • Глобальний пошук по багатьох ресурсах виконує запит на кожен зареєстрований ресурс.
  • Мініатюри без кешу: ImageColumn, що генерує прев'ю на льоту, робить це для кожного рядка.
  • Опції Select без ->searchable() вивантажують усі записи довідника у форму.

Як шукати причину: Debugbar або Telescope на сторінці ресурсу покажуть кількість запитів. Таблиця на 25 рядків має вкладатися в одиниці запитів; десятки - ознака незавантаженого звʼязку.

Livewire відповідає за серверний стан і рендер, Alpine - за миттєву взаємодію в браузері без запитів: розкриття меню, перемикання вкладок, анімації.

$wire - JavaScript-представлення компонента, доступне в Alpine. Через нього читають і змінюють властивості, викликають методи:

<div x-data>
    <span x-text="$wire.count"></span>
    <button x-on:click="$wire.count++">+1 локально</button>
    <button x-on:click="$wire.save()">Зберегти на сервері</button>
</div>

Зміна $wire.count оновлює значення в браузері, а на сервер воно потрапить з наступним запитом. Для негайної синхронізації - $wire.$set('count', 5).

$wire.entangle() створює двосторонньо синхронізовану копію властивості в даних Alpine. Документація Livewire прямо не радить його для нового коду: дублювання стану робить поведінку менш передбачуваною й може шкодити продуктивності. Директива @entangle застаріла і ламається при видаленні елементів з DOM.

{{-- Замість entangle --}}
<div x-data="{ open: $wire.entangle('showDropdown') }">

{{-- Читайте властивість напряму --}}
<div x-data x-show="$wire.showDropdown">

Як розділяти відповідальність:

  • Суто візуальний стан (відкрите меню, активна вкладка) - лише в Alpine, сервер про нього не знає.
  • Дані, які зберігаються чи валідуються, - у Livewire.
  • Дія, що не потребує сервера, - $wire.$js або звичайна Alpine-логіка, а не запит.

Межа між ними прямо впливає на швидкодію: кожна дія через сервер - мережевий запит і рендер.

Докладніше в документації: Livewire і Alpine

Сервер не тримає компонент між запитами. Після кожного запиту Livewire дегідрує компонент - рендерить HTML і створює JSON-знімок стану, а на наступному запиті гідрує - відтворює компонент зі знімка.

Знімок вбудовується в HTML атрибутом wire:snapshot:

{
  "state": { "count": 1, "post": [null, { "class": "post", "key": 7, "s": "mdl" }] },
  "memo": { "name": "counter", "id": "1526456", "children": [], "errors": [] },
  "checksum": "9f2c..."
}
  • state - публічні властивості; складні типи - кортежами з метаданими (s - ключ синтезатора);
  • memo - назва компонента, id, дочірні компоненти, помилки валідації тощо;
  • checksum - HMAC-SHA256 від знімка з ключем APP_KEY.

Що захищає контрольна сума. На кожному запиті сервер перераховує HMAC і порівнює. Змінений вручну знімок (інший id моделі, інша назва компонента) дає CorruptComponentPayloadException. Після 10 невдалих перевірок з однієї IP-адреси за 10 хвилин Livewire блокує подальші спроби. Без знання APP_KEY підробити знімок неможливо - тому витік APP_KEY критичний і для Livewire.

Що вона НЕ захищає. Запит містить не лише знімок, а й оновлення (updates) - нові значення властивостей від wire:model, - і виклики методів. Вони не підписані: це звичайне введення користувача, яке накладається на відновлений компонент. Тому:

  • будь-яку незаблоковану публічну властивість можна встановити в будь-яке значення;
  • будь-який публічний метод можна викликати з будь-якими аргументами.

Захищають від цього #[Locked], автоматичний захист ідентифікаторів моделей, валідація і авторизація в діях.

Практичні наслідки:

  • зміна APP_KEY робить недійсними знімки на всіх відкритих сторінках - користувачі отримають помилку при наступній дії;
  • розмір знімка - частина кожного запиту й відповіді: великі масиви в публічних властивостях роздувають трафік в обидва боки;
  • секрети в публічних властивостях видно в HTML - підпис захищає від зміни, але не від читання.

Докладніше в документації: Гідрація

Кожен компонент оновлюється незалежно. Клік у дочірньому компоненті надсилає на сервер лише його знімок, і рендериться лише він. Тому дорогу частину сторінки можна «ізолювати» в окремому компоненті - її повільний запит до бази не впливатиме на оновлення решти.

Оновлення батька - менш очевидне. У запиті лише знімок батька. Коли сервер рендерить його шаблон і зустрічає дочірній компонент, він не рендерить дитину заново - для цього немає її знімка. Замість неї в HTML іде порожня заглушка з тим самим wire:id:

<div wire:id="456"></div>

На клієнті Livewire морфить DOM батька й пропускає заглушки - дочірні компоненти лишаються як були, зі своїм станом.

Як Livewire розуміє, що дитина вже існує. Кожен вкладений компонент має ключ - явний wire:key або згенерований. Батько зберігає список ключів дітей (memo.children). При рендері:

  • ключ є в списку - компонент уже існує, рендеримо заглушку;
  • ключа немає - створюємо новий компонент з mount();
  • ключ зник із шаблону - компонент видаляється.

Звідси способи керування:

  • примусово перестворити дитину - змінити її ключ:
<livewire:report-chart :$filters :wire:key="'chart-'.md5(json_encode($filters))" />

Змінилися фільтри - новий ключ - новий компонент з новими параметрами в mount();

  • стабільні ключі в циклах (:wire:key="$item->id", а не індекс масиву) - інакше після видалення першого елемента стан компонентів зсунеться на сусідні;
  • #[Reactive] - коли дитина має оновлюватися разом з батьком без перестворення.

Наслідки для продуктивності:

  • плюс: оновлення батька не тягне рендер десятків дітей;
  • мінус: синхронізація між компонентами потребує подій, реактивних параметрів чи $parent, а #[Reactive] на багатьох дітях повертає витрати назад - кожна змінена дитина рендериться в тому самому запиті;
  • якщо компонент виділено лише для ізоляції рендеру, острівець (@island) дає те саме без окремого стану й комунікації.

Докладніше в документації: Як працює вкладення

Публічна властивість з об'єктом власного класу (DTO, value object) за замовчуванням не працює: Livewire не знає, як перетворити його на JSON і назад. Для цього є два механізми.

1. Wireable - простий спосіб для власних класів. Клас сам описує свою серіалізацію:

use Livewire\Wireable;

final class Address implements Wireable
{
    public function __construct(
        public string $city = '',
        public string $street = '',
    ) {}

    public function toLivewire(): array
    {
        return ['city' => $this->city, 'street' => $this->street];
    }

    public static function fromLivewire($value): static
    {
        return new static($value['city'], $value['street']);
    }
}
public Address $address;
<input wire:model="address.city">

2. Синтезатор - для типів, які не можна змінити (класи з бібліотек) або коли потрібен повний контроль:

use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;

final class MoneySynth extends Synth
{
    public static $key = 'money';

    public static function match($target): bool
    {
        return $target instanceof Money;
    }

    public function dehydrate($target): array
    {
        return [['amount' => $target->amount, 'currency' => $target->currency], []];
    }

    public function hydrate($value): Money
    {
        return new Money($value['amount'], $value['currency']);
    }
}

// AppServiceProvider::boot()
Livewire::propertySynthesizer(MoneySynth::class);
  • $key - позначка в знімку: значення зберігається кортежем [дані, { s: 'money' }];
  • match() - чи підходить синтезатор для значення під час дегідрації;
  • dehydrate() / hydrate() - перетворення в обидва боки;
  • методи get() і set() - якщо на вкладені частини значення має працювати wire:model (money.amount).

Так само влаштовані вбудовані типи: Stringable, моделі, колекції, дати, енуми - це синтезатори всередині Livewire.

Що враховувати:

  • усе, що повертає dehydrate/toLivewire, видно в браузері - не кладіть туди секретів;
  • гідровані дані - введення користувача: значення можна змінити в запиті, тож fromLivewire/hydrate мають валідувати, а не довіряти;
  • розмір - великий об'єкт їде в кожному запиті й відповіді;
  • часто простіше тримати примітиви ($city, $street) і збирати об'єкт у методі - власний тип потрібен, коли він справді спрощує код.

Докладніше в документації: Синтезатори

Livewire::test() проганяє компонент через той самий цикл, що й у браузері: mount, гідрація, оновлення властивостей, виклик дій, рендер. Тому тести перевіряють реальну поведінку, а не окремі методи класу.

use Livewire\Livewire;

it('creates a post', function () {
    $user = User::factory()->create();

    Livewire::actingAs($user)
        ->test('post.create')
        ->set('title', 'Перший пост')
        ->set('content', 'Текст')
        ->call('save')
        ->assertHasNoErrors()
        ->assertRedirect(route('posts.index'));

    expect(Post::where('title', 'Перший пост')->exists())->toBeTrue();
});

Що перевіряти в першу чергу:

1. Авторизація - найважливіше, бо будь-який публічний метод можна викликати з браузера:

Livewire::actingAs($stranger)
    ->test('post.edit', ['post' => $post])
    ->set('title', 'Зламано')
    ->call('save')
    ->assertForbidden();

2. Валідація:

->set('title', 'ab')->call('save')->assertHasErrors(['title' => ['min:3']]);

3. Події й побічні ефекти: assertDispatched('post-created'), assertRedirect(), зміни в базі.

4. Стан і вивід: assertSet('count', 1), assertSee('Збережено'), assertCount('items', 3).

5. «Димові» тести сторінок: $this->get('/posts/create')->assertSeeLivewire('post.create') - дешево і ловить зламані сторінки.

Корисні можливості:

  • Livewire::withQueryParams(['search' => 'php']) - для властивостей з #[Url];
  • Livewire::withoutLazyLoading() - тестувати лінивий компонент так, ніби він завантажився одразу;
  • assertRenderSkipped() - для дій з #[Renderless];
  • тест поруч з компонентом: make:livewire post.create --test створює ⚡create.test.php поряд (у Pest треба додати resources/views у шляхи тестів);
  • браузерні тести Pest 4+ (visit()->click()->assertSee()) - для того, що залежить від JavaScript: Alpine, wire:navigate, завантаження файлів.

Типові помилки:

  • тестувати лише «щасливий шлях» і не перевіряти, що чужий користувач не може викликати дію;
  • set() властивості, яку в реальному інтерфейсі не можна змінити, і робити висновки з цього, - але саме так поводиться зловмисник, тож це корисна перевірка #[Locked];
  • перевіряти внутрішні деталі (приватні методи) замість поведінки через call() і assert*.

Докладніше в документації: Тестування

Компонент з трейтом WithFileUploads приймає файли через звичайний wire:model:

use Livewire\WithFileUploads;

new class extends Component {
    use WithFileUploads;

    #[Validate('image|max:2048')]   // 2 МБ
    public $photo;

    public function save(): void
    {
        $this->validate();
        $path = $this->photo->store(path: 'avatars', options: 'public');
        auth()->user()->update(['avatar_path' => $path]);
    }
};
<input type="file" wire:model="photo">
@if ($photo) <img src="{{ $photo->temporaryUrl() }}"> @endif

Як це працює під капотом:

  1. при виборі файлу JavaScript запитує в компонента підписаний URL для завантаження;
  2. файл завантажується за ним у тимчасовий каталог (livewire-tmp/ на диску за замовчуванням);
  3. властивість $photo отримує об'єкт тимчасового файлу (TemporaryUploadedFile);
  4. у вашому методі файл перевіряється й переноситься на постійне місце.

Ризики й захист:

  • глобальна валідація тимчасових завантажень - за замовчуванням file|max:12288 (12 МБ). Тобто ще до вашої валідації сервер приймає будь-який файл до 12 МБ. Звузьте в config/livewire.php (temporary_file_upload.rules), якщо застосунку не потрібні великі файли;
  • обмеження частоти - ендпойнт завантаження за замовчуванням має throttle-middleware; його можна змінити (temporary_file_upload.middleware). Але в Livewire 4 файли понад 1 МБ на локальному диску йдуть частинами (chunked upload) - один файл стає десятками швидких запитів. Надто жорсткий ліміт на кшталт throttle:60,1 ламає великі завантаження;
  • розмір файлу обмежує ваше правило max:, а не upload_max_filesize PHP: частини збираються на сервері, і перерване завантаження продовжується з місця зупинки;
  • перевірка типу - лише через правила валідації (image, mimes:pdf), які перевіряють вміст, а не розширення. Ніколи не зберігайте з оригінальною назвою від користувача без перевірки - використовуйте згенеровану (store() робить саме так);
  • попередній перегляд (temporaryUrl()) працює лише для зображень і через підписаний URL - чужі файли так не подивитися;
  • очищення - на локальному диску Livewire сам видаляє старі тимчасові файли; для S3 треба налаштувати правило життєвого циклу (php artisan livewire:configure-s3-upload-cleanup, файли старші 24 годин);
  • публічність - зберігайте в приватний диск усе, що не призначене для всіх, і віддавайте через підписані URL.

Пряме завантаження в S3 (LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3) знімає навантаження з сервера застосунку: файл іде з браузера одразу в бакет. Але частина правил валідації потребує доступу до файлу - бакет має це дозволяти.

Пастка з назвою: upload - зарезервоване слово Livewire. Метод чи властивість з такою назвою зламають завантаження.

Тести: UploadedFile::fake()->image('avatar.jpg') з ->set('photo', $file) і Storage::fake().

Докладніше в документації: Завантаження файлів

Є три рівні складності - від простої обгортки до окремого Livewire-компонента.

1. Blade-компонент-обгортка над справжнім <input> - найчастіший випадок (мітка, поле, помилка в одному місці):

{{-- resources/views/components/input-text.blade.php --}}
@props(['name', 'label'])

<label>
    {{ $label }}
    <input type="text" name="{{ $name }}" {{ $attributes }}>
</label>
@error($name) <p class="text-red-600">{{ $message }}</p> @enderror
<x-input-text name="title" label="Заголовок" wire:model.live.blur="title" />

Секрет - {{ $attributes }}: wire:model з усіма модифікаторами переходить на справжнє поле. Для зручності з $attributes->wire('model') можна прочитати назву властивості й використати її для @error без окремого name.

2. Поле без нативного <input> на Alpine (лічильник, перемикач, вибір кольору) - x-modelable:

{{-- resources/views/components/input-counter.blade.php --}}
<div x-data="{ count: 0 }" x-modelable="count" {{ $attributes }}>
    <button type="button" x-on:click="count--">-</button>
    <span x-text="count"></span>
    <button type="button" x-on:click="count++">+</button>
</div>
<x-input-counter wire:model="quantity" />
<x-input-counter x-model="quantity" />   {{-- працює і в чистому Alpine --}}

x-modelable каже Alpine, яку змінну пов'язати з wire:model чи x-model на цьому елементі. Стан живе в браузері, сервер отримує значення разом з дією.

3. Окремий Livewire-компонент - #[Modelable], коли полю потрібна серверна логіка (пошук по базі для автодоповнення, завантаження варіантів):

use Livewire\Attributes\Modelable;

new class extends Component {
    #[Modelable]
    public ?int $value = null;

    public string $search = '';

    #[Computed]
    public function options() { /* пошук у базі за $search */ }
};
<livewire:user-picker wire:model="assigneeId" />

Що обрати: перший варіант - за замовчуванням; другий - для суто клієнтської взаємодії без запитів; третій - лише коли справді потрібен сервер, бо кожне поле-компонент додає власний стан, запити й складність синхронізації з батьком.

Пастки: забутий {{ $attributes }} (і wire:model мовчки не працює); type="button" на кнопках усередині форми; wire:key для таких полів у циклах.

Докладніше в документації: Атрибут Modelable

Автозбереження (як у Google Docs чи налаштуваннях) робиться хуком updated: поле відправляється на сервер при виході з нього й одразу записується в базу.

new class extends Component {
    public Post $post;

    #[Validate('required|max:255')]
    public string $title = '';

    #[Validate('required')]
    public string $content = '';

    public function mount(Post $post): void
    {
        $this->authorize('update', $post);
        $this->post = $post;
        $this->fill($post->only(['title', 'content']));
    }

    public function updated(string $property): void
    {
        if (! in_array($property, ['title', 'content'], true)) {
            return;
        }

        $this->authorize('update', $this->post);
        $this->post->update([$property => $this->{$property}]);
    }
};
<input wire:model.live.blur="title">
<textarea wire:model.live.blur="content"></textarea>
<span wire:dirty>Не збережено</span>

Правила з #[Validate] перевіряються до updated: невалідне значення не дійде до збереження.

Пастки:

1. Назва властивості - від клієнта. Варіант із документації, де updated($name, $value) робить update([$name => $value]), небезпечний без білого списку: клієнт може оновити будь-яку публічну властивість, і її назва стане назвою колонки в update(). Масове призначення з $fillable частково рятує, але явний перелік полів надійніший.

2. Авторизація на кожне збереження. Перевірка в mount() виконується один раз; права могли змінитися, а запит - бути підробленим.

3. Частково збережений стан. Поля зберігаються по одному. Якщо бізнес-правило охоплює кілька полів («дата завершення після дати початку»), окреме збереження першого поля може записати неузгоджені дані. Для таких полів - звичайна кнопка «Зберегти» з валідацією всього разом.

4. Навантаження й побічні ефекти. Кожне поле - запит і UPDATE, а з ним спостерігачі моделі, події, індексація пошуку, журнал змін. Для «важких» моделей краще зберігати чернетку окремо чи з затримкою.

5. Конкурентне редагування. Дві вкладки чи два редактори перезаписують зміни одне одного без попередження. Потрібна перевірка версії (updated_at) чи блокування запису.

6. Невидимий зворотний зв'язок. Користувач має бачити «Збережено» / «Помилка збереження» - інакше він не знає, чи можна закривати сторінку. wire:dirty, wire:loading і повідомлення про помилку обов'язкові.

Докладніше в документації: Форми: збереження в реальному часі

Коли правила залежать від стану компонента чи потребують об'єктів правил Laravel, атрибутів #[Validate] замало - потрібен метод rules().

use Illuminate\Validation\Rule;

new class extends Component {
    public ?Product $product = null;

    public string $sku = '';
    public string $type = 'physical';
    public ?float $weight = null;

    /** @var list<array{name: string, qty: int}> */
    public array $items = [];

    protected function rules(): array
    {
        return [
            // унікальність, крім поточного запису при редагуванні
            'sku' => ['required', Rule::unique('products', 'sku')->ignore($this->product)],

            'type' => ['required', Rule::in(['physical', 'digital'])],

            // умовне правило: вага обов'язкова лише для фізичних товарів
            'weight' => [Rule::requiredIf($this->type === 'physical'), 'nullable', 'numeric', 'min:0'],

            // масив рядків
            'items' => ['array', 'min:1', 'max:50'],
            'items.*.name' => ['required', 'string', 'max:100'],
            'items.*.qty' => ['required', 'integer', 'min:1'],
        ];
    }

    protected function validationAttributes(): array
    {
        return ['items.*.qty' => 'кількість'];
    }
};

Шаблон з динамічними рядками:

@foreach ($items as $index => $item)
    <div wire:key="item-{{ $index }}">
        <input wire:model="items.{{ $index }}.name">
        @error("items.{$index}.name") <p>{{ $message }}</p> @enderror
    </div>
@endforeach
<button type="button" wire:click="addItem">Додати рядок</button>
public function addItem(): void
{
    if (count($this->items) < 50) {
        $this->items[] = ['name' => '', 'qty' => 1];
    }
}

Додавання через метод на сервері, а не через $set з браузера, дає змогу одразу обмежити кількість рядків.

Що варто знати:

  • rules() не перевіряється при введенні - лише в $this->validate(). Для перевірки в реальному часі - порожній #[Validate] над властивістю;
  • межі масиву обов'язкові (max:50): масив приходить з браузера, і без обмеження хтось надішле сто тисяч елементів;
  • ключ wire:key для рядків - бажано стабільний ідентифікатор, а не індекс, якщо рядки можна видаляти з середини списку;
  • помилки адресуються з індексом - items.3.qty;
  • messages() і validationAttributes() - власні тексти й назви полів; назви rules, messages, validationAttributes зарезервовані - не використовуйте їх для властивостей;
  • власні правила (php artisan make:rule) працюють так само, як у контролерах;
  • валідатор напряму: $this->withValidator(fn ($validator) => $validator->after(...)) - для перевірок, що охоплюють кілька полів.

Докладніше в документації: Валідація: правила в методі rules()

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

use Livewire\Attributes\Async;

new class extends Component {
    #[Async]
    public function trackClick(string $url): void
    {
        Analytics::track('external-link', ['url' => $url, 'user' => auth()->id()]);
    }
};
<a href="{{ $url }}" target="_blank" wire:click.async="trackClick('{{ $url }}')">Перейти</a>

Або точково в шаблоні: wire:click.async, wire:intersect.async.

Чому стан і асинхронність несумісні. Кожен запит стартує зі знімка стану, який браузер мав на момент відправки. Паралельні запити не бачать результатів одне одного:

#[Async]   // так не можна
public function increment(): void
{
    $this->count++;
}

П'ять швидких кліків - п'ять запитів, кожен починає з count = 0, кожен повертає 1. Лічильник показує 1 замість 5. Зміни губляться, а остаточний стан визначає відповідь, що прийшла останньою, - не обов'язково остання за часом дія.

Безпечні сценарії:

  • чисті побічні ефекти: аналітика, журнали, постановка джоби в чергу, сповіщення - дія не змінює властивостей, що відображаються;
  • дані для JavaScript: результат повертається в Alpine і зберігається там, а не в стані компонента:
<div x-data="{ suggestions: [] }">
    <input x-on:input.debounce="suggestions = await $wire.fetchSuggestions($event.target.value)">
</div>

Правило: якщо асинхронна дія присвоює значення публічній властивості, що впливає на шаблон, - це помилка дизайну.

Захист від гонитви на сервері асинхронність не скасовує: дві паралельні дії, що змінюють один запис у базі, потребують атомарних операцій (increment()), транзакцій чи блокувань, як і будь-які паралельні HTTP-запити.

Зв'язок з іншими механізмами:

  • з #[Renderless] - класичне поєднання для «запустив і забув»;
  • #[Isolate] - про групування між компонентами, а #[Async] - про чергу всередині компонента;
  • острівці теж ходять паралельно, і застереження про спільний стан для них те саме.

Докладніше в документації: Атрибут Async

Звичайна обчислювана властивість кешується лише в межах одного запиту. Для дорогих даних є кешування довше - через кеш Laravel.

persist: true - кеш для цього екземпляра компонента між його запитами (типово 3600 секунд):

#[Computed(persist: true, seconds: 600)]
public function stats()
{
    return Order::query()->where('shop_id', $this->shopId)->selectRaw('...')->first();
}

Користувач клацає фільтри, а важка статистика не перераховується на кожен клік.

cache: true - кеш спільний для всіх екземплярів компонента в застосунку:

#[Computed(cache: true, key: 'homepage-popular-posts', seconds: 300)]
public function popularPosts()
{
    return Post::popular()->limit(10)->get();
}

Одна й та сама головна сторінка для всіх - запит раз на п'ять хвилин замість кожного рендеру.

Головний ризик - cache: true з персональними даними:

#[Computed(cache: true)]   // помилка!
public function myOrders()
{
    return auth()->user()->orders()->latest()->get();
}

Ключ кешу не залежить від користувача. Перший, хто відкрив сторінку, «заповнить» кеш своїми замовленнями, а наступні побачать його дані. Це витік персональних даних, а не просто баг.

Правила:

  • cache: true - лише для даних, однакових для всіх (публічні списки, довідники, загальна статистика);
  • якщо дані залежать від користувача, мови, параметрів - ключ має їх містити (key: "orders-user-{$id}" неможливо в атрибуті, тож для таких випадків - звичайний Cache::remember у методі з явним ключем) або використовувати persist: true, прив'язаний до екземпляра;
  • інвалідація: кеш живе до закінчення терміну. Після зміни даних unset($this->stats) скидає лише кеш у межах запиту; для збережених значень потрібне явне очищення через кеш Laravel чи коротший термін;
  • серіалізація: у кеш потрапляють моделі й колекції - перевірте, що драйвер кешу й налаштування serializable_classes дозволяють такі об'єкти;
  • авторизація до кешу: перевірка прав має відбуватися до звернення до кешованого значення, а не всередині методу, який при влучанні в кеш не виконується.

Коли краще не використовувати ці опції: якщо запит швидкий (кілька мілісекунд) - звернення до кешу через мережу (Redis) може коштувати стільки ж, а складність і ризик застарілих даних додаються.

Докладніше в документації: Атрибут Computed

Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 41 Senior 36

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії