Middle: питання на співбесіді з теми «Дії, інфолисти й віджети»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
5 питань
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()рахує спроби для комбінації дії, запису й сторінки - від повторних кліків захищає, але не замінює серверних обмежень на дорогі операції (листи, платежі).