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

Питання на співбесіді з 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>

Що відбувається при кліку:

  1. Livewire перехоплює клік і запитує нову сторінку звичайним GET-запитом;
  2. замінює вміст <body> і оновлює <head> (заголовок, мета-теги);
  3. змінює адресу через History API - кнопки «Назад» і «Вперед» працюють;
  4. показує смугу прогресу вгорі сторінки.

Чому швидше: 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).

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

Обидва способи показують чи ховають частину інтерфейсу, але працюють на різних рівнях.

@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-transition Alpine, бо лише перемикає display.

Коли @if:

  • вміст не повинен потрапити в HTML - дані, доступні лише певній ролі. Прихований wire:show елемент усе одно є в коді сторінки й видний у DevTools;
  • вміст дорогий для рендеру (запити до бази в шаблоні) і потрібен рідко;
  • великі блоки, які збільшують розмір відповіді на кожному рендері.

Пов'язані директиви:

  • wire:text="expression" - вставити текст виразу без запиту (лічильник символів, попередній перегляд);
  • wire:cloak - сховати елемент до ініціалізації Livewire, щоб не «блимали» елементи, які мали бути приховані;
  • Alpine x-show - те саме для стану, що живе лише в Alpine, без Livewire.

Пастка безпеки: wire:show - це про вигляд, а не про доступ. Кнопка «Видалити», прихована через 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() - інакше вимкнене поле все одно збереже зв'язок.

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

FileUpload завантажує файл через Livewire у тимчасове сховище, а під час збереження форми переносить його на диск і записує в атрибут моделі шлях до файлу.

use Filament\Forms\Components\FileUpload;

FileUpload::make('cover')
    ->image()
    ->disk('public')
    ->directory('covers')
    ->visibility('public')
    ->maxSize(2048);          // кілобайти

Чому зображення не відкривається - найчастіші причини:

  1. Видимість private за замовчуванням. Файли завантажуються з приватною видимістю, якщо диск не public. На S3 чи R2 без ->visibility('public') пряме посилання на файл повертає 403.
  2. Диск за замовчуванням - той, що в FILESYSTEM_DISK, часто local. Файли з нього взагалі не віддаються вебсервером.
  3. Немає storage:link для диска public.
  4. Неправильний 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 41 Senior 36

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