Питання на співбесіді: Таблиці Filament
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
12 питань
Таблиця Filament будується з Eloquent-запиту ресурсу, а колонки описують, що показати з кожного запису:
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('title')
->searchable()
->sortable(),
TextColumn::make('author.name')
->label('Автор')
->searchable(),
IconColumn::make('is_featured')
->boolean(),
TextColumn::make('created_at')
->dateTime('d.m.Y H:i')
->sortable(),
])
->defaultSort('created_at', 'desc');
}
Що тут відбувається:
TextColumn::make('title')- ім'я колонки збігається з атрибутом моделі;- крапкова нотація
author.name- значення зі зв'язку. Filament сам додає жадібне завантаженняauthor, тож N+1 тут немає; searchable()- у таблиці з'являється поле пошуку, і Filament додаєwhere ... likeпо цій колонці (зокрема крізь зв'язок);sortable()- клік по заголовку сортує запит.
Колонки з обчисленим значенням. Якщо колонка показує аксесор чи результат state(), її в базі немає, і пошук «у лоб» не спрацює. Треба вказати справжні колонки:
TextColumn::make('full_name')
->searchable(['first_name', 'last_name'])
Пастки:
- пошук через
LIKE '%...%'по великій таблиці повільний - краще робити пошуковими лише потрібні колонки, а для великих обсягів підключити Scout; state(fn ($record) => $record->orders()->sum('total'))виконує окремий запит на кожен рядок. Для агрегатів є спеціальні методи (counts(),sum()), що працюють одним запитом;- для довгого тексту -
limit(50)іwrap(), щоб таблиця не роз'їжджалася.
Фільтри описують у ->filters([...]). Кожен фільтр отримує Eloquent-запит і звужує його.
Основні типи:
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Filters\TrashedFilter;
use Illuminate\Database\Eloquent\Builder;
->filters([
// чекбокс: увімкнено - діє query()
Filter::make('is_featured')
->label('Лише рекомендовані')
->query(fn (Builder $query): Builder => $query->where('is_featured', true)),
// список значень, можна кілька
SelectFilter::make('status')
->options([
'draft' => 'Чернетка',
'published' => 'Опубліковано',
])
->multiple(),
// за зв'язком, з пошуком
SelectFilter::make('author')
->relationship('author', 'name')
->searchable()
->preload(),
// три стани: так / ні / усі
TernaryFilter::make('email_verified_at')
->nullable(),
// для моделей з SoftDeletes
TrashedFilter::make(),
])
Власний фільтр - будь-які поля форми плюс query() з масивом $data, наприклад діапазон дат з двох DatePicker.
Чому фільтр не діє одразу. За замовчуванням зміни фільтрів відкладені: користувач вибирає кілька значень і тисне «Застосувати» - таблиця перезавантажується один раз. Це економить запити. Щоб фільтри працювали миттєво:
$table->deferFilters(false);
Корисне:
->persistFiltersInSession()- фільтри зберігаються між відвідуваннями сторінки;->default()на фільтрі - активний одразу;filtersLayout: FiltersLayout::AboveContent- показати фільтри над таблицею, а не в випадному меню.
Пастка: query() фільтра загортається в окрему групу where (...), щоб orWhere не зламав інші фільтри. Тому прибрати глобальний скоуп (наприклад, SoftDeletingScope) з query() не вийде - для цього є baseQuery().
TextColumn уміє форматувати значення без зміни самих даних.
use Filament\Tables\Columns\TextColumn;
TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'draft' => 'gray',
'paid' => 'success',
'refunded' => 'danger',
default => 'warning',
}),
TextColumn::make('total')
->money('UAH'), // 1 250,00 ₴ з урахуванням локалі
TextColumn::make('price_cents')
->money('UAH', divideBy: 100), // ціни, що зберігаються в копійках
TextColumn::make('created_at')
->dateTime('d.m.Y H:i'),
TextColumn::make('last_login_at')
->since(), // «3 години тому»
TextColumn::make('title')
->description(fn (Post $record): string => $record->excerpt)
->limit(60),
Найчистіше рішення для статусів - enum. Якщо атрибут моделі кастується в enum, що реалізує HasLabel і HasColor, колонка з badge() сама бере підпис і колір:
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum OrderStatus: string implements HasLabel, HasColor
{
case New = 'new';
case Paid = 'paid';
public function getLabel(): string
{
return match ($this) {
self::New => 'Нове',
self::Paid => 'Оплачено',
};
}
public function getColor(): string
{
return match ($this) {
self::New => 'warning',
self::Paid => 'success',
};
}
}
Той самий enum потім працює в SelectFilter::make('status')->options(OrderStatus::class) і в полях форми. Підписи статусів живуть в одному місці, а не розкидані по match у різних ресурсах.
Пастки:
formatStateUsing()змінює лише відображення: пошук і сортування працюють по сирому значенню з бази;html()чиmarkdown()санітизують вміст, алеformatStateUsing(), що повертаєHtmlString, - ні: так легко вивести неекрановані дані користувача.
Масові дії (bulk actions) виконуються над кількома вибраними рядками. Щойно в таблиці з'являється хоча б одна така дія, біля кожного рядка з'являється чекбокс.
У Filament 5 їх кладуть у toolbarActions() (або headerActions()), часто згрупованими у випадне меню:
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Illuminate\Database\Eloquent\Collection;
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
BulkAction::make('publish')
->label('Опублікувати')
->requiresConfirmation()
->action(fn (Collection $records) => $records->each->update(['status' => 'published']))
->deselectRecordsAfterCompletion(),
]),
])
$records - Eloquent-колекція вибраних моделей.
Дії над окремим рядком - окремо, у recordActions():
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
Корисні налаштування:
->checkIfRecordIsSelectableUsing(fn (Order $record): bool => $record->status !== 'archived')- деякі рядки вибрати не можна;->maxSelectableRecords(100)- обмеження кількості;->selectCurrentPageOnly()- не давати одним кліком вибрати всі сторінки.
Пастки:
$records->each->update(...)- окремий запит на кожен запис. Для сотень рядків це прийнятно, для десятків тисяч - ні (єchunkSelectedRecords()іfetchSelectedRecords(false));- права:
DeleteBulkActionу ресурсі перевіряє політикуdeleteAny. Для власних дій перевірку прав треба додати самому, наприкладauthorizeIndividualRecords('update')- тоді записи, які користувач змінювати не може, просто не потраплять у$records; - після дії вибір рядків за замовчуванням лишається -
deselectRecordsAfterCompletion()прибирає його.
Найчастіша причина повільної таблиці в адмінці - запит на кожен рядок для підрахунку пов'язаних записів. 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.
QueryBuilder - фільтр, у якому користувач сам складає умови з груп «І» / «АБО» з необмеженою вкладеністю: «статус - оплачено І (сума > 1000 АБО клієнт - VIP)». Це рівень «розширеного пошуку» CRM, без жодного рядка SQL з боку користувача.
use Filament\QueryBuilder\Constraints\BooleanConstraint;
use Filament\QueryBuilder\Constraints\DateConstraint;
use Filament\QueryBuilder\Constraints\NumberConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint;
use Filament\QueryBuilder\Constraints\RelationshipConstraint\Operators\IsRelatedToOperator;
use Filament\QueryBuilder\Constraints\SelectConstraint;
use Filament\QueryBuilder\Constraints\TextConstraint;
use Filament\Tables\Enums\FiltersLayout;
use Filament\Tables\Filters\QueryBuilder;
->filters([
QueryBuilder::make()
->constraints([
TextConstraint::make('customer_name'),
NumberConstraint::make('total'),
BooleanConstraint::make('is_paid'),
DateConstraint::make('created_at'),
SelectConstraint::make('status')
->options(OrderStatus::class)
->multiple(),
RelationshipConstraint::make('tags')
->multiple()
->selectable(
IsRelatedToOperator::make()
->titleAttribute('name')
->searchable()
->multiple(),
),
NumberConstraint::make('items.qty')->integer(),
]),
], layout: FiltersLayout::AboveContent)
Що дає кожне обмеження: готовий набір операторів для свого типу - «містить», «починається з», «більше», «між датами», «є / немає пов'язаних записів» тощо. Можна перевизначити список операторів і написати власні обмеження й оператори.
Коли QueryBuilder доречний:
- аналітики й менеджери, яким потрібні довільні комбінації умов, а не заздалегідь передбачені фільтри;
- таблиці з десятками полів, де окремий фільтр на кожне перетворив би панель фільтрів на стіну.
Коли краще звичайні фільтри: для 3-5 типових сценаріїв («мої», «неоплачені», «за місяць») звичайні SelectFilter/TernaryFilter зрозуміліші і швидші в роботі.
Що варто врахувати:
- продуктивність. Користувач може зібрати умову, під яку немає жодного індексу:
LIKEпо тексту,ORміж різними колонками,whereHasпо великих зв'язках. На великих таблицях варто обмежити набір обмежень полями з індексами; - зв'язки перетворюються на
whereHas- підзапити, які можуть бути дорогими; - місце. Вкладені групи потребують простору - фільтри краще винести над таблицею (
FiltersLayout::AboveContent); - збереження: у поєднанні з
persistFiltersInSession()складний фільтр не доведеться збирати щоразу.
Таблиці Filament спочатку будувалися навколо Eloquent, але з методом records() джерелом рядків може бути будь-що: масив, колекція, відповідь стороннього API.
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Http;
public function table(Table $table): Table
{
return $table
->records(function (int $page, int $recordsPerPage, ?string $sortColumn, ?string $sortDirection, ?string $search): LengthAwarePaginator {
$response = Http::baseUrl('https://api.example.com')
->get('/invoices', [
'page' => $page,
'per_page' => $recordsPerPage,
'sort' => $sortColumn,
'direction' => $sortDirection,
'q' => $search,
])
->throw()
->json();
return new LengthAwarePaginator(
collect($response['data'])->keyBy('id'),
total: $response['total'],
perPage: $recordsPerPage,
currentPage: $page,
);
})
->columns([
TextColumn::make('number')->searchable(),
TextColumn::make('amount')->money('UAH')->sortable(),
]);
}
Головна відмінність - усе вручну. Вбудовані пошук, сортування, фільтри й пагінація Filament генерують SQL. З власними даними їх немає кому виконати, тому параметри ін'єктуються в records():
$page,$recordsPerPage- для пагінації (повернутиLengthAwarePaginator);$sortColumn,$sortDirection- для сортування;$search- для пошуку;$filters- масив станів фільтрів.
Ключі масиву - ідентифікатори записів. Їх треба робити стабільними й унікальними (keyBy('id')): за ними Livewire відрізняє рядки між оновленнями, а дії отримують потрібний запис.
У колбеках колонок і дій запис - масив, а не модель: fn (array $record) => ....
Що врахувати:
- кожне оновлення таблиці (пошук, сторінка, сортування, polling) - новий HTTP-запит до API. Варто кешувати відповіді на короткий час і обробляти помилки, щоб недоступний API не ламав сторінку;
- ліміти й тайм-аути стороннього сервісу:
Http::timeout(), повтори, обмеження частоти; - дії (створення, редагування, видалення) теж пишуться вручну - через
action(), що викликає API; - авторизація лежить на вас: політики моделей тут не працюють;
- для невеликих статичних наборів (налаштування, довідники з конфіга) підходить простий масив без пагінації.
За замовчуванням масова дія завантажує всі вибрані моделі в пам'ять і передає Eloquent-колекцію в action(). Коли користувач натискає «вибрати все» на таблиці з 200 000 рядків, це легко перевищує ліміт пам'яті PHP.
Порційна обробка:
use Filament\Actions\BulkAction;
use Illuminate\Support\LazyCollection;
BulkAction::make('archive')
->chunkSelectedRecords(250)
->action(function (LazyCollection $records) {
$records->each->update(['archived_at' => now()]);
})
Записи підтягуються пачками, $records стає LazyCollection - пам'ять стала, а код майже не змінюється.
Без завантаження моделей взагалі:
use Illuminate\Support\Collection;
BulkAction::make('archive')
->fetchSelectedRecords(false)
->action(fn (Collection $records) => Order::whereKey($records)->update(['archived_at' => now()]))
Тут $records - лише ідентифікатори, а оновлення - один SQL-запит.
Ціна fetchSelectedRecords(false). Filament завантажує моделі не просто так:
- щоб перевірити політику для кожного запису (
authorizeIndividualRecords('update')); - щоб спрацювали події моделі (
updating,deleted, спостерігачі, журнал активності, очищення кешу, Scout-індексація).
Масовий update()/delete() через запит обходить і те, і те. Для DeleteBulkAction це означає: без deleting-подій не видаляться файли, не оновиться пошуковий індекс, не запишеться аудит.
Як обирати:
| Потреба | Підхід |
|---|---|
| потрібні політики й події, записів сотні | за замовчуванням |
| потрібні політики й події, записів тисячі | chunkSelectedRecords() |
| чиста операція над даними, подій немає | fetchSelectedRecords(false) |
| дуже довга операція | поставити джобу в чергу з ідентифікаторами |
Звіт про часткові невдачі. Якщо частина записів не обробилася, $action->reportBulkProcessingFailure() додасть причину в підсумкове сповіщення («3 з 50 замовлень не вдалося архівувати»), а authorizeIndividualRecords() так само повідомляє про записи, відкинуті політикою.
Довгі операції в HTTP-запиті ризикують тайм-аутом. Для них краще: дія ставить джобу (Bus::batch по пачках ідентифікаторів) і надсилає сповіщення в базу даних, коли все готово.
Сторінка списку ресурсу - Livewire-компонент, тож її тестують через livewire() з хелперами Filament.
use App\Filament\Resources\Orders\Pages\ListOrders;
use App\Models\Order;
use App\Models\User;
use Filament\Actions\Testing\TestAction;
use function Pest\Livewire\livewire;
beforeEach(fn () => $this->actingAs(User::factory()->admin()->create()));
it('lists, searches and sorts orders', function () {
$orders = Order::factory()->count(5)->create();
livewire(ListOrders::class)
->assertCanSeeTableRecords($orders)
->searchTable($orders->first()->number)
->assertCanSeeTableRecords($orders->take(1))
->assertCanNotSeeTableRecords($orders->skip(1));
livewire(ListOrders::class)
->sortTable('total', 'desc')
->assertCanSeeTableRecords($orders->sortByDesc('total'), inOrder: true);
});
it('filters by status', function () {
$paid = Order::factory()->paid()->count(2)->create();
$new = Order::factory()->count(3)->create();
livewire(ListOrders::class)
->filterTable('status', 'paid')
->assertCanSeeTableRecords($paid)
->assertCanNotSeeTableRecords($new);
});
it('archives an order from the row action', function () {
$order = Order::factory()->create();
livewire(ListOrders::class)
->callAction(TestAction::make('archive')->table($order))
->assertNotified();
expect($order->fresh()->archived_at)->not->toBeNull();
});
Масові дії - спершу вибрати рядки:
livewire(ListOrders::class)
->selectTableRecords($orders->pluck('id')->all())
->callAction(TestAction::make('archive')->table()->bulk());
Пастки:
- автентифікація обов'язкова - без
actingAs()тест отримає редирект на вхід чи 403, і зовсім не те, що перевіряє; deferLoading()- таблиця з відкладеним завантаженням не містить рядків, доки не викликати->loadTable(). Без цьогоassertCanSeeTableRecordsпадає на, здавалося б, робочому коді;- збережений у сесії стан (фільтри, сортування) може перетікати між викликами
livewire()в одному тесті; - видимість дій для різних ролей варто тестувати окремо:
assertActionHidden(TestAction::make('delete')->table($order))для користувача без прав; - тестувати варто свою логіку (власні фільтри, дії, скоупи), а не те, що Filament уміє сортувати колонку.