Питання на співбесіді з Livewire і Filament
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
113 питань
Валідація в реальному часі - перевірка поля під час заповнення форми, а не лише при відправці.
Що для цього потрібно:
- правила на властивості через
#[Validate]- Livewire перевіряє властивість при кожному оновленні з браузера; - оновлення має доходити до сервера -
wire:model.liveчиwire:model.live.blur.
#[Validate('required|email|unique:users,email')]
public string $email = '';
<input type="email" wire:model.live.blur="email">
@error('email') <p>{{ $message }}</p> @enderror
Користувач заповнив поле й перейшов до наступного - запит, перевірка, помилка з'являється одразу.
Чому не просто .live: перевірка на кожне натискання клавіші показує «Некоректна адреса», поки людина ще друкує, і створює запит на кожну паузу. Перевірка при виході з поля (.blur) - зручніша і дешевша. Виняток - поля, де миттєвий відгук корисний (сила пароля, доступність імені користувача).
Чому rules() не спрацьовує при введенні. Метод rules() застосовується лише при виклику $this->validate(). При оновленні властивості Livewire перевіряє тільки ті, що мають атрибут #[Validate]. Щоб і правила з rules() (наприклад, з об'єктами Rule::unique(), яких атрибут не дозволяє) перевірялися при введенні, додайте порожній атрибут:
#[Validate]
public string $email = '';
protected function rules(): array
{
return ['email' => ['required', 'email', Rule::unique('users')->ignore(auth()->id())]];
}
Вимкнути автоматичну перевірку для поля з атрибутом - #[Validate('required', onUpdate: false)].
Перевірити одне поле вручну: $this->validateOnly('email') у хуку updated.
Помилки в JavaScript - магічна властивість $errors:
<p wire:show="$errors.has('email')" wire:text="$errors.first('email')"></p>
Підсумкова перевірка обов'язкова: валідація в реальному часі - зручність. Перед збереженням $this->validate() все одно викликається, бо частину полів користувач міг не чіпати, а запит можна відправити й в обхід інтерфейсу.
Запит Livewire - не звичайний запит сторінки, а AJAX. HTTP-редирект у відповідь на нього браузер не виконає як перехід. Тому Livewire передає «команду перейти» в JavaScript, а той змінює сторінку.
Методи компонента:
public function save(): void
{
$post = $this->form->save();
session()->flash('status', 'Пост опубліковано');
$this->redirectRoute('posts.show', ['post' => $post]);
}
$this->redirect('/posts')- за адресою;$this->redirectRoute('posts.show', [...])- за назвою маршруту;$this->redirectAction([PostController::class, 'index'])- на дію контролера;$this->redirectIntended('/dashboard')- туди, куди користувач ішов до входу.
Повернути redirect()->route(...) з методу теж можна - Livewire розпізнає звичайну відповідь-редирект Laravel.
SPA-перехід замість повного завантаження:
$this->redirect('/posts', navigate: true);
$this->redirectRoute('posts.index', navigate: true);
Сторінка підвантажується як при wire:navigate: без перезавантаження скриптів і стилів, швидше.
Флеш-повідомлення працюють, як у звичайному Laravel, - session()->flash(), а на сторінці призначення:
@if (session('status'))
<div class="alert">{{ session('status') }}</div>
@endif
Повідомлення без редиректу. Якщо після збереження користувач лишається на тій самій сторінці, флеш-повідомлення з'явиться лише при наступному запиті - не одразу. Тут краще:
- подія в браузер, яку слухає компонент сповіщень (
$this->dispatch('notify', message: 'Збережено')); - властивість компонента з текстом повідомлення.
Пастки:
- код після редиректу виконується:
$this->redirect()лише записує намір, а не зупиняє метод. Якщо після нього є ще логіка, вона спрацює - за потреби додайтеreturn; - редирект на зовнішній домен з
navigate: trueне працює як SPA-перехід - для зовнішніх адрес звичайнийredirect(); - не редиректити за адресою з введення користувача без перевірки - відкритий редирект використовують для фішингу.
Після збереження форма, на якій користувач лишається (коментар, повідомлення в чаті, додавання рядка в список), має очиститися.
reset() повертає властивості до значень, оголошених у класі:
public string $body = '';
public ?int $rating = null;
public function addComment(): void
{
$this->validate();
$this->post->comments()->create($this->only(['body', 'rating']));
$this->reset('body', 'rating'); // лише ці поля
// $this->reset(); // усі публічні властивості
}
Обережно з reset() без аргументів: він скидає всі публічні властивості компонента, включно з тими, що встановлено в mount() (наприклад, $post), - і компонент «губить» свій контекст. Безпечніше явно перелічувати поля або тримати поля форми в об'єкті форми й скидати лише його: $this->form->reset().
pull() - отримати значення і одразу скинути:
$this->post->comments()->create($this->pull(['body', 'rating']));
resetExcept([...]) - скинути все, крім указаного.
Помилки валідації зберігаються окремо від значень:
$this->resetValidation(); // усі помилки
$this->resetValidation('body'); // помилку одного поля
$this->resetErrorBag(); // те саме, що resetValidation()
Типовий випадок - кнопка «Скасувати» в модальному вікні: скинути і поля, і помилки, щоб при наступному відкритті форма була чистою.
Що варто знати:
- типізована властивість без значення за замовчуванням (
public string $title;) післяreset()стає неініціалізованою, і звернення до неї в шаблоні кине помилку. Задавайте значення за замовчуванням; reset()не викликає хуківupdated- це зміна на сервері, а не з браузера;- поля з
wire:modelбез.liveочищаються в інтерфейсі, бо після відповіді сервера Livewire морфить DOM з новими значеннями; - для полів, якими керує сторонній JavaScript (редактор тексту, вибір дати під
wire:ignore), скидання властивості не очистить сам віджет - йому треба надіслати подію.
Острівець - частина шаблону компонента, що оновлюється ізольовано: дія всередині острівця перерендерює лише його, а не весь компонент.
<div>
<h1>Дашборд</h1>
@island
<div>
Дохід: {{ $this->revenue }}
<button type="button" wire:click="$refresh">Оновити</button>
</div>
@endisland
<livewire:recent-orders />
</div>
Клік «Оновити» перерахує й перемалює лише блок доходу. Оскільки revenue - обчислювана властивість, дорогий запит виконується тільки при рендері острівця.
Можливості:
- ліниве завантаження частини компонента:
@island(lazy: true)чи@island(defer: true)з@placeholderусередині; - іменовані острівці і виклик з будь-якого місця компонента:
@island(name: 'stats') ... @endisland
<button wire:click="$refresh" wire:island="stats">Оновити статистику</button>
- дописування замість заміни - для «Завантажити ще» й нескінченної стрічки:
wire:island.append="feed"чи.prepend; always: true- острівець оновлюється разом з кожним рендером компонента;skip: true- не рендерити, доки не викличуть явно.
Важлива поведінка за замовчуванням: коли рендериться весь компонент, острівці пропускаються. Змінили фільтр поза острівцем - острівець, що від нього залежить, сам не оновиться (потрібен always: true або явний виклик за назвою).
Чим краще за дочірній компонент:
- немає окремого файлу, стану, параметрів і подій для синхронізації - острівець бачить властивості й методи компонента;
- не потрібен
#[Reactive]для передачі змін.
Коли все ж дочірній компонент:
- повторне використання в кількох місцях;
- власні хуки життєвого циклу, власна авторизація в
mount(); - складний ізольований стан.
Обмеження:
- острівці не можна ставити в
@foreach,@ifта інші керуючі конструкції - вони не мають доступу до змінних циклу чи умови; - запити острівців ідуть паралельно і змінюють той самий стан компонента: якщо кілька запитів у польоті одночасно, «перемагає» відповідь, що прийшла останньою. Дії, що змінюють спільні властивості, варто тримати поза паралельними острівцями.
Після кожної дії Livewire за замовчуванням рендерить шаблон компонента заново й відправляє HTML у браузер. Для дій, що нічого не змінюють на екрані, це зайва робота: виконання Blade, обчислювані властивості й запити в шаблоні, розмір відповіді, морфінг DOM.
#[Renderless] - дія без рендеру:
use Livewire\Attributes\Renderless;
new class extends Component {
public Post $post;
#[Renderless]
public function trackRead(): void
{
$this->post->increment('reads');
}
};
<div wire:intersect.once="trackRead">...</div>
Інші способи:
$this->skipRender()усередині методу - коли рендер треба пропустити за умовою:
public function saveDraft(): void
{
$this->post->update(['draft' => $this->draft]);
if (! $this->showSavedAt) {
$this->skipRender(); // показувати нічого не треба - рендер зайвий
}
}
- модифікатор
.renderlessу шаблоні - точково, без зміни класу:wire:click.renderless="trackClick",wire:model.live.renderless="draft".
Типові застосування:
- аналітика й лічильники (перегляди, кліки, час на сторінці);
- збереження чернетки чи налаштувань, які вже відображені в браузері (стан у полі чи в Alpine);
- дії, результат яких обробляє JavaScript (повернене значення з
await $wire.method()).
Пастки:
- зміни властивостей у такій дії не з'являться на екрані - шаблон не рендерився. Якщо дія змінює те, що показується, рендер потрібен;
- помилки валідації в дії без рендеру теж не відобразяться через
@error- для таких випадків потрібен рендер чи обробка в JavaScript; - стан компонента все одно оновлюється - новий знімок повертається в браузер, тож наступні дії бачитимуть змінені значення.
Поєднання з #[Async] - класична пара для «запустив і забув»: дія не стає в чергу за іншими й не перемальовує компонент.
Тести: ->call('trackRead')->assertRenderSkipped().
Черга дій у межах компонента. Livewire за замовчуванням виконує дії одного компонента послідовно: поки один запит у польоті, наступні дії чекають. Так стан лишається передбачуваним - кожна дія бачить результат попередньої.
Групування між компонентами (bundling). Якщо кілька компонентів сторінки оновлюються одночасно (подія, на яку реагують три компоненти, кілька опитувань), Livewire об'єднує їхні оновлення в один HTTP-запит. Менше з'єднань, менше навантаження, а також це основа для механізмів, що потребують узгодження компонентів (реактивні параметри, #[Modelable]).
Мінус групування: спільний запит відповідає тоді, коли закінчив найповільніший компонент. Повільний віджет з викликом зовнішнього API затримує оновлення решти сторінки.
#[Isolate] - запити компонента не групуються з іншими і йдуть паралельно:
use Livewire\Attributes\Isolate;
new #[Isolate] class extends Component {
public function refreshShipping(): void
{
$this->status = Carrier::trackParcel($this->trackingNumber); // 2-3 секунди
}
};
Коли ізолювати:
- дорогі операції (складні запити, зовнішні API, важкі обчислення);
- кілька компонентів з
wire:pollз різними інтервалами; - компоненти, що слухають ті самі події, де один повільний не повинен гальмувати інших;
- компонент не взаємодіє з іншими через реактивні параметри.
Ціна ізоляції - більше паралельних запитів і процесів PHP. Ізолювати все підряд - отримати десятки запитів замість одного.
Пов'язані механізми:
- ліниві компоненти ізольовані за замовчуванням - кожен вантажиться окремим паралельним запитом;
lazy.bundle/#[Lazy(bundle: true)]об'єднує їх; #[Async]/.async- не стосується групування між компонентами, а виводить дію з черги свого компонента: вона виконується одразу, паралельно з іншими діями;- Livewire 4 зробив опитування й
wire:model.liveнеблокувальними: швидке введення в полі пошуку не чекає завершення попереднього запиту.
Діагностика: вкладка Network у DevTools - видно, скільки запитів livewire/update іде, які компоненти в кожному (у тілі запиту - масив компонентів) і скільки триває кожен.
Звичайна пагінація Laravel перезавантажує сторінку на кожен перехід. У Livewire-компоненті перехід між сторінками відбувається без перезавантаження, а номер сторінки все одно записується в URL (?page=2).
use Livewire\Attributes\Computed;
use Livewire\WithPagination;
new class extends Component {
use WithPagination;
public string $search = '';
public function updatedSearch(): void
{
$this->resetPage();
}
#[Computed]
public function posts()
{
return Post::query()
->where('title', 'like', "%{$this->search}%")
->with('author')
->latest()
->paginate(20);
}
};
<input wire:model.live.debounce.300ms="search">
@foreach ($this->posts as $post)
<article wire:key="{{ $post->id }}">...</article>
@endforeach
{{ $this->posts->links() }}
Ключові моменти:
- трейт
WithPaginationобов'язковий - без нього посилання пагінації ведуть на звичайні URL з перезавантаженням; - пагінатор - в обчислюваній властивості чи в
render(), а не в публічній властивості: пагінатор не серіалізується як стан компонента; resetPage()при зміні фільтра - інакше користувач на 5-й сторінці, змінивши пошук, побачить порожню 5-ту сторінку нових результатів;- методи навігації:
setPage(3),nextPage(),previousPage().
Кілька пагінаторів на сторінці конфліктують за параметр page - потрібна власна назва:
return Invoice::paginate(10, pageName: 'invoices-page');
$this->resetPage(pageName: 'invoices-page');
Швидкість на великих таблицях:
paginate()виконує ще йCOUNT(*)по всьому набору - на мільйонах рядків це дорого;simplePaginate()- лише «Попередня / Наступна», без підрахунку;cursorPaginate()- курсор у URL замість номера сторінки, не сповільнюється на глибоких сторінках (немаєOFFSET), але не дає перейти на довільну сторінку.
Прокрутка після переходу. Посилання пагінації Livewire після кожного переходу прокручують до початку сторінки. Якщо список - не перший блок на сторінці, краще прокручувати до нього: {{ $this->posts->links(data: ['scrollTo' => '#posts']) }}, або вимкнути прокрутку ('scrollTo' => false).
Без номера сторінки в URL - трейт WithoutUrlPagination поряд з WithPagination (наприклад, для списку в модальному вікні).
Нескінченна стрічка замість сторінок - острівець з wire:island.append і wire:intersect на «маркері» внизу списку.
Події Livewire - це події браузера, тож їх бачать і Alpine, і будь-який JavaScript на сторінці.
З PHP у JavaScript. Подія, відправлена з компонента, доходить у браузер:
$this->dispatch('notify', message: 'Збережено', type: 'success');
{{-- Alpine-компонент сповіщень у макеті --}}
<div x-data="{ items: [] }"
x-on:notify.window="items.push($event.detail)">
...
</div>
// звичайний JavaScript
Livewire.on('notify', ({ message, type }) => showToast(message, type));
З JavaScript у Livewire. Компонент з #[On('cart-updated')] отримає подію, відправлену будь-звідки:
<button x-on:click="$dispatch('cart-updated', { count: 3 })">...</button>
Livewire.dispatch('cart-updated', { count: 3 });
Livewire.dispatchTo('cart-badge', 'cart-updated', { count: 3 });
У скрипті компонента (Livewire 4: тег <script> у шаблоні, this - це $wire):
<script>
this.$on('post-created', () => { /* ... */ });
this.$dispatchSelf('refresh');
</script>
Події браузера на корені компонента - wire:назва-події:
<div wire:custom-event="handle">
<button x-on:click="$dispatch('custom-event')">...</button> {{-- спливе до кореня --}}
</div>
<div wire:custom-event.window="handle"> {{-- подія з будь-якого місця сторінки --}}
Безпека - головне. Слухач #[On] - це метод, який будь-хто може викликати з консолі браузера з довільними параметрами: Livewire.dispatch('order-paid', { orderId: 5 }). Тож слухачі мають перевіряти права й дані так само, як дії. Подія order-paid, що сама по собі змінює статус замовлення, - діра.
Прибирання слухачів. Livewire.on() повертає функцію для відписки. В Alpine-компонентах, що живуть із wire:navigate, слухачі треба знімати в destroy(), інакше після кожного переходу вони накопичуватимуться:
Alpine.data('cartBadge', () => ({
off: null,
init() { this.off = Livewire.on('cart-updated', ({ count }) => (this.count = count)); },
destroy() { this.off?.(); },
}));
Трансляція з сервера (Echo) - слухачі виду #[On('echo:orders,OrderShipped')] отримують події WebSocket від Laravel Reverb чи Pusher без власного JavaScript.
При SPA-переході через wire:navigate вміст <body> замінюється новим. Але деякі елементи не повинні перестворюватися: аудіо- чи відеоплеєр, що грає, чат-віджет, бічна панель з прокручуванням.
@persist позначає елемент, який Livewire переносить на нову сторінку замість заміни:
{{-- resources/views/layouts/app.blade.php --}}
<body>
<main>{{ $slot }}</main>
@persist('player')
<audio src="{{ $episode->file }}" controls></audio>
@endpersist
@livewireScripts
</body>
Якщо на новій сторінці є @persist з тією самою назвою, Livewire бере існуючий DOM-елемент зі старої сторінки й ставить на місце нового. Відтворення не переривається, зберігаються стан Alpine, обробники подій і значення полів.
Умови:
- працює лише з
wire:navigate- при звичайному переході чи оновленні сторінки все завантажується заново; - елемент з тією самою назвою має бути на обох сторінках. Найпростіше - розмістити його в спільному макеті;
- розміщувати поза Livewire-компонентами сторінки, зазвичай прямо в макеті. Вміст, який має оновлюватися з сервера, не варто «заморожувати».
Прокрутка всередині збереженого елемента (довга бічна навігація) - wire:navigate:scroll:
@persist('sidebar')
<aside class="overflow-y-auto" wire:navigate:scroll>...</aside>
@endpersist
У Livewire 3 для цього був wire:scroll - при оновленні до v4 його треба перейменувати.
Підводні камені:
- збережений елемент не оновлюється. Якщо в ньому показано «Активний пункт меню» чи лічильник, після переходу вони лишаться старими. Активне посилання в збереженій навігації позначається через
data-current/wire:current- Livewire оновлює їх при навігації; - назва має бути унікальною й стабільною - різні елементи з однією назвою на різних сторінках підмінять один одного;
- персональні дані в збереженому елементі (наприклад, після виходу з облікового запису через
wire:navigate) переживуть перехід - вихід краще робити повним перезавантаженням сторінки.
Сторонні бібліотеки (редактори тексту, вибір дати, карти, графіки) самі змінюють DOM: додають елементи, класи, атрибути. Після наступного запиту Livewire морфить DOM за новим HTML із сервера - і затирає все, що побудувала бібліотека.
wire:ignore каже Livewire не чіпати вміст елемента при оновленнях:
<div wire:ignore>
<div x-data x-init="initEditor($el)"></div>
</div>
wire:ignore.self - ігнорувати зміни лише атрибутів самого елемента, а вміст оновлювати як звичайно.
Типовий шаблон інтеграції через Alpine:
<div wire:ignore
x-data="{ value: $wire.entangle('content') }"
x-init="
const editor = new Editor($refs.editor, { content: value });
editor.on('change', () => $wire.content = editor.getHTML());
">
<div x-ref="editor"></div>
</div>
Краще без entangle - напряму через $wire:
<div wire:ignore
x-data
x-init="
const picker = flatpickr($refs.input, {
defaultDate: $wire.date,
onChange: (dates, str) => $wire.date = str,
});
">
<input x-ref="input" type="text">
</div>
Що варто врахувати:
- напрям «сервер → бібліотека»: якщо сервер змінив значення (скинули форму), бібліотека під
wire:ignoreпро це не знає. Потрібна подія ($this->dispatch('editor-reset')) чи$wire.$watch('date', ...)в Alpine; - частота запитів:
$wire.content = ...лише змінює значення в браузері, на сервер воно піде з наступною дією.$wire.set('content', value)надсилає запит одразу - для редактора тексту це запит на кожне натискання клавіші; - знищення: бібліотеки, що підписуються на
window/document, мають прибиратися вdestroy()Alpine-компонента - інакше зwire:navigateслухачі накопичуються; wire:key, якщо таких полів кілька в циклі, - щоб при зміні списку бібліотека не опинилася «прив'язаною» до чужого рядка;- власне поле з
wire:model: обгортку можна оформити Blade-компонентом зx-modelable, тоді в шаблонах вистачить<x-date-picker wire:model="date" />.
Альтернатива - бібліотеки, створені для Livewire/Alpine (Flux, Alpine-плагіни), які вже вміють жити з морфінгом.
Livewire дає два способи виконати JavaScript, і вони вирішують різні задачі.
1. #[Js] - метод, що виконується в браузері без запиту. PHP-метод повертає рядок JavaScript; при виклику з шаблону сервер не задіюється:
use Livewire\Attributes\Js;
new class extends Component {
public string $title = '';
public string $content = '';
#[Js]
public function clearForm(): string
{
return <<<'JS'
$wire.title = ''
$wire.content = ''
JS;
}
};
<button type="button" wire:click="clearForm">Очистити</button>
Очищення полів відбувається миттєво. Код JavaScript потрапляє в сторінку під час рендеру, тож на клік не потрібен мережевий запит.
2. $this->js() - виконати JavaScript після дії на сервері:
public function save(): void
{
$this->form->save();
$this->js("document.getElementById('comment-form').reset()");
$this->js('$wire.$refresh()');
}
Код ставиться в чергу й виконується, коли відповідь прийде в браузер.
3. Скрипти в шаблоні (Livewire 4): тег <script> у шаблоні однофайлового чи багатофайлового компонента виконується з this = $wire, без обгортки @script:
<script>
this.$js.focusSearch = () => this.$refs.search.focus();
</script>
<input wire:ref="search">
<button type="button" wire:click="$js.focusSearch">Пошук</button>
Такі скрипти віддаються окремими кешованими файлами.
Коли що:
- суто клієнтська дія (очистити, згорнути, сфокусуватися) -
#[Js]чи$jsу скрипті компонента; - реакція на результат серверної дії (прокрутити до нового коментаря, закрити вікно, сповіщення) -
$this->js()або подія, яку слухає JavaScript; - переважно клієнтська логіка - Alpine, а не JavaScript, згенерований у PHP.
Пастки:
- не вставляйте дані користувача в рядок JavaScript -
$this->js("alert('{$this->name}')")з ім'ям'); stealCookies(); ('виконає чужий код. Дані передавайте через подію з параметрами чиJs::from($value)для безпечного екранування; - CSP: з увімкненим
csp_safeскладні вирази JavaScript у директивах обмежені - перевірте сумісність, якщо сайт використовує суворий Content Security Policy; #[Js]-метод не має доступу до PHP-стану на момент кліку - лише до того, що є в$wireу браузері.
Filament використовує політики моделей Laravel. Якщо для моделі ресурсу зареєстровано політику, Filament перевіряє її методи:
viewAny()- чи бачить користувач ресурс узагалі: без нього ресурс зникає з навігації, а сторінки віддають 403.view(),create(),update(),delete()- доступ до перегляду, створення, редагування й видалення конкретного запису.deleteAny(),forceDeleteAny(),restoreAny()- масові операції. Filament за замовчуванням не перевіряєdelete()для кожного запису окремо - це повільно. Якщо потрібно, у масової дії є->authorizeIndividualRecords().reorder()- зміна порядку рядків у таблиці.
class PostPolicy
{
public function viewAny(User $user): bool
{
return $user->hasRole('editor');
}
public function update(User $user, Post $post): bool
{
return $user->isAdmin() || $post->author_id === $user->id;
}
}
Що варто знати:
- Немає політики - немає обмежень: якщо політику не зареєстровано, Filament дозволяє все. Тому політика для кожної моделі в панелі - обов'язкова звичка.
- Перевірки повторюються на кожному Livewire-запиті, а не лише при відкритті сторінки: якщо доступ відібрали, наступна дія вже буде заборонена.
- Доступ до панелі визначає
canAccessPanel()на моделі користувача (інтерфейсFilamentUser). Без нього на проді в панель не пустить нікого. - Власні дії й сторінки політики ресурсу автоматично не покривають - їм потрібні
->authorize()чиcanAccess(). - Обмеження видимих рядків (редактор бачить лише свої пости) - через
getEloquentQuery()ресурсу, а не лише через політику.
Filament дає три основні способи працювати зі зв'язками, і вибір залежить від типу зв'язку та обсягу даних.
1. Select / CheckboxList з relationship() - вибрати наявні записи прямо у формі. Підходить для BelongsTo, MorphTo і BelongsToMany:
Select::make('author_id')
->relationship('author', 'name')
->searchable()
->preload();
Select::make('tags')
->multiple()
->relationship(titleAttribute: 'name'); // зберігається в pivot-таблицю
Зміни зберігаються разом з основною формою.
2. Repeater з relationship() - редагувати кілька дочірніх записів (HasMany) усередині форми батька: позиції замовлення, телефони контакту. Добре, коли записів небагато і вони не мають сенсу окремо від батька.
3. Relation manager - окрема інтерактивна таблиця під формою редагування чи перегляду. Підтримує HasMany, HasManyThrough, BelongsToMany, MorphMany, MorphToMany:
php artisan make:filament-relation-manager CategoryResource posts title
public static function getRelations(): array
{
return [PostsRelationManager::class];
}
У ньому є пошук, фільтри, пагінація, дії створення, редагування, AttachAction/DetachAction, AssociateAction.
Як обирати:
| Ситуація | Інструмент |
|---|---|
| вибрати одного автора чи кілька тегів | Select |
| 2-10 залежних рядків, що редагуються разом з батьком | Repeater |
| десятки й сотні пов'язаних записів, потрібні пошук і пагінація | relation manager |
Що варто знати про relation managers:
- вони завантажуються ліниво і зберігають зміни одразу, а не разом із формою батька;
- на сторінці перегляду вони за замовчуванням лише для читання;
- фільтр списку в модальному вікні
AttachAction- це лише відображення. Обмежити, які записи взагалі можна приєднати, треба черезrecordSelectOptionsQuery(), інакше підроблений запит приєднає будь-який запис.
Глобальний пошук - поле у верхній панелі, що шукає одразу по всіх ресурсах. Ресурс бере в ньому участь, якщо в нього задано атрибут-назву запису:
protected static ?string $recordTitleAttribute = 'title';
Пошук по кількох полях і зв'язках:
public static function getGloballySearchableAttributes(): array
{
return ['title', 'slug', 'author.name'];
}
Додаткові деталі під назвою результату:
public static function getGlobalSearchResultDetails(Model $record): array
{
return [
'Автор' => $record->author->name,
'Категорія' => $record->category->name,
];
}
Головна пастка - N+1. Деталі звертаються до зв'язків, і без жадібного завантаження кожен результат робить окремі запити. Зв'язки треба підвантажити в запиті пошуку:
public static function getGlobalSearchEloquentQuery(): Builder
{
return parent::getGlobalSearchEloquentQuery()->with(['author', 'category']);
}
Що ще впливає на швидкість:
- пошук будується на
LIKE '%...%'по кожному атрибуту - звичайний B-tree-індекс тут не допомагає. На великих таблицях варто обмежити кількість атрибутів і ресурсів у пошуку; - результатів на ресурс за замовчуванням не більше 50 (
$globalSearchResultsLimit); - ресурс, якому пошук не потрібен, вимикають
$isGloballySearchable = false; - ресурси без доступу (політика
viewAny) у пошук не потрапляють.
Зручності: гарячі клавіші для фокусу на полі пошуку задають у панелі через globalSearchKeyBindings(['command+k', 'ctrl+k']), а до результатів можна додати дії через getGlobalSearchResultActions().
Filament працює й без додаткових кроків, але кілька команд помітно впливають на швидкість і на те, чи взагалі відкриється панель на продакшені.
1. Кешування компонентів і іконок:
php artisan filament:optimize
Це скорочення для двох команд:
filament:cache-components- індексує ресурси, сторінки, віджети й relation managers уbootstrap/cache/filament, щоб не сканувати каталоги на кожному запиті;icons:cache- кешує Blade Icons, які Filament використовує всюди.
Скинути кеш - php artisan filament:optimize-clear.
Локально кеш компонентів вмикати не варто: нові ресурси й сторінки не з'являться, доки кеш не перебудувати.
2. Публікація ресурсів після оновлення пакета. У composer.json Laravel-проєкту з Filament зазвичай є скрипт:
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi",
"@php artisan filament:upgrade"
]
filament:upgrade оновлює опубліковані CSS і JavaScript Filament. Якщо ці файли не оновилися (наприклад, composer install запускався з --no-scripts), після оновлення пакета панель може зламатися через старі ресурси.
3. Звичайні оптимізації Laravel - config:cache, route:cache, view:cache - Filament підтримує.
4. Доступ. На продакшені користувач має реалізувати FilamentUser::canAccessPanel(), інакше всі отримають 403.
5. Черги. Імпорт і експорт, а також сповіщення в базу даних і через трансляцію працюють через черги. Без запущеного воркера імпорт «зависне», а сповіщення не з'являться.
6. Файли. FileUpload за замовчуванням зберігає файли з видимістю private. Якщо зображення мають бути публічними, потрібні ->visibility('public'), правильний APP_URL і storage:link для диска public.
Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Livewire 21 Filament 19 Дії, інфолисти й віджети 13 Продуктивність Livewire 12 Події, Alpine і навігація 12 Таблиці Filament 12 Форми й схеми Filament 12 Форми й валідація 12
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії