Middle: питання на співбесіді з теми «Таблиці Filament»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Найчастіша причина повільної таблиці в адмінці - запит на кожен рядок для підрахунку пов'язаних записів. 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.