Питання на співбесіді з Livewire і Filament
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
113 питань
wire:navigate на посиланні перетворює звичайний перехід на SPA-навігацію: сторінка завантажується через fetch, а браузер не перезавантажується.
<nav>
<a href="/" wire:navigate>Головна</a>
<a href="/jobs" wire:navigate>Вакансії</a>
<a href="/posts" wire:navigate.hover>Блог</a>
</nav>
Що відбувається при кліку:
- Livewire перехоплює клік і запитує нову сторінку звичайним GET-запитом;
- замінює вміст
<body>і оновлює<head>(заголовок, мета-теги); - змінює адресу через History API - кнопки «Назад» і «Вперед» працюють;
- показує смугу прогресу вгорі сторінки.
Чому швидше: CSS і JavaScript не завантажуються й не виконуються заново, Alpine і Livewire не ініціалізуються з нуля. Сторінки відкриваються помітно швидше, і застосунок відчувається як SPA, хоча сервер віддає звичайний HTML.
Попереднє завантаження:
- за замовчуванням Livewire починає запит, щойно користувач натиснув кнопку миші, - до того, як він її відпустив;
.hover- починати завантаження після 60 мс наведення чи фокусу на посиланні. Ще швидше для користувача, але сервер отримує запити на сторінки, які можуть і не знадобитися.
Корисні можливості:
$this->redirect('/posts', navigate: true)- SPA-перехід після дії компонента;@persist('player')- елемент (аудіоплеєр, бічна панель) переживає перехід без перестворення;- активне посилання - атрибут
data-currentна посиланні поточної сторінки чи директиваwire:current="font-bold"; - анімація переходу -
wire:transition.navigateчерез View Transitions API браузера.
Що варто знати:
- сервер віддає повну сторінку, як звичайно, - маршрути, middleware, SEO не змінюються. Без JavaScript посилання працюють як звичайні;
- JavaScript сторінок треба адаптувати:
DOMContentLoadedспрацьовує лише на першому завантаженні - замість ньогоlivewire:navigated; - гостьові сторінки з кешуванням на CDN працюють з
wire:navigate, але залежать від того, що віддає кеш - варто перевірити, що кешований HTML не містить персональних даних.
Параметри дії передаються як аргументи виклику:
<button wire:click="addToCart({{ $product->id }}, 2)">У кошик</button>
<button wire:click="setStatus('archived')">В архів</button>
public function addToCart(int $productId, int $qty = 1): void
{
$product = Product::query()->published()->findOrFail($productId);
$this->cart->add($product, min($qty, 10));
}
Моделі й енуми як параметри. Якщо параметр типізований моделлю, Livewire знайде її за переданим ключем (як прив'язка моделей у маршрутах):
public function delete(Post $post): void
{
$this->authorize('delete', $post);
$post->delete();
}
Параметри - це введення користувача. У консолі браузера можна викликати $wire.addToCart(999, 100000). Тип перевіряє PHP, а межі, існування запису й права - лише ваш код.
Не тільки клік. Після wire: можна вказати будь-яку подію браузера:
<input wire:keydown.enter="search">
<input wire:keydown.escape="clear">
<div wire:mouseenter="loadPreview({{ $id }})">
<trix-editor wire:trix-change="setContent($event.target.value)">
Модифікатори (працюють через x-on Alpine):
| Модифікатор | Що робить |
|---|---|
.prevent |
preventDefault() |
.stop |
stopPropagation() |
.once |
спрацювати один раз |
.self |
лише якщо подія виникла на самому елементі |
.window, .document |
слухати на window / document |
.outside |
клік поза елементом |
.debounce.300ms, .throttle.500ms |
обмеження частоти |
.shift.enter, .ctrl, .escape |
клавіші й комбінації |
Модифікатори Livewire 4:
.async- виконати дію паралельно, не чекаючи черги;.renderless- без повторного рендеру;.preserve-scroll- зберегти позицію прокрутки після оновлення.
$event - об'єкт події браузера в виразі: wire:change="setColor($event.target.value)".
Магічні дії без методу: $refresh (перерендерити), $set('tab', 'settings') (встановити властивість), $toggle('open'), $dispatch(...), $parent.method().
Стан завантаження: елемент, що ініціював запит, автоматично отримує атрибут data-loading - кнопку легко стилізувати через Tailwind (data-loading:opacity-50).
Обидва способи показують чи ховають частину інтерфейсу, але працюють на різних рівнях.
@if - на сервері. Blade вирішує, чи потрапить розмітка в HTML взагалі:
@if ($showModal)
<div class="modal">...</div>
@endif
Щоб показати вікно, потрібен запит на сервер: змінити $showModal, перерендерити компонент, отримати новий HTML.
wire:show - у браузері. Елемент завжди є в DOM, а директива перемикає display: none за виразом:
<button type="button" x-on:click="$wire.showModal = true">Новий пост</button>
<div wire:show="showModal" class="modal">
<textarea wire:model="content"></textarea>
<button wire:click="save">Опублікувати</button>
</div>
Відкриття вікна - без запиту: $wire.showModal = true змінює значення в браузері, і wire:show реагує одразу. На сервер значення піде з наступною дією. Після save() на сервері можна виставити $this->showModal = false - і вікно закриється.
Коли wire:show:
- миттєве перемикання (модальні вікна, вкладки, розгортання деталей), де затримка запиту помітна;
- вміст дешевий і може бути в HTML завжди;
- потрібні анімації появи -
wire:showпоєднується зx-transitionAlpine, бо лише перемикаєdisplay.
Коли @if:
- вміст не повинен потрапити в HTML - дані, доступні лише певній ролі. Прихований
wire:showелемент усе одно є в коді сторінки й видний у DevTools; - вміст дорогий для рендеру (запити до бази в шаблоні) і потрібен рідко;
- великі блоки, які збільшують розмір відповіді на кожному рендері.
Пов'язані директиви:
wire:text="expression"- вставити текст виразу без запиту (лічильник символів, попередній перегляд);wire:cloak- сховати елемент до ініціалізації Livewire, щоб не «блимали» елементи, які мали бути приховані;- Alpine
x-show- те саме для стану, що живе лише в Alpine, без Livewire.
Пастка безпеки: wire:show - це про вигляд, а не про доступ. Кнопка «Видалити», прихована через wire:show, усе одно викликає метод, який можна викликати й без кнопки, - перевірка прав потрібна в методі.
Дія (Action) у Filament - кнопка з необов'язковим модальним вікном і логікою, що виконується після підтвердження. Дії живуть у заголовку сторінки, в рядках таблиці, у масових операціях.
use Filament\Actions\Action;
use Filament\Forms\Components\Select;
Action::make('updateAuthor')
->label('Змінити автора')
->schema([
Select::make('authorId')
->label('Автор')
->options(User::query()->pluck('name', 'id'))
->required(),
])
->action(function (array $data, Post $record): void {
$record->author()->associate($data['authorId']);
$record->save();
})
Як це працює:
schema()описує поля модального вікна. Дані з форми приходять у замиканняaction()масивом$data, вже провалідованими.- У замикання автоматично підставляються залежності за іменем:
$record(поточний запис у таблиці чи на сторінці),$data,$livewire. fillForm()заповнює форму наявними даними.
Корисні налаштування:
->requiresConfirmation()- просте підтвердження без форми (для видалення, архівування).->visible(fn (Post $record) => ...)/->authorize('update')- показувати дію лише тим, кому можна.->successNotificationTitle('Збережено')- сповіщення після виконання.
Важливо: дія - не лише кнопка. Її логіка виконується на сервері через Livewire, тож перевірку прав роблять у самій дії (authorize), а не лише приховуванням кнопки.
Панель у Filament - окрема адмінка зі своїм адресом, входом, навігацією й оформленням. Кожну описує клас-провайдер, який створює php artisan filament:install --panels (наприклад, app/Providers/Filament/AdminPanelProvider.php):
public function panel(Panel $panel): Panel
{
return $panel
->default()
->id('admin')
->path('admin')
->login()
->passwordReset()
->emailVerification()
->profile()
->colors(['primary' => Color::Indigo])
->brandName('Laravel Ukraine')
->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources')
->discoverPages(in: app_path('Filament/Pages'), for: 'App\Filament\Pages')
->middleware([/* стек web: сесія, CSRF, ... */])
->authMiddleware([Authenticate::class]);
}
Що тут задається:
| Метод | Що робить |
|---|---|
id() |
ідентифікатор панелі - потрібен, коли панелей кілька |
path() / domain() |
адреса: /admin чи окремий піддомен |
login(), registration(), passwordReset(), emailVerification(), profile() |
вбудовані сторінки автентифікації - вмикаються лише явно |
colors(), brandName(), brandLogo(), favicon() |
оформлення |
discoverResources() / resources([...]) |
які ресурси, сторінки й віджети підключити |
middleware() / authMiddleware() |
стек для всіх сторінок і для захищених |
spa(), topNavigation(), databaseNotifications() |
режим навігації без перезавантаження, верхнє меню, сповіщення |
registration() - обережно: публічна реєстрація в адмінку майже ніколи не потрібна. Без неї користувачів створюють сидером, командою make:filament-user чи з самої панелі.
Хто може зайти - вирішує не провайдер, а модель користувача. На продакшені Filament пускає лише тих, для кого canAccessPanel() повертає true:
class User extends Authenticatable implements FilamentUser
{
public function canAccessPanel(Panel $panel): bool
{
return $panel->getId() === 'admin' && $this->is_admin;
}
}
Без інтерфейсу FilamentUser локально заходить будь-хто з акаунтом, а на продакшені - ніхто (403).
Кілька панелей - кілька провайдерів з різними id() і path(): адмінка для команди й кабінет для клієнтів, кожна зі своїми ресурсами, authGuard() і правилами входу.
Типові помилки: провайдер не зареєстровано в bootstrap/providers.php (панель просто не відкривається), змішування ресурсів двох панелей через спільний каталог для discoverResources(), і забута перевірка в canAccessPanel() для другої панелі.
За замовчуванням у локальному оточенні (APP_ENV=local) будь-який користувач моделі User може увійти в панель. Це зручно для розробки, але на продакшені правило інше: доступ отримують лише ті, кому це явно дозволено. Якщо нічого не налаштувати, після деплою адміністратор бачить 403.
Рішення - контракт FilamentUser:
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable implements FilamentUser
{
public function canAccessPanel(Panel $panel): bool
{
if ($panel->getId() === 'admin') {
return $this->is_admin && $this->hasVerifiedEmail();
}
return true; // інші панелі, наприклад кабінет клієнта
}
}
Що важливо:
- метод отримує
$panel, тож для кількох панелей умови мають бути різними. Типова помилка -return true«на швидку руку», після чого будь-який зареєстрований користувач сайту потрапляє в адмінку; canAccessPanel()- лише вхідні двері. Що саме користувач може бачити й робити всередині, вирішують політики моделей (viewAny,update,delete) - Filament перевіряє їх для ресурсів автоматично;- перевірку краще будувати на ролі чи прапорці в базі, а не на списку email у коді;
- якщо на сайті є власний вхід поза панеллю, користувач, авторизований там, усе одно проходить через
canAccessPanel()при відкритті панелі.
Як перевірити до деплою: тестом з APP_ENV не local - зайти звичайним користувачем на /admin і переконатися, що відповідь 403, а адміністратором - 200.
Кожен ресурс і сторінка автоматично з'являються в боковому меню панелі. Вигляд пункту налаштовують статичними властивостями й методами ресурсу.
use BackedEnum;
use Filament\Support\Icons\Heroicon;
use UnitEnum;
class OrderResource extends Resource
{
protected static string | BackedEnum | null $navigationIcon = Heroicon::OutlinedShoppingBag;
protected static string | UnitEnum | null $navigationGroup = 'Магазин';
protected static ?int $navigationSort = 2;
protected static ?string $navigationLabel = 'Замовлення';
public static function getNavigationBadge(): ?string
{
return (string) static::getModel()::where('status', 'new')->count();
}
public static function getNavigationBadgeColor(): ?string
{
return 'warning';
}
}
Що тут відбувається:
- іконка - у Filament 5 зручно брати з enum
Heroicon(автодоповнення замість рядків на кшталтheroicon-o-...); - група - пункти з однаковою групою збираються під спільним заголовком. Групу можна задати рядком або enum, щоб не дублювати назву в різних ресурсах;
- порядок -
$navigationSortу межах групи; - бейдж - число чи короткий текст біля пункту, наприклад кількість нових замовлень.
Пастки:
- бейдж рахується на кожному завантаженні будь-якої сторінки панелі. Важкий
count()по великій таблиці сповільнює всю адмінку. Для таких випадків - індекс під умову або кешування значення на хвилину; $shouldRegisterNavigation = falseховає пункт меню, але не забороняє доступ до сторінки за URL. Обмежувати доступ треба політиками чиcanAccess();- для великої панелі групи можна згорнути або винести частину ресурсів у кластер (
make:filament-cluster) - окремий розділ зі своєю піднавігацією.
Таблиця Filament будується з Eloquent-запиту ресурсу, а колонки описують, що показати з кожного запису:
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->searchable()
->sortable(),
TextColumn::make('author.name')
->label('Автор')
->searchable(),
IconColumn::make('is_featured')
->boolean(),
TextColumn::make('created_at')
->dateTime('d.m.Y H:i')
->sortable(),
])
->defaultSort('created_at', 'desc');
}
Що тут відбувається:
TextColumn::make('title')- ім'я колонки збігається з атрибутом моделі;- крапкова нотація
author.name- значення зі зв'язку. Filament сам додає жадібне завантаженняauthor, тож N+1 тут немає; searchable()- у таблиці з'являється поле пошуку, і Filament додаєwhere ... likeпо цій колонці (зокрема крізь зв'язок);sortable()- клік по заголовку сортує запит.
Колонки з обчисленим значенням. Якщо колонка показує аксесор чи результат state(), її в базі немає, і пошук «у лоб» не спрацює. Треба вказати справжні колонки:
TextColumn::make('full_name')
->searchable(['first_name', 'last_name'])
Пастки:
- пошук через
LIKE '%...%'по великій таблиці повільний - краще робити пошуковими лише потрібні колонки, а для великих обсягів підключити Scout; state(fn ($record) => $record->orders()->sum('total'))виконує окремий запит на кожен рядок. Для агрегатів є спеціальні методи (counts(),sum()), що працюють одним запитом;- для довгого тексту -
limit(50)іwrap(), щоб таблиця не роз'їжджалася.
Фільтри описують у ->filters([...]). Кожен фільтр отримує Eloquent-запит і звужує його.
Основні типи:
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Database\Eloquent\Builder;
->filters([
// чекбокс: увімкнено - діє query()
Filter::make('is_featured')
->label('Лише рекомендовані')
->query(fn (Builder $query): Builder => $query->where('is_featured', true)),
// список значень, можна кілька
SelectFilter::make('status')
->options([
'draft' => 'Чернетка',
'published' => 'Опубліковано',
])
->multiple(),
// за зв'язком, з пошуком
SelectFilter::make('author')
->relationship('author', 'name')
->searchable()
->preload(),
// три стани: так / ні / усі
TernaryFilter::make('email_verified_at')
->nullable(),
// для моделей з SoftDeletes
TrashedFilter::make(),
])
Власний фільтр - будь-які поля форми плюс query() з масивом $data, наприклад діапазон дат з двох DatePicker.
Чому фільтр не діє одразу. За замовчуванням зміни фільтрів відкладені: користувач вибирає кілька значень і тисне «Застосувати» - таблиця перезавантажується один раз. Це економить запити. Щоб фільтри працювали миттєво:
$table->deferFilters(false);
Корисне:
->persistFiltersInSession()- фільтри зберігаються між відвідуваннями сторінки;->default()на фільтрі - активний одразу;filtersLayout: FiltersLayout::AboveContent- показати фільтри над таблицею, а не в випадному меню.
Пастка: query() фільтра загортається в окрему групу where (...), щоб orWhere не зламав інші фільтри. Тому прибрати глобальний скоуп (наприклад, SoftDeletingScope) з query() не вийде - для цього є baseQuery().
TextColumn уміє форматувати значення без зміни самих даних.
use Filament\Tables\Columns\TextColumn;
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'paid' => 'success',
'refunded' => 'danger',
default => 'warning',
}),
TextColumn::make('total')
->money('UAH'), // 1 250,00 ₴ з урахуванням локалі
TextColumn::make('price_cents')
->money('UAH', divideBy: 100), // ціни, що зберігаються в копійках
TextColumn::make('created_at')
->dateTime('d.m.Y H:i'),
TextColumn::make('last_login_at')
->since(), // «3 години тому»
TextColumn::make('title')
->description(fn (Post $record): string => $record->excerpt)
->limit(60),
Найчистіше рішення для статусів - enum. Якщо атрибут моделі кастується в enum, що реалізує HasLabel і HasColor, колонка з badge() сама бере підпис і колір:
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum OrderStatus: string implements HasLabel, HasColor
{
case New = 'new';
case Paid = 'paid';
public function getLabel(): string
{
return match ($this) {
self::New => 'Нове',
self::Paid => 'Оплачено',
};
}
public function getColor(): string
{
return match ($this) {
self::New => 'warning',
self::Paid => 'success',
};
}
}
Той самий enum потім працює в SelectFilter::make('status')->options(OrderStatus::class) і в полях форми. Підписи статусів живуть в одному місці, а не розкидані по match у різних ресурсах.
Пастки:
formatStateUsing()змінює лише відображення: пошук і сортування працюють по сирому значенню з бази;html()чиmarkdown()санітизують вміст, алеformatStateUsing(), що повертаєHtmlString, - ні: так легко вивести неекрановані дані користувача.
Масові дії (bulk actions) виконуються над кількома вибраними рядками. Щойно в таблиці з'являється хоча б одна така дія, біля кожного рядка з'являється чекбокс.
У Filament 5 їх кладуть у toolbarActions() (або headerActions()), часто згрупованими у випадне меню:
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Collection;
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
BulkAction::make('publish')
->label('Опублікувати')
->requiresConfirmation()
->action(fn (Collection $records) => $records->each->update(['status' => 'published']))
->deselectRecordsAfterCompletion(),
]),
])
$records - Eloquent-колекція вибраних моделей.
Дії над окремим рядком - окремо, у recordActions():
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
Корисні налаштування:
->checkIfRecordIsSelectableUsing(fn (Order $record): bool => $record->status !== 'archived')- деякі рядки вибрати не можна;->maxSelectableRecords(100)- обмеження кількості;->selectCurrentPageOnly()- не давати одним кліком вибрати всі сторінки.
Пастки:
$records->each->update(...)- окремий запит на кожен запис. Для сотень рядків це прийнятно, для десятків тисяч - ні (єchunkSelectedRecords()іfetchSelectedRecords(false));- права:
DeleteBulkActionу ресурсі перевіряє політикуdeleteAny. Для власних дій перевірку прав треба додати самому, наприкладauthorizeIndividualRecords('update')- тоді записи, які користувач змінювати не може, просто не потраплять у$records; - після дії вибір рядків за замовчуванням лишається -
deselectRecordsAfterCompletion()прибирає його.
У Filament 5 форми, infolists і макети - це схеми (Filament\Schemas\Schema). Поля форми живуть у Filament\Forms\Components, а макетні компоненти (Section, Grid, Tabs, Fieldset, Wizard) - у Filament\Schemas\Components.
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Основне')
->schema([
TextInput::make('title')->required()->maxLength(255),
TextInput::make('slug')->required(),
RichEditor::make('body')->columnSpanFull(),
])
->columns(2)
->columnSpanFull(),
Section::make('Публікація')
->schema([
Select::make('status')->options(PostStatus::class)->required(),
]),
]);
}
Сітка - головне, що треба розуміти:
- сторінки створення й редагування ресурсу за замовчуванням розкладають компоненти у дві колонки;
Section,Grid,Fieldset- теж звичайні елементи цієї сітки й займають одну колонку. Тому секція раптом виявляється на половину ширини;columnSpanFull()- розтягнути компонент на всю ширину;columns(2)на секції - внутрішня сітка для її полів;columnSpan(['md' => 2, 'xl' => 1])- різна ширина на різних екранах (брейкпоінти Tailwind).
Інші макетні компоненти:
Tabs- вкладки для довгих форм (стан можна зберігати в URL);Fieldset- рамка з підписом для невеликої групи полів;Grid- сітка без візуального оформлення;Wizard- покрокова форма.
Пастки при оновленні з v3: старі простори імен Filament\Forms\Components\Section чи Filament\Forms\Form у v5 не працюють - макети переїхали в Filament\Schemas\Components, а метод форми приймає Schema $schema і повертає $schema->components([...]).
Валідацію описують методами поля - це ті самі правила Laravel, але з підказками в інтерфейсі (зірочка обов'язкового поля, maxlength в інпуті).
use Filament\Forms\Components\TextInput;
TextInput::make('email')
->email()
->required()
->maxLength(255)
->unique(),
TextInput::make('price')
->numeric()
->minValue(0)
->rules(['decimal:0,2']), // будь-яке правило Laravel
TextInput::make('slug')
->required()
->regex('/^[a-z0-9-]+$/')
->validationMessages([
'regex' => 'Лише малі латинські літери, цифри й дефіс.',
]),
Валідація запускається під час відправки форми. Помилки показуються під полями, а збереження не відбувається.
unique() у ресурсі. Форма знає свою модель і поточний запис, тож на сторінці редагування unique() автоматично ігнорує запис, що редагується: email користувача не «конфліктує» сам із собою. Якщо це не потрібно - unique(ignoreRecord: false).
Пастка з unique(): правило Laravel звертається до таблиці напряму, оминаючи Eloquent. Тому:
- м'яко видалені записи теж вважаються зайнятими;
- мультиорендність не враховується - email буде «зайнятим», якщо він є в іншого тенанта.
Для цього є scopedUnique() - перевірка через модель з усіма глобальними скоупами, включно з SoftDeletes і тенантами.
Умовна валідація:
TextInput::make('password')
->password()
->required(fn (string $operation): bool => $operation === 'create');
$operation - create, edit або view.
Що варто знати:
- правила лише на клієнті не існує: усе перевіряється на сервері, тож підробити
maxlengthу DevTools не допоможе; - поле, яке не зберігається (
saved(false)), все одно валідується; - для повідомлень українською досить локалізації Laravel (
lang/uk/validation.php) - Filament використовує ті самі переклади.
Select з методом relationship() сам завантажує варіанти зі зв'язку і зберігає вибір.
BelongsTo (один автор):
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
Select::make('author_id')
->relationship('author', 'name')
->searchable()
->preload()
->createOptionForm([
TextInput::make('name')->required(),
TextInput::make('email')->email()->required(),
])
->required();
searchable()- пошук по варіантах (за замовчуванням по колонці-назві;searchable(['name', 'email'])- по кількох);preload()- завантажити варіанти разом зі сторінкою, а не під час введення. Добре для десятків записів, погано для десятків тисяч;createOptionForm()- кнопка «+», модальне вікно створення нового автора, який одразу стає вибраним.
BelongsToMany (кілька тегів) - додати multiple():
Select::make('tags')
->multiple()
->relationship(titleAttribute: 'name')
->preload();
Filament сам синхронізує pivot-таблицю під час збереження форми.
Звуження варіантів:
Select::make('category_id')
->relationship('category', 'name', fn (Builder $query) => $query->where('is_active', true));
Що Filament перевіряє сам. Для Select автоматично діє правило «значення має бути серед дозволених варіантів». Підставити в запиті id запису, якого немає у варіантах (наприклад, неактивну категорію), не вийде.
Пастки:
- без
searchable()на великій таблиціSelectнамагається вивантажити всі записи у список; - для рекурсивних зв'язків (
parent_id) потрібенrelationship(..., ignoreRecord: true), щоб запис не можна було зробити батьком самого себе; - якщо поле
multiple()зі зв'язком треба зробитиdisabled(), викликатиdisabled()слід доrelationship()- інакше вимкнене поле все одно збереже зв'язок.
FileUpload завантажує файл через Livewire у тимчасове сховище, а під час збереження форми переносить його на диск і записує в атрибут моделі шлях до файлу.
use Filament\Forms\Components\FileUpload;
FileUpload::make('cover')
->image()
->disk('public')
->directory('covers')
->visibility('public')
->maxSize(2048); // кілобайти
Чому зображення не відкривається - найчастіші причини:
- Видимість
privateза замовчуванням. Файли завантажуються з приватною видимістю, якщо диск неpublic. На S3 чи R2 без->visibility('public')пряме посилання на файл повертає 403. - Диск за замовчуванням - той, що в
FILESYSTEM_DISK, частоlocal. Файли з нього взагалі не віддаються вебсервером. - Немає
storage:linkдля дискаpublic. - Неправильний
APP_URL- URL файлів будується з нього, і превью в FilePond не завантажується.
Кілька файлів:
FileUpload::make('attachments')
->multiple()
->acceptedFileTypes(['application/pdf'])
->maxFiles(5);
Шляхи зберігаються JSON-масивом, тож атрибут моделі потребує касту array.
Безпека імен файлів. За замовчуванням Filament генерує випадкові імена - і це правильно. preserveFilenames() на дисках local/public небезпечний: користувач може завантажити файл з розширенням .php. Якщо оригінальна назва потрібна для показу, її зберігають окремо: ->storeFileNamesIn('attachment_names').
Пастки:
- старі файли не видаляються. Коли користувач замінює чи прибирає файл, попередній лишається на диску. Прибирати треба самостійно, наприклад у спостерігачі моделі;
- ліміт розміру обмежує ще й Livewire (
temporary_file_uploadуconfig/livewire.php, за замовчуванням 12 МБ) таupload_max_filesizeу PHP -maxSize()більший за ці значення не допоможе; acceptedFileTypes()перевіряє MIME-тип, а не розширення - покладатися лише на нього для безпеки не варто.
Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Livewire 21 Filament 19 Дії, інфолисти й віджети 13 Продуктивність Livewire 12 Події, Alpine і навігація 12 Таблиці Filament 12 Форми й схеми Filament 12 Форми й валідація 12
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії