Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Таблиці 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 уміє сортувати колонку.

Докладніше в документації: Тестування таблиць