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

Питання на співбесіді: Дії, інфолисти й віджети

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

13 питань

Дія (action) у Filament - це кнопка разом із логікою: що станеться після натискання, чи відкриється модальне вікно, хто її бачить. Усі дії в Filament 5 живуть у просторі імен Filament\Actions - і в таблицях, і на сторінках, і у формах.

Готові дії для типових операцій з Eloquent:

  • CreateAction, EditAction, ViewAction, DeleteAction;
  • ReplicateAction - копія запису (excludeAttributes(['slug']) - не копіювати унікальні поля);
  • ForceDeleteAction, RestoreAction - для моделей з SoftDeletes;
  • ImportAction, ExportAction - CSV/XLSX через черги;
  • масові варіанти: DeleteBulkAction, ForceDeleteBulkAction, RestoreBulkAction.
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Actions\ReplicateAction;

->recordActions([
    EditAction::make(),
    ReplicateAction::make()->excludeAttributes(['slug']),
    DeleteAction::make(),
])

Готові дії вже вміють модальне вікно, сповіщення про успіх і перевірку політики моделі в ресурсі.

Власна дія з підтвердженням:

use Filament\Actions\Action;
use Filament\Support\Icons\Heroicon;

Action::make('cancel')
    ->label('Скасувати замовлення')
    ->icon(Heroicon::OutlinedXCircle)
    ->color('danger')
    ->requiresConfirmation()
    ->modalHeading('Скасувати замовлення?')
    ->modalDescription('Клієнт отримає лист, а оплату буде повернено.')
    ->modalSubmitActionLabel('Так, скасувати')
    ->action(fn (Order $record) => $record->cancel())
    ->successNotificationTitle('Замовлення скасовано');

requiresConfirmation() відкриває стандартне вікно підтвердження, і код в action() виконається лише після згоди.

Пастки:

  • url() і підтвердження несумісні: якщо дія лише відкриває посилання (->url(...)), вікно підтвердження не з'явиться. Для підтвердження перед переходом потрібен action() з редиректом усередині;
  • кілька дій з однаковою назвою в одному місці конфліктують - ім'я в make() має бути унікальним;
  • підтвердження - не авторизація. Хто може натиснути кнопку, вирішують visible()/authorize(), а не модальне вікно.

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

Спливаючі сповіщення («тости») створює клас Filament\Notifications\Notification з флюентним API:

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

Notification::make()
    ->title('Замовлення збережено')
    ->body('Клієнту надіслано лист з підтвердженням.')
    ->success()
    ->send();

Стан і вигляд:

  • success(), warning(), danger(), info() - колір та іконка;
  • icon(), iconColor() - власна іконка;
  • duration(5000) / seconds(5) - скільки показувати (за замовчуванням 6 секунд);
  • persistent() - не зникати, доки користувач не закриє.

Дії в сповіщенні:

Notification::make()
    ->title('Імпорт завершено з помилками')
    ->warning()
    ->persistent()
    ->actions([
        Action::make('view')
            ->label('Переглянути')
            ->button()
            ->url(route('imports.show', $import)),
    ])
    ->send();

Як це працює. Сповіщення записується в сесію і показується на наступному рендері. Тому його можна надіслати звідки завгодно: з дії, з контролера перед редиректом, зі звичайного Livewire-компонента. Є і JavaScript-версія: new FilamentNotification().title('...').send().

У готових діях (CreateAction, EditAction, сторінки ресурсів) сповіщення про успіх уже є - текст міняють через successNotificationTitle(), а прибирають - successNotification(null).

Пастки:

  • send() показує сповіщення поточному користувачу в браузері. Щоб повідомити іншого користувача чи зберегти повідомлення надовго - сповіщення в базу даних (sendToDatabase()) чи трансляція (broadcast());
  • заголовок може містити простий безпечний HTML. Дані користувача варто вставляти обережно - санітайзер Filament пропускає атрибути style;
  • надіслане з джоби в черзі сповіщення через send() нікого не досягне: у воркера немає сесії браузера.

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

Infolist - схема для перегляду даних запису у вигляді «підпис - значення». Вона використовується на сторінці перегляду ресурсу (ViewRecord), у модальних вікнах ViewAction, у relation managers.

use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\RepeatableEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

