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