Питання на співбесіді: Дії, інфолисти й віджети
Питання з реальних співбесід з відповідями: 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 на сторінці перегляду за замовчуванням працюють лише в режимі читання.
Віджет статистики - кілька карток з числом, описом і міні-графіком.
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().
Чому сповіщення не з'являються - типові причини:
- Немає воркера черги. Клас сповіщення Filament реалізує
ShouldQueue, тож запис у базу виконується в черзі. Без запущеногоqueue:work(чи Horizon) сповіщення просто чекають у черзі. - Не ввімкнено
databaseNotifications()у тій панелі, де очікують дзвіночок. - Опитування. Нові сповіщення підтягуються раз на 30 секунд (за замовчуванням). Миттєво - лише з вебсокетами:
sendToDatabase($user, isEventDispatched: true)плюс налаштований Laravel Echo. - PostgreSQL: колонка
dataу міграції має бутиjson(). - 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):
- опублікувати конфіг Filament (
php artisan vendor:publish --tag=filament-config); - у
config/filament.phpрозкоментувати й заповнити розділbroadcasting.echo; - переконатися, що в
.envє потрібні змінніVITE_*; - мати запущені воркер черги (сповіщення ставиться в чергу) і вебсокет-сервер.
Поєднання найкращого з двох світів - сповіщення в базі даних з миттєвою доставкою:
Notification::make()
->title('Новий відгук на вакансію')
->sendToDatabase($recruiter, isEventDispatched: true);
Запис зберігається в базі (видно в дзвіночку й пізніше), а подія через вебсокет змушує панель одразу підтягнути нові сповіщення, без очікування опитування. Після цього опитування можна вимкнути: databaseNotificationsPolling(null).
Що обирати:
- результат дії, яку користувач щойно виконав, -
send(); - щось, що користувач має побачити навіть пізніше (призначене завдання, відгук, результат імпорту) -
sendToDatabase(); - миттєва, але неважлива подія для відкритої сторінки (хтось редагує той самий запис) -
broadcast().
Пастки:
- приватні канали авторизуються через
routes/channels.php- перевірте, що користувач отримує лише свій канал; - черга й вебсокет-сервер - дві окремі точки відмови: без воркера сповіщення не відправиться, без Reverb - не дійде;
- опитування на сотнях вкладок часто дорожче за один вебсокет-сервер - на великих панелях 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()без запису.