public static function infolist(Schema $schema): Schema
{
    return $schema
        ->components([
            Section::make('Замовлення')
                ->schema([
                    TextEntry::make('number')->copyable(),
                    TextEntry::make('customer.name')->label('Клієнт'),
                    TextEntry::make('status')->badge(),
                    TextEntry::make('total')->money('UAH'),
                    IconEntry::make('is_paid')->boolean(),
                    TextEntry::make('created_at')->dateTime('d.m.Y H:i'),
                ])
                ->columns(3)
                ->columnSpanFull(),

            RepeatableEntry::make('items')
                ->schema([
                    TextEntry::make('product.name'),
                    TextEntry::make('qty'),
                ])
                ->columns(2)
                ->columnSpanFull(),
        ]);
}

Записи (entries) живуть у Filament\Infolists\Components: TextEntry, IconEntry, ImageEntry, ColorEntry, CodeEntry, KeyValueEntry, RepeatableEntry. Макет - ті самі Section, Grid, Tabs зі схем.

Чим краще за форму з disabled():

  • форматування як у таблицях: бейджі, гроші, дати, іконки, markdown, копіювання в буфер, посилання;
  • жодних полів введення - менше HTML, немає стану форми в Livewire-компоненті;
  • зв'язки й JSON читаються крапковою нотацією: customer.name, meta.title;
  • вимкнена форма виглядає як «щось зламалося» і погано читається.

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

  • сторінку перегляду додають у ресурс опцією --view при генерації (або вручну в getPages());
  • якщо метод infolist() не визначено, Filament показує на сторінці перегляду вимкнену форму;
  • TextEntry з html() чи markdown() санітизує вміст - на відміну від власного Blade з {!! !!};
  • relation managers на сторінці перегляду за замовчуванням працюють лише в режимі читання.

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

Віджет статистики - кілька карток з числом, описом і міні-графіком.

php artisan make:filament-widget OrdersOverview --stats-overview
use Filament\Support\Icons\Heroicon;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;

class OrdersOverview extends StatsOverviewWidget
{
    protected ?string $pollingInterval = '60s';

    protected function getStats(): array
    {
        $today = Order::whereDate('created_at', today());

        return [
            Stat::make('Замовлень сьогодні', $today->count())
                ->description('+12% до вчора')
                ->descriptionIcon(Heroicon::ArrowTrendingUp)
                ->color('success')
                ->chart([7, 3, 4, 5, 6, 3, 9]),

            Stat::make('Виручка сьогодні', Number::currency($today->sum('total'), 'UAH', 'uk')),

            Stat::make('Нових клієнтів', Customer::whereDate('created_at', today())->count()),
        ];
    }
}

Віджети з каталогу app/Filament/Widgets автоматично з'являються на дашборді панелі.

Корисні налаштування:

  • protected static ?int $sort = 1; - порядок на дашборді;
  • protected int | string | array $columnSpan = 'full'; - ширина;
  • public static function canView(): bool - кому показувати;
  • protected static bool $isLazy = false; - вимкнути ліниве завантаження.

Дві важливі поведінки за замовчуванням:

  • опитування кожні 5 секунд. Віджет статистики (і графік) сам оновлюється раз на 5 секунд. Якщо в getStats() важкі агрегати по великих таблицях, кожен відкритий дашборд створює постійне навантаження на базу. Інтервал варто збільшити ($pollingInterval = '60s') або вимкнути (null);
  • ліниве завантаження. Віджети завантажуються окремим запитом після сторінки, тож повільний віджет не блокує решту дашборда.

Пастки:

  • canView() ховає віджет з дашборда, але якщо той самий віджет вбудовано на іншу сторінку, умову треба перевірити й там;
  • важкі метрики варто кешувати (Cache::remember на кілька хвилин) - точність до секунди на дашборді рідко потрібна;
  • число без форматування (1234567) погано читається: краще Number::format() чи Number::abbreviate().

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

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() рахує спроби для комбінації дії, запису й сторінки - від повторних кліків захищає, але не замінює серверних обмежень на дорогі операції (листи, платежі).

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

ImportAction дає користувачу завантажити CSV, зіставити його колонки з полями моделі й імпортувати рядки у фоні. Рядки, що не пройшли валідацію, збираються в окремий CSV «невдалих рядків» для завантаження.

Підготовка:

php artisan make:queue-batches-table
php artisan make:notifications-table
php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrate
php artisan make:filament-importer Product --generate

Імпорт використовує пакети джоб (job batches) і сповіщення в базі даних - без воркера черги нічого не відбудеться.

Імпортер:

use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;

class ProductImporter extends Importer
{
    protected static ?string $model = Product::class;

    public static function getColumns(): array
    {
        return [
            ImportColumn::make('sku')->requiredMapping()->rules(['required', 'max:32']),
            ImportColumn::make('name')->requiredMapping()->rules(['required', 'max:255']),
            ImportColumn::make('price')->numeric()->rules(['numeric', 'min:0']),
            ImportColumn::make('category')->relationship(resolveUsing: 'slug'),
        ];
    }

    public function resolveRecord(): ?Product
    {
        // оновлювати наявні товари за SKU, нові - створювати
        return Product::firstOrNew(['sku' => $this->data['sku']]);
    }
}
ImportAction::make()
    ->importer(ProductImporter::class)
    ->chunkSize(250)
    ->maxRows(50_000);

CSV ділиться на частини (за замовчуванням по 100 рядків), кожна обробляється окремою джобою.

Ризики й як їх закрити:

  • немає авторизації кожного запису. Імпорт не викликає політики: хто може запустити імпорт, той створить чи оновить будь-який запис, який поверне resolveRecord(). Для недовірених користувачів перевірки треба додати в хуки beforeCreate() / beforeUpdate() імпортера;
  • resolveRecord() за слабким ключем (назва, email без нормалізації) - дублікати чи перезапис чужих даних;
  • CSV-формули. У файлі невдалих рядків значення лишаються як були. Комірка =HYPERLINK(...) чи =cmd|..., відкрита в Excel, може виконатися як формула. Варто попереджати користувачів або очищати значення, що починаються з =, +, -, @;
  • файл невдалих рядків за замовчуванням може завантажити лише користувач, що запустив імпорт. Власна політика ImportPolicy замінює цю логіку повністю - перевірку автора треба повторити в ній;
  • розмір. maxRows() і розумний chunkSize() захищають від файлу на мільйони рядків, що «покладе» чергу;
  • кастинг перед валідацією - ціни з комою (12,50) чи дати в дд.мм.рррр треба привести в castStateUsing(), інакше всі рядки впадуть на валідації.

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

ExportAction формує CSV чи XLSX у фоні: користувач вибирає колонки, отримує сповіщення, коли файл готовий, і завантажує його. Під капотом - пакети джоб і сповіщення в базі даних.

php artisan make:filament-exporter Order --generate
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;

class OrderExporter extends Exporter
{
    protected static ?string $model = Order::class;

    public static function getColumns(): array
    {
        return [
            ExportColumn::make('number'),
            ExportColumn::make('customer.name')->label('Клієнт'),
            ExportColumn::make('items_count')->counts('items'),
            ExportColumn::make('total'),
            ExportColumn::make('created_at'),
        ];
    }
}
use Filament\Actions\ExportAction;
use Filament\Actions\Exports\Enums\ExportFormat;
use Illuminate\Database\Eloquent\Builder;

ExportAction::make()
    ->exporter(OrderExporter::class)
    ->formats([ExportFormat::Xlsx, ExportFormat::Csv])
    ->modifyQueryUsing(fn (Builder $query) => $query->whereBelongsTo(auth()->user()->team))
    ->fileDisk('s3')
    ->chunkSize(500)
    ->maxRows(100_000);

Звідки беруться дані. У таблиці експорт бере поточний запит таблиці - з пошуком, фільтрами й сортуванням. Користувач експортує те, що бачить у списку (усі сторінки).

Безпека:

  • політики для кожного запису не перевіряються. Якщо таблиця показує лише «свої» записи завдяки скоупу ресурсу - добре. Але експорт поза таблицею (з кнопки на сторінці) бере всю модель. Обмеження варто дублювати в modifyQueryUsing() або в modifyQuery() експортера;
  • диск. Готові файли зберігаються на диску. Filament навмисно уникає публічного диска (якщо диск за замовчуванням public і є local, експорт піде в local), але на продакшені краще явно вказати приватний s3/r2;
  • CSV-формули. Значення з бази потрапляють у файл як є. Текст, що починається з =, +, -, @ і вводився користувачами, Excel може виконати. Захист - formatStateUsing() на колонках з довільним текстом (наприклад, префікс ');
  • чутливі колонки (телефони, email, суми) варто не додавати в експортер «про всяк випадок» - користувач вибирає з того, що ви дозволили.

Продуктивність:

  • експорт ділиться на частини (за замовчуванням по 100 рядків) - для простих рядків chunkSize() можна збільшити, для важких зменшити;
  • зв'язки й агрегати в колонках варто жадібно завантажити в modifyQuery() експортера, інакше кожна частина робить N+1;
  • maxRows() не дасть випадково поставити в чергу мільйонний експорт.

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

У Filament три способи доставити сповіщення, і вони розв'язують різні задачі:

Спосіб Куди Зберігається Коли отримує
send() поточний користувач, через сесію ні на наступному рендері
sendToDatabase($user) таблиця notifications так при опитуванні (30 с) чи через вебсокет
broadcast($user) вебсокет-канал користувача ні миттєво, якщо сторінка відкрита

Broadcast-сповіщення - тимчасовий «тост», що з'являється в браузері користувача в реальному часі, але ніде не зберігається. Якщо вкладка закрита - сповіщення втрачено.

Notification::make()
    ->title('Експорт готовий')
    ->success()
    ->broadcast($user);

Налаштування (окрім звичайного broadcasting у Laravel - Reverb чи Pusher):

  1. опублікувати конфіг Filament (php artisan vendor:publish --tag=filament-config);
  2. у config/filament.php розкоментувати й заповнити розділ broadcasting.echo;
  3. переконатися, що в .env є потрібні змінні VITE_*;
  4. мати запущені воркер черги (сповіщення ставиться в чергу) і вебсокет-сервер.

Поєднання найкращого з двох світів - сповіщення в базі даних з миттєвою доставкою:

Notification::make()
    ->title('Новий відгук на вакансію')
    ->sendToDatabase($recruiter, isEventDispatched: true);

Запис зберігається в базі (видно в дзвіночку й пізніше), а подія через вебсокет змушує панель одразу підтягнути нові сповіщення, без очікування опитування. Після цього опитування можна вимкнути: databaseNotificationsPolling(null).

Що обирати:

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

Пастки:

  • приватні канали авторизуються через routes/channels.php - перевірте, що користувач отримує лише свій канал;
  • черга й вебсокет-сервер - дві окремі точки відмови: без воркера сповіщення не відправиться, без Reverb - не дійде;
  • опитування на сотнях вкладок часто дорожче за один вебсокет-сервер - на великих панелях broadcast окупається.

Докладніше в документації: Broadcast-сповіщення

Дії тестують через livewire() на тій сторінці чи компоненті, де вони розміщені.

Дія на сторінці (наприклад, у шапці сторінки редагування):

use App\Filament\Resources\Orders\Pages\EditOrder;
use App\Models\Order;

use function Pest\Livewire\livewire;

it('cancels an order', function () {
    $order = Order::factory()->paid()->create();

    livewire(EditOrder::class, ['record' => $order->getRouteKey()])
        ->callAction('cancel', data: ['reason' => 'Клієнт передумав'])
        ->assertHasNoFormErrors()
        ->assertNotified('Замовлення скасовано');

    expect($order->refresh()->status)->toBe(OrderStatus::Cancelled);
});

Дія в рядку таблиці - через TestAction:

use Filament\Actions\Testing\TestAction;

livewire(ListOrders::class)
    ->callAction(TestAction::make('cancel')->table($order), data: ['reason' => '...']);

// масова дія
livewire(ListOrders::class)
    ->selectTableRecords($orders->pluck('id')->all())
    ->callAction(TestAction::make('cancel')->table()->bulk());

Валідація даних модального вікна:

livewire(EditOrder::class, ['record' => $order->getRouteKey()])
    ->callAction('cancel', data: ['reason' => ''])
    ->assertHasFormErrors(['reason' => 'required']);

Зупинка дії ($action->halt() у хуку):

livewire(EditOrder::class, ['record' => $shipped->getRouteKey()])
    ->callAction('cancel')
    ->assertActionHalted('cancel');

Видимість і права:

$this->actingAs(User::factory()->support()->create());

livewire(ListOrders::class)
    ->assertActionHidden(TestAction::make('refund')->table($order))
    ->assertActionVisible(TestAction::make('view')->table($order));

Сповіщення:

  • ->assertNotified() / ->assertNotified('Заголовок') / ->assertNotNotified() - для сповіщень через сесію;
  • сповіщення в базі даних - це звичайні сповіщення Laravel: Notification::fake() (фасад Laravel) і assertSentTo($user, \Filament\Notifications\DatabaseNotification::class).

Що варто перевіряти обов'язково:

  • заборону, а не лише успіх: користувач без прав не бачить дію, а підроблений виклик нічого не змінює в базі;
  • побічні ефекти через фейки: Mail::fake(), Queue::fake(), Bus::fake(), - а не реальні листи й джоби;
  • для дій у шапці таблиці - TestAction::make('export')->table() без запису.

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