Питання на співбесіді з 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 рендерить усі елементи в одній формі, і велика кількість робить її повільною.
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().
Чому сповіщення не з'являються - типові причини:
- Немає воркера черги. Клас сповіщення 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()рахує спроби для комбінації дії, запису й сторінки - від повторних кліків захищає, але не замінює серверних обмежень на дорогі операції (листи, платежі).
#[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 $pageLivewire не може привести значення, лишається значення за замовчуванням, а не помилка; - масиви підтримуються (
?filters[status]=paid), і часткові значення з URL зливаються зі значенням за замовчуванням; - з об'єктами форм:
#[Url]можна поставити на властивість form object - тоді параметр за замовчуванням називається як сама властивість; - пагінація: трейт
WithPaginationсам тримає номер сторінки в?page=, а при зміні фільтра варто викликати$this->resetPage()вupdated*-хуку, інакше користувач опиниться на неіснуючій сторінці.
SEO: кожна комбінація параметрів - окрема адреса. Для фасетних фільтрів на публічних сторінках подумайте про canonical, noindex чи обмеження комбінацій, інакше краулери знайдуть мільйони варіантів.
У 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]масиву моделей означає, що при кожному рендері батька весь масив серіалізується й передається дитині - для великих даних краще передавати ідентифікатори.
Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Livewire 21 Filament 19 Дії, інфолисти й віджети 13 Продуктивність Livewire 12 Події, Alpine і навігація 12 Таблиці Filament 12 Форми й схеми Filament 12 Форми й валідація 12
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії