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

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

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

113 питань

Найчастіша причина повільної таблиці в адмінці - запит на кожен рядок для підрахунку пов'язаних записів. Filament має для цього методи, які додають агрегат в основний запит (через withCount, withSum тощо).

use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Eloquent\Builder;

// кількість
TextColumn::make('orders_count')
    ->counts('orders')
    ->sortable(),

// кількість зі скоупом
TextColumn::make('orders_count')
    ->counts(['orders' => fn (Builder $query) => $query->where('status', 'paid')]),

// чи є хоча б один
TextColumn::make('orders_exists')
    ->exists('orders'),

// сума, середнє, мінімум, максимум
TextColumn::make('orders_sum_total')
    ->sum('orders', 'total')
    ->money('UAH'),

TextColumn::make('reviews_avg_rating')
    ->avg('reviews', 'rating')
    ->numeric(decimalPlaces: 1),

Ім'я колонки обов'язково за конвенцією Laravel: {зв'язок}_count, {зв'язок}_exists, {зв'язок}_{функція}_{поле}. Саме під таким ім'ям Eloquent кладе результат у модель.

Чому не state():

// N+1: окремий SELECT COUNT на кожен рядок сторінки
TextColumn::make('orders')
    ->state(fn (Customer $record): int => $record->orders()->count()),

На сторінці з 50 рядками - 50 додаткових запитів. Агрегатні методи дають один запит з підзапитами, і по таких колонках ще й можна сортувати.

Інші джерела N+1 у таблицях:

  • крапкова нотація (author.name) Filament завантажує жадібно сам;
  • але зв'язки, до яких звертаються всередині колбеків description(), color(), url(), - ні. Їх треба підвантажити явно:
->modifyQueryUsing(fn (Builder $query) => $query->with(['author.media', 'category']))

Як ловити: Model::preventLazyLoading() у локальному оточенні - лінивий доступ до незавантаженого зв'язку кидає виняток, і проблема видна одразу, а не на продакшені.

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

Підсумки (summaries) - рядок під таблицею з агрегатами по колонках. Їх додають до колонки методом summarize():

use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\Summarizers\Average;
use Filament\Tables\Columns\Summarizers\Count;
use Filament\Tables\Columns\Summarizers\Range;
use Filament\Tables\Columns\Summarizers\Sum;
use Filament\Tables\Columns\TextColumn;
use Illuminate\Database\Query\Builder;

TextColumn::make('total')
    ->money('UAH')
    ->summarize([
        Sum::make()->money('UAH')->label('Разом'),
        Average::make()->money('UAH')->label('Середній чек'),
    ]),

TextColumn::make('created_at')
    ->date()
    ->summarize(Range::make()->minimalDateTimeDifference()),

IconColumn::make('is_paid')
    ->boolean()
    ->summarize(
        Count::make()->query(fn (Builder $query) => $query->where('is_paid', true))->label('Оплачено'),
    ),

Чотири вбудовані підсумовувачі: Sum, Average, Count, Range (мінімум-максимум, зокрема для дат). Власний - через Summarizer::make()->using(...).

Як вони рахуються:

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

Підсумки по групах. Якщо рядки згруповані (defaultGroup('status')), підсумок з'являється під кожною групою. А groupsOnly() ховає самі рядки, лишаючи тільки групи з підсумками - вийде простий звіт прямо в адмінці.

Пастки:

  • перша колонка таблиці не може мати підсумків - у ній виводиться підпис рядка підсумків;
  • підсумки працюють по колонці в базі. Для колонки з аксесором чи state() SQL-агрегат порахувати нема з чого;
  • кожен підсумок - окремий агрегатний запит на кожне оновлення таблиці. На великих таблицях без індексів під фільтри це відчутно;
  • гроші краще зберігати в копійках цілим числом і показувати через money(divideBy: 100), щоб сума не накопичувала похибку float.

Докладніше в документації: Підсумки таблиць

Групування показує рядки блоками зі спільним заголовком: замовлення за статусом, задачі за проєктом, події за днем.

Групування за замовчуванням:

$table->defaultGroup('status');

Вибір групування користувачем:

use Filament\Tables\Grouping\Group;

$table
    ->groups([
        'status',
        Group::make('author.name')
            ->label('Автор')
            ->collapsible(),
        Group::make('created_at')
            ->label('День')
            ->date(),
    ])
    ->defaultGroup('status');
  • за зв'язком - крапкова нотація (author.name);
  • за датою - date() групує по дню й ігнорує час, інакше кожна мітка часу стала б окремою групою;
  • collapsible() - групи можна згортати.

Заголовок і опис групи:

Group::make('status')
    ->getTitleFromRecordUsing(fn (Order $record): string => $record->status->getLabel())
    ->titlePrefixedWithLabel(false)

Для enum з HasLabel заголовок береться з підпису автоматично.

Як це працює. Filament сортує запит за полем групи і розбиває поточну сторінку на блоки. Тобто групування - це сортування плюс заголовки, а не GROUP BY. Звідси наслідки:

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

Поєднання з підсумками. Підсумовувачі колонок (summarize()) показують агрегат під кожною групою, а groupsOnly() ховає рядки й лишає тільки заголовки груп з підсумками - готовий звіт «скільки й на яку суму в кожному статусі».

Масовий вибір у групі: selectGroupsOnly() дозволяє масово вибирати рядки лише в межах однієї групи - корисно, коли масова дія має сенс тільки для однорідних записів.

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

Колонки, які можна ховати:

TextColumn::make('email')
    ->toggleable(),

TextColumn::make('id')
    ->toggleable(isToggledHiddenByDefault: true),   // прихована, але доступна

У таблиці з'являється менеджер колонок, де користувач вмикає й вимикає їх. reorderableColumns() дозволяє ще й міняти порядок.

Стан колонок зберігається в сесії за замовчуванням - після переходу на іншу сторінку й назад користувач бачить свій набір. Вимкнути: ->persistColumnsInSession(false).

Що ще можна зберігати в сесії (за замовчуванням вимкнено):

$table
    ->persistFiltersInSession()
    ->persistSortInSession()
    ->persistSearchInSession()
    ->persistColumnSearchesInSession();

Типовий сценарій: менеджер відфільтрував замовлення, відкрив одне, відредагував і повернувся до списку - фільтри на місці, а не скинуті.

Глобальні налаштування для всіх таблиць - у сервіс-провайдері:

use Filament\Tables\Table;

Table::configureUsing(function (Table $table): void {
    $table
        ->persistFiltersInSession()
        ->paginationPageOptions([10, 25, 50]);
});

Пастки й нюанси:

  • сесія і URL - різні речі. На сторінці списку ресурсу пошук, сортування, фільтри й групування й так синхронізуються з query string - таким посиланням можна поділитися. Сесія потрібна для іншого: щоб стан відновився, коли користувач повертається на сторінку без параметрів в адресі, наприклад через меню. У власних Livewire-компонентах з таблицею синхронізації з URL за замовчуванням немає;
  • кілька таблиць на сторінці (основна плюс relation managers чи віджети) мають власні ключі стану. Для власних Livewire-компонентів з кількома таблицями знадобиться queryStringIdentifier(), щоб параметри URL не конфліктували;
  • збережений фільтр може «загубити» записи: користувач відфільтрував, забув і через день думає, що даних немає. Помітні індикатори активних фільтрів і кнопка скидання тут дуже доречні;
  • приховані колонки не потрапляють у вивід, але запит для них не змінюється - важкі агрегати в прихованій колонці все одно виконуються, якщо додані через modifyQueryUsing.

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

За замовчуванням зміна поля не відправляє запит на сервер - стан піде разом із наступним запитом чи відправкою форми. Щоб форма перебудувалася одразу після зміни поля, його роблять live().

Залежний список:

use Filament\Forms\Components\Select;
use Filament\Schemas\Components\Utilities\Get;

Select::make('country_id')
    ->options(Country::query()->pluck('name', 'id'))
    ->live(),

Select::make('city_id')
    ->options(fn (Get $get): array => City::query()
        ->where('country_id', $get('country_id'))
        ->pluck('name', 'id')
        ->all())
    ->disabled(fn (Get $get): bool => blank($get('country_id'))),

Генерація slug із заголовка:

use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Utilities\Get;
use Filament\Schemas\Components\Utilities\Set;
use Illuminate\Support\Str;

TextInput::make('title')
    ->live(onBlur: true)
    ->afterStateUpdated(function (Get $get, Set $set, ?string $old, ?string $state) {
        if (($get('slug') ?? '') !== Str::slug($old)) {
            return;   // slug уже змінили вручну - не перезаписуємо
        }

        $set('slug', Str::slug($state));
    }),

TextInput::make('slug'),

Утиліти:

  • Get $get - прочитати значення іншого поля. Є й типізовані методи: $get->string('email'), $get->integer('qty'), $get->enum('status', Status::class);
  • Set $set - змінити значення іншого поля;
  • afterStateUpdated() - що зробити після зміни, з доступом до $state і $old.

Варіанти live():

  • live() - запит на кожну зміну (для select, чекбоксів);
  • live(onBlur: true) - коли поле втратило фокус (для тексту);
  • live(debounce: 500) - після паузи у введенні.

Пастки:

  • live() на текстовому полі без onBlur/debounce - запит на кожне натискання клавіші, і форма «підгальмовує»;
  • кожен такий запит перерендерює всю форму. Для великих форм є часткове оновлення (partiallyRenderComponentsAfterStateUpdated()) або логіка на JavaScript без запиту (afterStateUpdatedJs());
  • $set() не викликає afterStateUpdated() поля, яке змінює, якщо не передати shouldCallUpdatedHooks: true;
  • приховане поле (hidden()) не зберігається - якщо значення має потрапити в базу, ховати його треба інакше або задавати в обробнику збереження.

Докладніше в документації: Реактивність полів

Дегідратація - етап, на якому форма збирає дані полів у масив для збереження (getState()). На ньому можна перетворювати значення й виключати поля.

Перетворити значення поля:

use Filament\Forms\Components\TextInput;
use Illuminate\Support\Facades\Hash;

TextInput::make('password')
    ->password()
    ->dehydrateStateUsing(fn (string $state): string => Hash::make($state))
    ->saved(fn (?string $state): bool => filled($state))          // порожнє - не чіпати пароль
    ->required(fn (string $operation): bool => $operation === 'create');

(Якщо в моделі є каст 'password' => 'hashed', хешувати вручну не треба.)

Виключити поле зі збереження:

TextInput::make('password_confirmation')
    ->password()
    ->same('password')
    ->saved(false);

Поле валідується, але в масив даних не потрапляє.

Що не зберігається за замовчуванням:

  • вимкнені поля (disabled()) - щоб користувач не міг підмінити значення через Livewire. Явний saved() повертає збереження, але тоді значення знову контролює клієнт;
  • приховані поля (hidden()).

Додати чи змінити дані на рівні сторінки ресурсу:

// CreatePost
protected function mutateFormDataBeforeCreate(array $data): array
{
    $data['user_id'] = auth()->id();

    return $data;
}

// EditPost
protected function mutateFormDataBeforeSave(array $data): array
{
    $data['last_edited_by_id'] = auth()->id();

    return $data;
}

protected function mutateFormDataBeforeFill(array $data): array
{
    // підготувати дані запису перед заповненням форми
    return $data;
}

Для модальних дій (CreateAction, EditAction) те саме робить mutateDataUsing().

Що де робити:

  • перетворення одного поля - у самому полі (dehydrateStateUsing()), щоб правило жило поруч з полем;
  • дані, яких немає у формі (автор, тенант, хто редагував), - у mutateFormDataBeforeCreate/Save(). Ніколи не через приховане поле: його значення приходить з браузера;
  • повністю власне збереження - handleRecordCreation() / handleRecordUpdate() на сторінці.

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

Repeater - список однотипних елементів з однаковим набором полів: учасники, позиції, контакти.

use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;

Repeater::make('members')
    ->schema([
        TextInput::make('name')->required(),
        Select::make('role')->options(['member' => 'Учасник', 'owner' => 'Власник'])->required(),
    ])
    ->columns(2)
    ->minItems(1)
    ->maxItems(10);

Builder - список різнотипних блоків у довільному порядку. Класичний приклад - вміст сторінки з блоків «заголовок», «абзац», «зображення», «цитата»:

use Filament\Forms\Components\Builder;
use Filament\Forms\Components\Builder\Block;

Builder::make('content')
    ->blocks([
        Block::make('heading')->schema([TextInput::make('text')->required()]),
        Block::make('image')->schema([FileUpload::make('url')->image()]),
    ]);

Два способи зберігання:

1. JSON-колонка (за замовчуванням). Увесь масив зберігається в одну колонку, модель потребує касту array. Просто, але шукати й фільтрувати по вмісту незручно.

2. Зв'язок HasMany для Repeater:

Repeater::make('items')
    ->relationship()
    ->schema([...])
    ->orderColumn('sort');

Кожен елемент - окремий запис у таблиці. Перетягування для зміни порядку в режимі зв'язку працює лише з orderColumn().

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

  • distinct() на полі всередині - значення не повторюються між елементами (наприклад, «правильна відповідь» лише одна);
  • table([...]) - показати елементи Repeater таблицею замість карток;
  • itemLabel() - підпис згорнутого елемента за його вмістом.

Пастка з $get(). Усередині елемента $get('field') шукає поле в цьому ж елементі. Щоб дістатися поля зовні - $get('../client_id') (на рівень вище) чи $get('../../client_id').

Коли не варто. Якщо елементів сотні або з ними працюють окремо від батька (свої статуси, пошук), краще relation manager: Repeater рендерить усі елементи в одній формі, і велика кількість робить її повільною.

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

Wizard ділить довгу форму на кроки з чіткою послідовністю. Поля кожного кроку валідуються при переході далі - користувач не дізнається про помилку на першому кроці лише в самому кінці.

use Filament\Schemas\Components\Wizard;
use Filament\Schemas\Components\Wizard\Step;
use Filament\Support\Exceptions\Halt;

Wizard::make([
    Step::make('Компанія')
        ->schema([
            TextInput::make('company_name')->required(),
            TextInput::make('edrpou')->required()->length(8),
        ]),
    Step::make('Контакт')
        ->schema([
            TextInput::make('email')->email()->required(),
        ])
        ->afterValidation(function (Get $get) {
            if (Company::where('edrpou', $get('edrpou'))->exists()) {
                Notification::make()->danger()->title('Компанія вже зареєстрована')->send();

                throw new Halt();   // лишитися на поточному кроці
            }
        }),
    Step::make('Підтвердження')
        ->schema([
            Checkbox::make('terms')->accepted(),
        ]),
])
    ->persistStepInQueryString();

Що тут є:

  • afterValidation() / beforeValidation() - хуки кроку; виняток Halt не пускає на наступний крок;
  • persistStepInQueryString() - номер кроку в URL, оновлення сторінки не скидає користувача на початок;
  • skippable() - дозволити перескакувати кроки (для редагування, де дані вже заповнені);
  • submitAction() - кнопка відправки на останньому кроці.

У ресурсі Filament для сторінки створення є окремий підхід - трейт HasWizard на сторінці CreateRecord і метод getSteps(). Тоді кнопка «Створити» з'являється лише на останньому кроці. У модальному вікні дії wizard задають через ->steps([...]).

Пастки:

  • валідація кроку перевіряє лише його поля. Правила, що залежать від полів з інших кроків, варто ставити на останній крок або перевіряти ще раз під час збереження;
  • дані всіх кроків живуть у стані Livewire-компонента до відправки - якщо закрити вкладку, вони пропадуть. Для довгих анкет корисне збереження чернетки;
  • крок, прихований через hidden(), не валідується і не зберігається - зручно для умовних гілок, але легко пропустити обов'язкові дані.

Докладніше в документації: Майстри

CreateAction і EditAction відкривають модальне вікно з формою і зберігають запис. Кожен етап можна змінити.

Дані перед збереженням:

use Filament\Actions\CreateAction;

CreateAction::make()
    ->mutateDataUsing(function (array $data): array {
        $data['user_id'] = auth()->id();

        return $data;
    })

Для EditAction є ще mutateRecordDataUsing() - змінити дані запису перед заповненням форми.

Власний процес збереження:

use Illuminate\Database\Eloquent\Model;

CreateAction::make()
    ->using(function (array $data, string $model): Model {
        return app(CreateOrder::class)->handle($data);   // доменна дія замість $model::create()
    })

Хуки життєвого циклу:

CreateAction::make()
    ->beforeFormFilled(fn () => ...)
    ->afterFormValidated(fn () => ...)
    ->before(fn () => ...)          // перед збереженням
    ->after(fn (Model $record) => ...)   // після збереження

Зупинити процес:

use Filament\Notifications\Notification;

EditAction::make()
    ->before(function (EditAction $action, Order $record) {
        if ($record->isShipped()) {
            Notification::make()
                ->warning()
                ->title('Відправлене замовлення змінювати не можна')
                ->send();

            $action->halt();
        }
    })

halt() проти cancel():

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

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

  • сповіщення про успіх змінюють через successNotificationTitle() чи successNotification(), а редирект - через successRedirectUrl();
  • after() виконується після збереження, але в тому ж запиті. Довгі операції (листи, інтеграції) варто ставити в чергу, інакше вікно «зависне»;
  • сторінки ресурсу мають свої аналоги: mutateFormDataBeforeCreate(), beforeCreate(), afterCreate(), handleRecordCreation() на класі сторінки;
  • CreateAction має «Створити ще один» - для моделей, де це не має сенсу, вимикається createAnother(false).

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

Сповіщення в базі даних зберігаються в таблиці notifications Laravel і показуються в панелі в окремому вікні з дзвіночком - навіть якщо користувача не було онлайн у момент відправки.

Налаштування:

php artisan make:notifications-table
php artisan migrate
// панель
$panel
    ->databaseNotifications()
    ->databaseNotificationsPolling('30s');

Відправка:

use Filament\Actions\Action;
use Filament\Notifications\Notification;

Notification::make()
    ->title('Новий відгук на вакансію')
    ->body("{$candidate->name} відгукнувся на «{$vacancy->title}».")
    ->actions([
        Action::make('open')
            ->label('Відкрити')
            ->url(VacancyResource::getUrl('edit', ['record' => $vacancy]))
            ->markAsRead(),
    ])
    ->sendToDatabase($recruiter);

Можна і через звичайний клас сповіщення Laravel, повернувши з toDatabase() результат Notification::make()->...->getDatabaseMessage().

Чому сповіщення не з'являються - типові причини:

  1. Немає воркера черги. Клас сповіщення Filament реалізує ShouldQueue, тож запис у базу виконується в черзі. Без запущеного queue:work (чи Horizon) сповіщення просто чекають у черзі.
  2. Не ввімкнено databaseNotifications() у тій панелі, де очікують дзвіночок.
  3. Опитування. Нові сповіщення підтягуються раз на 30 секунд (за замовчуванням). Миттєво - лише з вебсокетами: sendToDatabase($user, isEventDispatched: true) плюс налаштований Laravel Echo.
  4. PostgreSQL: колонка data у міграції має бути json().
  5. UUID у моделі користувача: у міграції потрібні uuidMorphs('notifiable').

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

  • сповіщення адресуються конкретній моделі - щоб повідомити всіх адміністраторів, передайте колекцію: sendToDatabase(User::admins()->get());
  • опитування раз на 30 секунд на кожній відкритій вкладці кожного користувача - теж навантаження; на великих панелях вебсокети вигідніші;
  • старі сповіщення варто чистити (планувальником), інакше таблиця росте без меж.

Докладніше в документації: Сповіщення в базі даних

Графіки у Filament малюються бібліотекою Chart.js. Віджет повертає дані в її форматі.

php artisan make:filament-widget OrdersChart --chart
use Filament\Widgets\ChartWidget;

class OrdersChart extends ChartWidget
{
    protected ?string $heading = 'Замовлення';

    protected ?string $pollingInterval = null;   // історичним даним опитування не потрібне

    public ?string $filter = 'month';

    protected function getFilters(): ?array
    {
        return [
            'week' => 'Тиждень',
            'month' => 'Місяць',
            'year' => 'Рік',
        ];
    }

    protected function getData(): array
    {
        $days = match ($this->filter) {
            'week' => 7,
            'year' => 365,
            default => 30,
        };

        $rows = Order::query()
            ->where('created_at', '>=', now()->subDays($days))
            ->selectRaw('DATE(created_at) as day, COUNT(*) as total')
            ->groupBy('day')
            ->orderBy('day')
            ->pluck('total', 'day');

        return [
            'datasets' => [
                ['label' => 'Замовлення', 'data' => $rows->values()->all()],
            ],
            'labels' => $rows->keys()->all(),
        ];
    }

    protected function getType(): string
    {
        return 'line';
    }
}

Типи графіків: line, bar, pie, doughnut, radar, polarArea, bubble, scatter. Опції Chart.js - через getOptions().

Готове рішення для часових рядів - пакет flowframe/laravel-trend, який рекомендує документація: Trend::model(Order::class)->between(...)->perDay()->count() повертає значення й дні без даних (агрегат з GROUP BY дні без замовлень просто пропускає, і графік виходить нерівномірним).

Пастки:

  • $filter - публічна властивість Livewire, її значення приходить з браузера. Використовувати її лише через match з відомими варіантами, а не підставляти в запит напряму;
  • опитування за замовчуванням - кожні 5 секунд. Агрегат за рік щоп'ять секунд на кожному відкритому дашборді - марне навантаження. Для історичних графіків опитування вимикають;
  • важкі агрегати - кешувати з ключем, що містить фільтр, або рахувати заздалегідь у таблицю статистики;
  • DATE(created_at) групує за часовим поясом бази - якщо база в UTC, а користувачі в Києві, межі днів «зсунуться».

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

Коли на дашборді кілька віджетів, зручно мати один набір фільтрів (період, регіон, менеджер), що діє на всі одразу.

1. Власна сторінка дашборда з формою фільтрів:

namespace App\Filament\Pages;

use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Pages\Dashboard as BaseDashboard;
use Filament\Pages\Dashboard\Concerns\HasFiltersForm;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

class Dashboard extends BaseDashboard
{
    use HasFiltersForm;

    public function filtersForm(Schema $schema): Schema
    {
        return $schema
            ->components([
                Section::make()
                    ->schema([
                        DatePicker::make('startDate'),
                        DatePicker::make('endDate'),
                        Select::make('region')->options(Region::class),
                    ])
                    ->columns(3)
                    ->columnSpanFull(),
            ]);
    }
}

Стандартний дашборд треба замінити цим класом (прибрати Dashboard::class зі сторінок панелі чи вимкнути автовиявлення стандартного).

2. Віджети читають фільтри через трейт:

use Filament\Widgets\Concerns\InteractsWithPageFilters;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class SalesOverview extends StatsOverviewWidget
{
    use InteractsWithPageFilters;

    protected function getStats(): array
    {
        $query = Order::query()
            ->when($this->pageFilters['startDate'] ?? null, fn ($q, $date) => $q->whereDate('created_at', '>=', $date))
            ->when($this->pageFilters['endDate'] ?? null, fn ($q, $date) => $q->whereDate('created_at', '<=', $date))
            ->when($this->pageFilters['region'] ?? null, fn ($q, $region) => $q->where('region', $region));

        return [
            Stat::make('Замовлень', $query->count()),
            Stat::make('Виручка', Number::currency((clone $query)->sum('total'), 'UAH', 'uk')),
        ];
    }
}

Коли фільтри змінюються, віджети перезавантажуються з новими значеннями.

Варіанти:

  • фільтри можна винести в модальне вікно за кнопкою (HasFiltersAction) - дашборд лишається компактним;
  • $this->pageFilters можна зберігати в сесії, щоб вибраний період не скидався при поверненні.

Пастки:

  • $pageFilters - сирі дані з браузера. Перевіряйте й приводьте їх (дати, значення зі списку), а не підставляйте в whereRaw;
  • повторне використання запиту - count() і sum() на тому самому білдері працюють, але при модифікаціях краще clone, щоб умови не накопичувалися;
  • кожна зміна фільтра перераховує всі віджети - для важких метрик потрібне кешування з ключем від фільтрів.

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

Способи обмежити дію:

use Filament\Actions\Action;

// показати лише тим, кому можна
Action::make('refund')
    ->visible(fn (Order $record): bool => auth()->user()->can('refund', $record))
    ->action(fn (Order $record) => $record->refund());

// через політику моделі
Action::make('refund')
    ->authorize('refund')
    ->action(fn (Order $record) => $record->refund());

// показати вимкненою з поясненням з політики
Action::make('refund')
    ->authorize('refund')
    ->authorizationTooltip();

// обмежити частоту
Action::make('resendInvoice')
    ->rateLimit(5);   // спроб на хвилину з однієї IP-адреси

Чи захищає прихована кнопка? Так, і це важлива відмінність Filament від «голого» Livewire. Дії викликаються через Livewire-запит, який можна підробити. Але перед виконанням Filament перевіряє дію на сервері: якщо visible() повертає false (або hidden() - true) чи дія disabled(), вона вважається вимкненою і не виконується. Тобто умова видимості працює і як перевірка доступу.

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

Готові дії в ресурсах (EditAction, DeleteAction, DeleteBulkAction) самі перевіряють відповідні методи політики (update, delete, deleteAny) - викликати authorize() для них не потрібно.

Пастки:

  • умова має залежати від запису. visible(auth()->user()->isAdmin()) у таблиці перевіряє роль, але не те, чи може цей адміністратор чіпати цей запис (інший тенант, чужий відділ);
  • масові дії - для перевірки кожного вибраного запису потрібен authorizeIndividualRecords('refund'), інакше перевіряється лише загальний доступ;
  • дія з url() - це просто посилання: захищати треба маршрут, на який воно веде, а не кнопку;
  • rateLimit() рахує спроби для комбінації дії, запису й сторінки - від повторних кліків захищає, але не замінює серверних обмежень на дорогі операції (листи, платежі).

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

#[Url] синхронізує публічну властивість з параметром у рядку запиту: при завантаженні сторінки значення береться з адреси, а при зміні властивості адреса оновлюється без перезавантаження.

use Livewire\Attributes\Url;

class Products extends Component
{
    #[Url]
    public string $search = '';

    #[Url(as: 'q', history: true, except: '')]
    public string $query = '';

    #[Url(keep: true)]
    public string $sort = 'new';
}

Тепер /products?q=laravel&sort=price відкриває сторінку з уже заповненими полями - посиланням можна поділитися, сторінку можна додати в закладки.

Параметри атрибута:

Параметр Що робить
as інша назва параметра в URL (?q= замість ?query=)
history true - кожна зміна додає запис в історію браузера (pushState), і «Назад» повертає попередній пошук. За замовчуванням replaceState: кнопка «Назад» веде на попередню сторінку, а не до попереднього значення
keep показувати параметр завжди, навіть зі значенням за замовчуванням
except не показувати параметр, коли значення дорівнює вказаному (наприклад, порожньому рядку)
nullable порожній параметр (?search=) перетворюється на null, а не на ''; визначається й автоматично з типу ?string

Нюанси, які варто знати:

  • значення з URL - це введення користувача. Хтось вручну пише ?sort=password - якщо $sort потрапляє в orderBy(), перевіряйте його списком дозволених значень (або використайте enum). #[Url] нічого не валідує;
  • невідповідний тип мовчки ігнорується: якщо в ?page=abc для int $page Livewire не може привести значення, лишається значення за замовчуванням, а не помилка;
  • масиви підтримуються (?filters[status]=paid), і часткові значення з URL зливаються зі значенням за замовчуванням;
  • з об'єктами форм: #[Url] можна поставити на властивість form object - тоді параметр за замовчуванням називається як сама властивість;
  • пагінація: трейт WithPagination сам тримає номер сторінки в ?page=, а при зміні фільтра варто викликати $this->resetPage() в updated*-хуку, інакше користувач опиниться на неіснуючій сторінці.

SEO: кожна комбінація параметрів - окрема адреса. Для фасетних фільтрів на публічних сторінках подумайте про canonical, noindex чи обмеження комбінацій, інакше краулери знайдуть мільйони варіантів.

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

У Livewire дані між батьківським і дочірнім компонентом за замовчуванням передаються один раз - при монтуванні. Далі кожен компонент живе своїм життям. Є два атрибути, що змінюють це, і вони працюють у протилежних напрямках.

#[Modelable] - дочірній компонент стає «полем введення», до якого батько прив'язується через wire:model:

// дочірній компонент - власний редактор тегів
class TagPicker extends Component
{
    #[Modelable]
    public array $tags = [];

    public function add(string $tag): void
    {
        $this->tags[] = $tag;
    }
}
{{-- батько --}}
<livewire:tag-picker wire:model="form.tags" />

Дочірній змінює $tags - значення потрапляє в form.tags батька, як з будь-якого звичайного поля форми. Модифікатори працюють так само: wire:model.live відправляє зміни на сервер одразу.

#[Reactive] - навпаки, дані йдуть від батька до дитини: коли батько змінює передане значення й перерендерюється, дочірній компонент отримує нове значення.

class OrderTotals extends Component
{
    #[Reactive]
    public array $items;
}
<livewire:order-totals :items="$items" />

Порівняння:

#[Modelable] #[Reactive]
напрямок дитина → батько (і початкове значення від батька) батько → дитина
як підключається wire:model на тезі компонента звичайний параметр :items="..."
чи може дитина змінювати так, у цьому і суть ні - Livewire кине CannotMutateReactivePropException
для чого власні поля введення: вибір дати, редактор, вибір файлів віджети, що відображають дані батька

Альтернативи й обмеження:

  • події ($this->dispatch(...) / #[On]) - для зв'язку компонентів, які не вкладені один в одного, чи коли дитина повідомляє про дію, а не про значення;
  • $parent у шаблоні дочірнього компонента викликає дії батька напряму (wire:click="$parent.save") - коротко, але тісно зв'язує компоненти;
  • кожен вкладений компонент - окремий стан і окрема гідрація. Якщо «поле» не має власної серверної логіки, простіше Blade-компонент з Alpine і x-modelable, ніж Livewire-компонент з #[Modelable];
  • #[Reactive] масиву моделей означає, що при кожному рендері батька весь масив серіалізується й передається дитині - для великих даних краще передавати ідентифікатори.

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

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

Рівні
Junior 36 Middle 41 Senior 36

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