Питання на співбесіді з Livewire і Filament
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
113 питань
Інколи дія потрібна не для того, щоб змінити стан і перемалювати компонент, а щоб отримати дані для JavaScript: варіанти автодоповнення, точки для карти, дані для графіка. Атрибут #[Json] робить дію таким ендпойнтом.
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchCities(string $query): array
{
return City::query()
->where('name', 'like', "{$query}%")
->limit(10)
->get(['id', 'name'])
->toArray();
}
};
<div x-data="{ query: '', cities: [] }">
<input x-model="query" x-on:input.debounce.300ms="cities = await $wire.searchCities(query)">
<template x-for="city in cities" :key="city.id">
<button type="button" x-text="city.name" x-on:click="$wire.cityId = city.id"></button>
</template>
</div>
Що відрізняє #[Json]-дію:
- повернене значення приходить у JavaScript як результат Promise від
$wire.method(); - помилки валідації не потрапляють у
@error, а відхиляють Promise зі структурою{ status: 422, errors: { поле: [...] } }:
try {
const result = await $wire.save();
} catch (e) {
if (e.status === 422) showErrors(e.errors);
}
- компонент не перерендерюється заради результату - дані живуть в Alpine.
Навіщо це замість окремого API-маршруту:
- не потрібні маршрут, контролер, CSRF-налаштування й автентифікація для API - дія працює в контексті компонента з його станом (
$this->countryId) і поточним користувачем; - логіка лишається поруч із компонентом, який її використовує.
Що варто пам'ятати:
- це публічний ендпойнт: будь-хто з доступом до сторінки може викликати метод з довільними аргументами. Обмежуйте результат (
limit), перевіряйте права, не повертайте зайвих полів моделей (паролі, приховані атрибути -toArray()поважає$hidden, але явнийget(['id', 'name'])надійніший); - частота: debounce на введенні обов'язковий, інакше кожна літера - запит;
- паралельність: такі дії часто поєднують з
#[Async], щоб вони не стояли в черзі за іншими діями компонента. Але тоді результат має зберігатися лише в JavaScript, не у властивостях; - для великих обсягів даних (тисячі точок) варто подумати про окремий кешований ендпойнт - відповідь дії Livewire теж містить знімок стану компонента.
Кожна дія Livewire - це повний цикл на сервері: відтворення компонента зі знімка, хуки, дія, рендер шаблону, новий знімок. Повільно може бути на будь-якому етапі, тому спершу - виміряти.
Інструменти:
- DevTools → Network, запити
livewire/update: тривалість, розмір запиту й відповіді (великий знімок видно одразу), скільки компонентів у кожному запиті; - Laravel Debugbar / Telescope / Pulse - SQL-запити, їх кількість і дублікати на один запит Livewire;
- профайлер (Xdebug, Blackfire, SPX) - де саме йде час: гідрація, дія чи рендер Blade.
Найчастіші причини:
1. Моделі й колекції в публічних властивостях. Відновлюються з бази на кожному запиті, без завантажених зв'язків - і шаблон отримує N+1. Рішення: обчислювані властивості з with().
2. Важкі запити в render(), boot(), hydrate(). Виконуються на кожен клік, навіть коли дія їх не стосується. Рішення: #[Computed] (виконується лише при зверненні), острівці, кешування.
3. Перерендер усього компонента заради дрібниці. Перемикач «улюблене» в рядку таблиці на 200 рядків перебудовує всю таблицю. Рішення: острівець, #[Renderless] для дій без зміни вигляду, окремий компонент для рядка.
4. Зайві запити від wire:model.live. Запит на кожну паузу у введенні в кожному полі. Рішення: звичайний wire:model (дані підуть з відправкою форми), .blur, більший debounce.
5. Великий знімок стану. Масиви на тисячі елементів, вкладені структури в публічних властивостях їдуть в обидва боки з кожним запитом. Рішення: тримати в стані лише ідентифікатори й значення фільтрів.
6. Багато компонентів на сторінці. Сотні дрібних компонентів у циклі - сотні знімків у HTML і накладні витрати на кожен. Рішення: Blade-компоненти для статичних частин, Livewire лише там, де справді потрібна інтерактивність.
7. Повільний компонент гальмує інші. Групування запитів змушує чекати найповільнішого. Рішення: #[Isolate], lazy/defer.
8. Опитування. wire:poll з коротким інтервалом на популярній сторінці множить навантаження на кількість відкритих вкладок.
Не лише сервер: повільний морфінг величезного DOM (тисячі рядків) теж відчутний - тут допомагають пагінація й віртуалізація, а не оптимізація PHP.
Перевірка після виправлення - ті самі вимірювання: кількість SQL-запитів на одну дію і розмір відповіді мають помітно зменшитися.
Livewire 4 замінив хуки commit і request з v3 на інтерсептори трьох рівнів.
| Рівень | Що це | API |
|---|---|---|
| дія | виклик одного методу (save, $refresh) |
$wire.intercept(), Livewire.interceptAction() |
| повідомлення | оновлення одного компонента (може містити кілька дій і змін властивостей) | $wire.interceptMessage(), Livewire.interceptMessage() |
| запит | HTTP-запит (може містити повідомлення кількох компонентів) | $wire.interceptRequest(), Livewire.interceptRequest() |
Кожен інтерсептор повертає функцію відписки.
Дія - найточніший рівень:
<script>
this.intercept('delete', ({ action, onSuccess, onError }) => {
if (! confirm('Видалити?')) {
action.cancel();
return;
}
onSuccess(() => showToast('Видалено'));
onError(({ preventDefault }) => {
preventDefault(); // не показувати модальне вікно помилки
showToast('Не вдалося видалити', 'error');
});
});
</script>
Колбеки: onSend, onCancel, onSuccess (з поверненим значенням методу), onError (відповідь з помилкою сервера), onFailure (мережева помилка), onFinish.
Повідомлення - доступ до корисного навантаження й етапів застосування відповіді: onSend({ payload }) (знімок, оновлення, виклики), onSuccess з вкладеними onSync, onEffect, onMorphed, onRender. Корисно, щоб ініціалізувати сторонні бібліотеки після морфінгу DOM.
Запит - глобальні речі на рівні HTTP:
Livewire.interceptRequest(({ onError }) => {
onError(({ response, preventDefault }) => {
if (response.status === 419) { // сесія чи CSRF-токен застаріли
preventDefault();
if (confirm('Сесія завершилася. Оновити сторінку?')) location.reload();
}
});
});
Також onRedirect (скасувати редирект), onDump (перехопити вивід dd()), onResponse, onStream.
Типові застосування:
- глобальна обробка помилок - 419 (сесія), 403, 500 у вигляді власного повідомлення замість типового модального вікна з HTML помилки;
- індикатори завантаження для складних випадків, де
wire:loadingзамало; - аналітика й моніторинг - час відповіді, помилки, назви дій;
- додаткові заголовки запиту на рівні застосунку.
Порядок для успішного повідомлення: onSuccess → onSync → onEffect → onMorph → onMorphed → onFinish → onRender (у наступному кадрі анімації). Код, що читає оновлений DOM, - в onMorphed, а не в onSuccess.
Що варто пам'ятати: інтерсептори - клієнтський код. Скасування дії в інтерсепторі (action.cancel()) - це UX, а не захист: дію можна викликати й без нього.
Звичайна дія Livewire повертає результат один раз, наприкінці. Для відповіді чат-бота, що генерується 20 секунд, користувач бачив би порожній екран усі 20 секунд. wire:stream дає змогу відправляти частини відповіді в браузер під час виконання дії.
new class extends Component {
public string $prompt = '';
public string $question = '';
public string $answer = '';
public function submitPrompt(): void
{
$this->question = $this->prompt;
$this->prompt = '';
$this->answer = '';
$this->js('$wire.ask()'); // друга дія - вже після того, як поле очистилося
}
public function ask(): void
{
// streamAnswer() - генератор над будь-яким клієнтом LLM зі стримінгом
foreach ($this->streamAnswer($this->question) as $chunk) {
$this->answer .= $chunk;
$this->stream(to: 'answer', content: $chunk);
}
}
};
<p wire:stream="answer">{{ $answer }}</p>
<form wire:submit="submitPrompt">
<input wire:model="prompt" placeholder="Ваше питання">
</form>
Як це працює: відповідь на запит ask() стає потоковою: кожен $this->stream() одразу відправляє шматок у браузер, і Livewire дописує його в елемент з wire:stream="answer". Коли метод завершується, приходить звичайна фінальна відповідь з повним станом компонента.
Параметри stream():
to:- назва цілі (значенняwire:streamв шаблоні);content:- текст;replace: true- замінити вміст замість дописування (для індикатора прогресу «Оброблено 40%»).
Чому дві дії (submitPrompt + ask): перша швидко повертає відповідь - поле введення очищується, питання з'являється на екрані. Друга вже стрімить відповідь.
Що враховувати в продакшені:
- процес PHP зайнятий весь час стримінгу. Двадцять секунд генерації - двадцять секунд зайнятого воркера PHP-FPM. При багатьох одночасних користувачах це вичерпує пул; доречні Octane/FrankenPHP чи винесення генерації в чергу з трансляцією результату через Reverb;
- буферизація по дорозі: проксі (Nginx
proxy_buffering, CDN), стиснення відповідей можуть накопичувати шматки й віддавати все наприкінці. Потрібно вимкнути буферизацію для цих запитів; - тайм-аути:
max_execution_time, тайм-аути проксі й балансувальника мають покривати найдовшу генерацію; - зберігати повний результат у властивості (
$this->answer .= $chunk) - інакше після фінальної відповіді та наступного рендеру текст зникне; - XSS: потоковий вміст вставляється як текст, але якщо ви рендерите відповідь як Markdown/HTML, санітизуйте її на сервері.
Альтернативи: response()->eventStream() з SSE у звичайному маршруті Laravel, якщо компонентна модель не потрібна.
З wire:navigate браузер фактично не залишає першу сторінку: замінюється вміст <body>, а JavaScript-оточення живе далі. Код, написаний для звичайних переходів, починає поводитися інакше.
1. DOMContentLoaded спрацьовує лише один раз. Ініціалізація на ньому не виконається на наступних сторінках:
// було
document.addEventListener('DOMContentLoaded', initTooltips);
// стало - спрацьовує і при першому завантаженні, і після кожного переходу
document.addEventListener('livewire:navigated', initTooltips);
2. Скрипти в <head> виконуються один раз. Нові скрипти, яких не було на попередній сторінці, - виконуються при переході й блокують перехід, поки не завантажаться.
3. Скрипти в <body> виконуються на кожній сторінці заново. Ініціалізація, що має бути одноразовою (аналітика, глобальні обробники), отримує атрибут data-navigate-once. Інакше - подвійні події аналітики й подвійні обробники.
4. Накопичення слухачів. Обробник на window чи document, доданий на кожній сторінці, після десятка переходів спрацьовує десять разів. Знімати в destroy() Alpine чи використовувати { once: true } для одноразових.
5. Застарілі ассети після деплою. Користувач не перезавантажує сторінку годинами і працює зі старим JavaScript. Атрибут data-navigate-track на тегах ассетів змушує Livewire зробити повне перезавантаження, якщо змінився рядок запиту в URL ассета. Директива @vite додає його автоматично.
6. Мерехтіння теми. Тема (dark) застосовується скриптом після завантаження - при навігації між сторінками з різними класами <html> інтерфейс «блимає». Застосовуйте її в livewire:navigating через e.detail.onSwap() - до того, як новий HTML з'явиться на екрані.
7. Аналітика. Перегляд сторінки треба відправляти на livewire:navigated, інакше аналітика бачить лише першу сторінку сесії.
8. Стан сторонніх бібліотек. Каруселі, карти, редактори з першої сторінки лишаються в пам'яті, якщо їх не знищити, - витоки пам'яті за годину роботи.
Події життєвого циклу навігації:
livewire:navigate- перехід почався (можна скасуватиpreventDefault());livewire:navigating- новий HTML отримано, перед заміною (місце дляonSwap);livewire:navigated- усе завершено (також на першому завантаженні).
Коли варто відмовитися від wire:navigate для посилання: вихід з облікового запису, перемикання мови, переходи між різними макетами чи застосунками - там надійніше звичайне перезавантаження.
Livewire 4 має вбудоване сортування перетягуванням - без сторонніх бібліотек.
<ul wire:sort="reorder">
@foreach ($this->tasks as $task)
<li wire:key="{{ $task->id }}" wire:sort:item="{{ $task->id }}">
<span wire:sort:handle>⋮⋮</span>
{{ $task->title }}
</li>
@endforeach
</ul>
Коли користувач відпускає елемент, Livewire викликає метод з ідентифікатором елемента (wire:sort:item) і новою позицією (з нуля). wire:sort:handle обмежує перетягування «ручкою».
Між кількома списками (канбан-дошка) - однакова група і ідентифікатор контейнера, що стає третім параметром:
<ul wire:sort="moveCard" wire:sort:group="cards" wire:sort:group-id="{{ $column->id }}">
Збереження порядку - ваша відповідальність, і тут легко помилитися:
public function reorder(int $taskId, int $position): void
{
$this->authorize('update', $this->project);
DB::transaction(function () use ($taskId, $position) {
$ids = $this->project->tasks()->orderBy('position')->lockForUpdate()->pluck('id');
abort_unless($ids->contains($taskId), 404); // лише задачі цього проєкту
$position = max(0, min($position, $ids->count() - 1));
$ordered = $ids->reject(fn ($id) => $id === $taskId)->values();
$ordered->splice($position, 0, [$taskId]);
foreach ($ordered as $index => $id) {
Task::whereKey($id)->update(['position' => $index]);
}
});
unset($this->tasks);
}
Що тут важливо:
- ідентифікатор і позиція - від клієнта. Без перевірки, що задача належить проєкту користувача, можна пересунути чужу задачу. Пошук через зв'язок (
$this->project->tasks()) чиfindOrFailв області видимості власника обов'язковий; - межі позиції - від'ємна чи завелика позиція не повинна ламати нумерацію;
- перенумерація в транзакції з блокуванням - два користувачі, що одночасно сортують один список, інакше отримають дублікати позицій;
- для довгих списків оновлювати лише зсунутий діапазон (
increment/decrementдля позицій між старою й новою), а не всі рядки; wire:keyна елементах обов'язковий - після відповіді сервера морфінг має зіставити елементи за ідентифікатором, а не за позицією;- для переміщення між колонками перевіряти й колонку призначення (третій параметр) - вона теж може бути чужою.
Альтернатива перенумерації - дробові чи «розріджені» позиції (крок 1000, вставка посередині між сусідами): одне оновлення замість багатьох, з періодичною перенумерацією.
Multi-tenancy у Filament - це панель, де кожен користувач працює в межах «тенанта» (команди, компанії, організації), і бачить лише його дані.
public function panel(Panel $panel): Panel
{
return $panel->tenant(Team::class);
}
Модель користувача реалізує HasTenants: getTenants() повертає тенантів, до яких у нього є доступ, canAccessTenant() - чи можна відкрити конкретного. Поточний тенант - у URL панелі й доступний через Filament::getTenant().
Що Filament робить автоматично:
- Глобальний скоуп на запити ресурсів панелі: таблиця показує лише записи поточного тенанта, а чужий запис за URL дає 404.
- Прив'язка нових записів до тенанта при створенні через ресурс.
Де дані можуть протекти:
- Моделі без ресурсу в панелі скоуп не отримують. Запит до них у віджеті чи власній сторінці поверне дані всіх тенантів. Рішення - tenant-middleware, що додає глобальні скоупи, або явна фільтрація.
- Код поза панеллю (черги, команди, API, сервіс-провайдери) не знає поточного тенанта - там скоупу немає.
withoutGlobalScopes()без аргументів знімає й скоуп тенанта.- Валідація
uniqueіexistsу Laravel не використовує Eloquent-скоупи, тож «email уже зайнятий» перевіриться по всіх тенантах. Для повної ізоляції -scopedUnique()/scopedExists(). - Власні властивості Livewire-сторінок з моделями Filament повторно не запитує й не авторизує - їх треба перевіряти самостійно.
Висновок: вбудована tenancy - зручний інструмент, але не гарантія ізоляції. Документація Filament прямо попереджає: безпека реалізації - відповідальність розробника. Тести на «користувач тенанта A не бачить даних тенанта B» для кожного ресурсу й віджета - обов'язкові.
Filament 5 має вбудовану багатофакторну автентифікацію (MFA) з двома провайдерами: застосунок-автентифікатор (TOTP-коди, Google Authenticator, 1Password) і коди на email.
Застосунок-автентифікатор:
Schema::table('users', function (Blueprint $table) {
$table->text('app_authentication_secret')->nullable();
$table->text('app_authentication_recovery_codes')->nullable();
});
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery
{
use InteractsWithAppAuthentication;
use InteractsWithAppAuthenticationRecovery;
}
use Filament\Auth\MultiFactor\App\AppAuthentication;
$panel->multiFactorAuthentication([
AppAuthentication::make()->recoverable(),
], isRequired: true);
Користувачі налаштовують MFA на сторінці профілю, тож у панелі має бути ввімкнено ->profile(). isRequired: true змушує налаштувати MFA перед роботою з панеллю. recoverable() дає 8 кодів відновлення на випадок втрати телефона.
Важливі деталі:
- трейт додає секрету каст
encryptedі ховає його із серіалізації. Секрет зашифровано ключемAPP_KEY: якщо змінити ключ безAPP_PREVIOUS_KEYS, усі налаштовані автентифікатори перестануть працювати; - коди відновлення одноразові - використаний код видаляється, а нові користувач генерує в профілі;
- перевірка MFA відбувається до входу в систему, тож окремий middleware на маршрути панелі не потрібен.
Обмеження, про які варто пам'ятати:
- MFA захищає лише вхід через панель. Якщо застосунок має власний вхід (сайт, API), користувач, авторизований там, відкриє панель без другого фактора, якщо MFA для нього вже налаштовано, але не вимагається повторно;
- email-коди слабші за застосунок: захищеність дорівнює захищеності поштової скриньки;
- адміністраторам із доступом до чутливих даних MFA варто робити обов'язковою, а не опційною.
Filament бере на себе багато перевірок, але не всі. Корисно чітко знати межу.
Що Filament робить сам:
- доступ до панелі - через
canAccessPanel()(на продакшені обов'язковий); - політики ресурсів -
viewAny,create,update,deleteперевіряються для сторінок і вбудованих дій; - приховані й вимкнені дії не можна викликати навіть підробленим запитом: дія, для якої
visible()повернувfalse, вважається вимкненою й не виконується на сервері; - вимкнені поля (
disabled()) не зберігаються, якщо явно не дозволитиsaved(); Selectза замовчуванням перевіряє, що обране значення є серед дозволених опцій;- HTML у
TextColumn/TextEntryзhtml()чиmarkdown()санітизується.
Що лишається на вас:
- власний Blade. Якщо виводите HTML з
RichEditorу своєму шаблоні - санітизуйте (str($html)->sanitizeHtml()) або використовуйтеRichContentRenderer. Санітайзер Filament пропускає атрибутиstyle, тож для недовіреного вмісту може знадобитися суворіший; - імпорт і експорт не перевіряють політики для кожного запису. Користувач з доступом до експорту отримає все, що поверне запит, - обмежуйте його через
modifyQueryUsing(); - фільтри списків - не межа безпеки. Звуження опцій у модальній таблиці (
tableSelect) лише відображення; дозволені записи задають запитом на кшталтrecordSelectOptionsQuery(); unique()не враховує глобальні скоупи й тенантів - для мультиорендних даних єscopedUnique();- файли.
preserveFilenames()на дискахlocal/publicвідкриває шлях до завантаження виконуваних файлів - краще випадкові імена йstoreFileNamesIn(); - CSV-формули. Значення, що починаються з
=,+,-,@, у файлах експорту чи «невдалих рядків» імпорту Excel може виконати як формулу.
Окремий нюанс - middleware. Панель збирає власний стек middleware і не використовує групу web застосунку. Middleware, додане в web (наприклад, заголовки CSP), на маршрутах панелі не спрацює - його треба реєструвати в $panel->middleware([...]).
Практичний підсумок: політика на кожну модель ресурсу, canAccessPanel() з перевіркою конкретної панелі, і обережність скрізь, де дані виходять за межі панелі: свої шаблони, файли, експорт.
Плагін панелі - клас, що реалізує інтерфейс Filament\Contracts\Plugin і вміє додати до панелі ресурси, сторінки, віджети, тему, render hooks. Так розповсюджують пакети (блог, журнал активності, ролі), а в проєкті так зручно ділити велику адмінку на модулі.
use Filament\Contracts\Plugin;
use Filament\Panel;
class BlogPlugin implements Plugin
{
protected bool $hasAuthorResource = true;
public static function make(): static
{
return app(static::class);
}
public function getId(): string
{
return 'acme-blog';
}
public function authorResource(bool $condition = true): static
{
$this->hasAuthorResource = $condition;
return $this;
}
public function register(Panel $panel): void
{
$panel
->resources(array_filter([
PostResource::class,
$this->hasAuthorResource ? AuthorResource::class : null,
]))
->pages([BlogSettings::class]);
}
public function boot(Panel $panel): void
{
// код, потрібний лише коли панель справді використовується
}
}
$panel->plugin(BlogPlugin::make()->authorResource(false));
register() проти boot():
register()викликається під час конфігурації панелі. Тут лише описують, що панель містить: ресурси, сторінки, віджети, налаштування. Жодної роботи з базою чи поточним користувачем;boot()виконується через middleware лише тоді, коли запит іде до цієї панелі. Тут доречна логіка, що має діяти тільки всередині панелі: наприклад, глобальні налаштування компонентів черезconfigureUsing(), які не повинні зачепити інші панелі чи решту сайту.
Що варто знати:
getId()має бути унікальним серед усіх плагінів - короткий загальний id на кшталтblogлегко конфліктує;- флюентна конфігурація (як
authorResource(false)) - звичний для користувачів плагіна спосіб налаштування; - плагін, підключений до кількох панелей, отримує окремий виклик з кожною панеллю, тож стан не повинен «перетікати» між ними;
- службовий провайдер пакета (міграції, переклади, view) - окремий від класу плагіна: плагін налаштовує панель, а провайдер - Laravel.
Альтернатива плагіну для невеликих змін - render hooks: вставити Blade-фрагмент у визначене місце панелі (PanelsRenderHook::BODY_END тощо) без власного класу.
QueryBuilder - фільтр, у якому користувач сам складає умови з груп «І» / «АБО» з необмеженою вкладеністю: «статус - оплачено І (сума > 1000 АБО клієнт - VIP)». Це рівень «розширеного пошуку» CRM, без жодного рядка SQL з боку користувача.
use Filament\QueryBuilder\Constraints\BooleanConstraint;
use Filament\QueryBuilder\Constraints\DateConstraint;
use Filament\QueryBuilder\Constraints\NumberConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator;
use Filament\QueryBuilder\Constraints\SelectConstraint;
use Filament\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\QueryBuilder;
->filters([
QueryBuilder::make()
->constraints([
TextConstraint::make('customer_name'),
NumberConstraint::make('total'),
BooleanConstraint::make('is_paid'),
DateConstraint::make('created_at'),
SelectConstraint::make('status')
->options(OrderStatus::class)
->multiple(),
RelationshipConstraint::make('tags')
->multiple()
->selectable(
IsRelatedToOperator::make()
->titleAttribute('name')
->searchable()
->multiple(),
),
NumberConstraint::make('items.qty')->integer(),
]),
], layout: FiltersLayout::AboveContent)
Що дає кожне обмеження: готовий набір операторів для свого типу - «містить», «починається з», «більше», «між датами», «є / немає пов'язаних записів» тощо. Можна перевизначити список операторів і написати власні обмеження й оператори.
Коли QueryBuilder доречний:
- аналітики й менеджери, яким потрібні довільні комбінації умов, а не заздалегідь передбачені фільтри;
- таблиці з десятками полів, де окремий фільтр на кожне перетворив би панель фільтрів на стіну.
Коли краще звичайні фільтри: для 3-5 типових сценаріїв («мої», «неоплачені», «за місяць») звичайні SelectFilter/TernaryFilter зрозуміліші і швидші в роботі.
Що варто врахувати:
- продуктивність. Користувач може зібрати умову, під яку немає жодного індексу:
LIKEпо тексту,ORміж різними колонками,whereHasпо великих зв'язках. На великих таблицях варто обмежити набір обмежень полями з індексами; - зв'язки перетворюються на
whereHas- підзапити, які можуть бути дорогими; - місце. Вкладені групи потребують простору - фільтри краще винести над таблицею (
FiltersLayout::AboveContent); - збереження: у поєднанні з
persistFiltersInSession()складний фільтр не доведеться збирати щоразу.
Таблиці Filament спочатку будувалися навколо Eloquent, але з методом records() джерелом рядків може бути будь-що: масив, колекція, відповідь стороннього API.
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Http;
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage, ?string $sortColumn, ?string $sortDirection, ?string $search): LengthAwarePaginator {
$response = Http::baseUrl('https://api.example.com')
->get('/invoices', [
'page' => $page,
'per_page' => $recordsPerPage,
'sort' => $sortColumn,
'direction' => $sortDirection,
'q' => $search,
])
->throw()
->json();
return new LengthAwarePaginator(
collect($response['data'])->keyBy('id'),
total: $response['total'],
perPage: $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('number')->searchable(),
TextColumn::make('amount')->money('UAH')->sortable(),
]);
}
Головна відмінність - усе вручну. Вбудовані пошук, сортування, фільтри й пагінація Filament генерують SQL. З власними даними їх немає кому виконати, тому параметри ін'єктуються в records():
$page,$recordsPerPage- для пагінації (повернутиLengthAwarePaginator);$sortColumn,$sortDirection- для сортування;$search- для пошуку;$filters- масив станів фільтрів.
Ключі масиву - ідентифікатори записів. Їх треба робити стабільними й унікальними (keyBy('id')): за ними Livewire відрізняє рядки між оновленнями, а дії отримують потрібний запис.
У колбеках колонок і дій запис - масив, а не модель: fn (array $record) => ....
Що врахувати:
- кожне оновлення таблиці (пошук, сторінка, сортування, polling) - новий HTTP-запит до API. Варто кешувати відповіді на короткий час і обробляти помилки, щоб недоступний API не ламав сторінку;
- ліміти й тайм-аути стороннього сервісу:
Http::timeout(), повтори, обмеження частоти; - дії (створення, редагування, видалення) теж пишуться вручну - через
action(), що викликає API; - авторизація лежить на вас: політики моделей тут не працюють;
- для невеликих статичних наборів (налаштування, довідники з конфіга) підходить простий масив без пагінації.
За замовчуванням масова дія завантажує всі вибрані моделі в пам'ять і передає Eloquent-колекцію в action(). Коли користувач натискає «вибрати все» на таблиці з 200 000 рядків, це легко перевищує ліміт пам'яті PHP.
Порційна обробка:
use Filament\Actions\BulkAction;
use Illuminate\Support\LazyCollection;
BulkAction::make('archive')
->chunkSelectedRecords(250)
->action(function (LazyCollection $records) {
$records->each->update(['archived_at' => now()]);
})
Записи підтягуються пачками, $records стає LazyCollection - пам'ять стала, а код майже не змінюється.
Без завантаження моделей взагалі:
use Illuminate\Support\Collection;
BulkAction::make('archive')
->fetchSelectedRecords(false)
->action(fn (Collection $records) => Order::whereKey($records)->update(['archived_at' => now()]))
Тут $records - лише ідентифікатори, а оновлення - один SQL-запит.
Ціна fetchSelectedRecords(false). Filament завантажує моделі не просто так:
- щоб перевірити політику для кожного запису (
authorizeIndividualRecords('update')); - щоб спрацювали події моделі (
updating,deleted, спостерігачі, журнал активності, очищення кешу, Scout-індексація).
Масовий update()/delete() через запит обходить і те, і те. Для DeleteBulkAction це означає: без deleting-подій не видаляться файли, не оновиться пошуковий індекс, не запишеться аудит.
Як обирати:
| Потреба | Підхід |
|---|---|
| потрібні політики й події, записів сотні | за замовчуванням |
| потрібні політики й події, записів тисячі | chunkSelectedRecords() |
| чиста операція над даними, подій немає | fetchSelectedRecords(false) |
| дуже довга операція | поставити джобу в чергу з ідентифікаторами |
Звіт про часткові невдачі. Якщо частина записів не обробилася, $action->reportBulkProcessingFailure() додасть причину в підсумкове сповіщення («3 з 50 замовлень не вдалося архівувати»), а authorizeIndividualRecords() так само повідомляє про записи, відкинуті політикою.
Довгі операції в HTTP-запиті ризикують тайм-аутом. Для них краще: дія ставить джобу (Bus::batch по пачках ідентифікаторів) і надсилає сповіщення в базу даних, коли все готово.
Сторінка списку ресурсу - Livewire-компонент, тож її тестують через livewire() з хелперами Filament.
use App\Filament\Resources\Orders\Pages\ListOrders;
use App\Models\Order;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
beforeEach(fn () => $this->actingAs(User::factory()->admin()->create()));
it('lists, searches and sorts orders', function () {
$orders = Order::factory()->count(5)->create();
livewire(ListOrders::class)
->assertCanSeeTableRecords($orders)
->searchTable($orders->first()->number)
->assertCanSeeTableRecords($orders->take(1))
->assertCanNotSeeTableRecords($orders->skip(1));
livewire(ListOrders::class)
->sortTable('total', 'desc')
->assertCanSeeTableRecords($orders->sortByDesc('total'), inOrder: true);
});
it('filters by status', function () {
$paid = Order::factory()->paid()->count(2)->create();
$new = Order::factory()->count(3)->create();
livewire(ListOrders::class)
->filterTable('status', 'paid')
->assertCanSeeTableRecords($paid)
->assertCanNotSeeTableRecords($new);
});
it('archives an order from the row action', function () {
$order = Order::factory()->create();
livewire(ListOrders::class)
->callAction(TestAction::make('archive')->table($order))
->assertNotified();
expect($order->fresh()->archived_at)->not->toBeNull();
});
Масові дії - спершу вибрати рядки:
livewire(ListOrders::class)
->selectTableRecords($orders->pluck('id')->all())
->callAction(TestAction::make('archive')->table()->bulk());
Пастки:
- автентифікація обов'язкова - без
actingAs()тест отримає редирект на вхід чи 403, і зовсім не те, що перевіряє; deferLoading()- таблиця з відкладеним завантаженням не містить рядків, доки не викликати->loadTable(). Без цьогоassertCanSeeTableRecordsпадає на, здавалося б, робочому коді;- збережений у сесії стан (фільтри, сортування) може перетікати між викликами
livewire()в одному тесті; - видимість дій для різних ролей варто тестувати окремо:
assertActionHidden(TestAction::make('delete')->table($order))для користувача без прав; - тестувати варто свою логіку (власні фільтри, дії, скоупи), а не те, що Filament уміє сортувати колонку.
Кожне live()-поле після зміни робить запит на сервер, і за замовчуванням перерендерюється весь Livewire-компонент - вся форма з усіма секціями, повторювачами, опціями селектів. На формі з сотнею полів це сотні мілісекунд на кожну зміну, і інтерфейс відчувається «важким».
1. Перемальовувати лише те, що залежить від зміни:
TextInput::make('name')
->live(onBlur: true)
->partiallyRenderComponentsAfterStateUpdated(['email']); // лише поле email
TextInput::make('quantity')
->live(debounce: 500)
->partiallyRenderAfterStateUpdated() // лише саме поле
->belowContent(fn (Get $get): string => 'Разом: ' . $get('quantity') * $get('price'));
2. Не рендерити взагалі, якщо потрібна лише серверна дія:
TextInput::make('search')
->live(debounce: 300)
->skipRenderAfterStateUpdated()
->afterStateUpdated(fn (?string $state) => /* записати в лог, кеш тощо */ null);
3. Перенести логіку в браузер - без запиту зовсім:
Select::make('role')
->options(['user' => 'Користувач', 'staff' => 'Персонал']);
Toggle::make('is_admin')
->hiddenJs(<<<'JS'
$get('role') !== 'staff'
JS);
TextInput::make('name')
->afterStateUpdatedJs(<<<'JS'
$set('slug', ($state ?? '').toLowerCase().replaceAll(' ', '-'))
JS);
hiddenJs(), visibleJs(), afterStateUpdatedJs() виконуються в Alpine на клієнті миттєво.
Безпека JS-варіантів: рядок з JavaScript виконується в браузері, тож ніколи не вставляйте в нього дані користувача конкатенацією - це XSS. Використовувати $state і $get() як значення безпечно.
4. Інші джерела повільності:
options()із запитом без кешу - перераховуються на кожному рендері. Для довгих списків -searchable()зgetSearchResultsUsing();Repeaterна сотні елементів - кожен рендериться повністю. Тут краще relation manager;- важкі замикання у
label(),helperText(),visible()- обчислюються на кожному рендері, зокрема запити в базу; preload()великих зв'язків.
Як шукати вузьке місце: вкладка Network - розмір і час відповіді запиту livewire/update; Laravel Debugbar чи Telescope - запити в базу під час одного оновлення форми.
Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Livewire 21 Filament 19 Дії, інфолисти й віджети 13 Продуктивність Livewire 12 Події, Alpine і навігація 12 Таблиці Filament 12 Форми й схеми Filament 12 Форми й валідація 12
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії