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

Питання на співбесіді: Форми й схеми Filament

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

12 питань

У Filament 5 форми, infolists і макети - це схеми (Filament\Schemas\Schema). Поля форми живуть у Filament\Forms\Components, а макетні компоненти (Section, Grid, Tabs, Fieldset, Wizard) - у Filament\Schemas\Components.

use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;

public static function configure(Schema $schema): Schema
{
    return $schema
        ->components([
            Section::make('Основне')
                ->schema([
                    TextInput::make('title')->required()->maxLength(255),
                    TextInput::make('slug')->required(),
                    RichEditor::make('body')->columnSpanFull(),
                ])
                ->columns(2)
                ->columnSpanFull(),

            Section::make('Публікація')
                ->schema([
                    Select::make('status')->options(PostStatus::class)->required(),
                ]),
        ]);
}

Сітка - головне, що треба розуміти:

  • сторінки створення й редагування ресурсу за замовчуванням розкладають компоненти у дві колонки;
  • Section, Grid, Fieldset - теж звичайні елементи цієї сітки й займають одну колонку. Тому секція раптом виявляється на половину ширини;
  • columnSpanFull() - розтягнути компонент на всю ширину;
  • columns(2) на секції - внутрішня сітка для її полів;
  • columnSpan(['md' => 2, 'xl' => 1]) - різна ширина на різних екранах (брейкпоінти Tailwind).

Інші макетні компоненти:

  • Tabs - вкладки для довгих форм (стан можна зберігати в URL);
  • Fieldset - рамка з підписом для невеликої групи полів;
  • Grid - сітка без візуального оформлення;
  • Wizard - покрокова форма.

Пастки при оновленні з v3: старі простори імен Filament\Forms\Components\Section чи Filament\Forms\Form у v5 не працюють - макети переїхали в Filament\Schemas\Components, а метод форми приймає Schema $schema і повертає $schema->components([...]).

Докладніше в документації: Макети схем

Валідацію описують методами поля - це ті самі правила Laravel, але з підказками в інтерфейсі (зірочка обов'язкового поля, maxlength в інпуті).

use Filament\Forms\Components\TextInput;

TextInput::make('email')
    ->email()
    ->required()
    ->maxLength(255)
    ->unique(),

TextInput::make('price')
    ->numeric()
    ->minValue(0)
    ->rules(['decimal:0,2']),          // будь-яке правило Laravel

TextInput::make('slug')
    ->required()
    ->regex('/^[a-z0-9-]+$/')
    ->validationMessages([
        'regex' => 'Лише малі латинські літери, цифри й дефіс.',
    ]),

Валідація запускається під час відправки форми. Помилки показуються під полями, а збереження не відбувається.

unique() у ресурсі. Форма знає свою модель і поточний запис, тож на сторінці редагування unique() автоматично ігнорує запис, що редагується: email користувача не «конфліктує» сам із собою. Якщо це не потрібно - unique(ignoreRecord: false).

Пастка з unique(): правило Laravel звертається до таблиці напряму, оминаючи Eloquent. Тому:

  • м'яко видалені записи теж вважаються зайнятими;
  • мультиорендність не враховується - email буде «зайнятим», якщо він є в іншого тенанта.

Для цього є scopedUnique() - перевірка через модель з усіма глобальними скоупами, включно з SoftDeletes і тенантами.

Умовна валідація:

TextInput::make('password')
    ->password()
    ->required(fn (string $operation): bool => $operation === 'create');

$operation - create, edit або view.

Що варто знати:

  • правила лише на клієнті не існує: усе перевіряється на сервері, тож підробити maxlength у DevTools не допоможе;
  • поле, яке не зберігається (saved(false)), все одно валідується;
  • для повідомлень українською досить локалізації Laravel (lang/uk/validation.php) - Filament використовує ті самі переклади.

Докладніше в документації: Валідація

Select з методом relationship() сам завантажує варіанти зі зв'язку і зберігає вибір.

BelongsTo (один автор):

use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;

Select::make('author_id')
    ->relationship('author', 'name')
    ->searchable()
    ->preload()
    ->createOptionForm([
        TextInput::make('name')->required(),
        TextInput::make('email')->email()->required(),
    ])
    ->required();
  • searchable() - пошук по варіантах (за замовчуванням по колонці-назві; searchable(['name', 'email']) - по кількох);
  • preload() - завантажити варіанти разом зі сторінкою, а не під час введення. Добре для десятків записів, погано для десятків тисяч;
  • createOptionForm() - кнопка «+», модальне вікно створення нового автора, який одразу стає вибраним.

BelongsToMany (кілька тегів) - додати multiple():

Select::make('tags')
    ->multiple()
    ->relationship(titleAttribute: 'name')
    ->preload();

Filament сам синхронізує pivot-таблицю під час збереження форми.

Звуження варіантів:

Select::make('category_id')
    ->relationship('category', 'name', fn (Builder $query) => $query->where('is_active', true));

Що Filament перевіряє сам. Для Select автоматично діє правило «значення має бути серед дозволених варіантів». Підставити в запиті id запису, якого немає у варіантах (наприклад, неактивну категорію), не вийде.

Пастки:

  • без searchable() на великій таблиці Select намагається вивантажити всі записи у список;
  • для рекурсивних зв'язків (parent_id) потрібен relationship(..., ignoreRecord: true), щоб запис не можна було зробити батьком самого себе;
  • якщо поле multiple() зі зв'язком треба зробити disabled(), викликати disabled() слід до relationship() - інакше вимкнене поле все одно збереже зв'язок.

Докладніше в документації: Select: зв'язки

FileUpload завантажує файл через Livewire у тимчасове сховище, а під час збереження форми переносить його на диск і записує в атрибут моделі шлях до файлу.

use Filament\Forms\Components\FileUpload;

FileUpload::make('cover')
    ->image()
    ->disk('public')
    ->directory('covers')
    ->visibility('public')
    ->maxSize(2048);          // кілобайти

Чому зображення не відкривається - найчастіші причини:

  1. Видимість private за замовчуванням. Файли завантажуються з приватною видимістю, якщо диск не public. На S3 чи R2 без ->visibility('public') пряме посилання на файл повертає 403.
  2. Диск за замовчуванням - той, що в FILESYSTEM_DISK, часто local. Файли з нього взагалі не віддаються вебсервером.
  3. Немає storage:link для диска public.
  4. Неправильний APP_URL - URL файлів будується з нього, і превью в FilePond не завантажується.

Кілька файлів:

FileUpload::make('attachments')
    ->multiple()
    ->acceptedFileTypes(['application/pdf'])
    ->maxFiles(5);

Шляхи зберігаються JSON-масивом, тож атрибут моделі потребує касту array.

Безпека імен файлів. За замовчуванням Filament генерує випадкові імена - і це правильно. preserveFilenames() на дисках local/public небезпечний: користувач може завантажити файл з розширенням .php. Якщо оригінальна назва потрібна для показу, її зберігають окремо: ->storeFileNamesIn('attachment_names').

Пастки:

  • старі файли не видаляються. Коли користувач замінює чи прибирає файл, попередній лишається на диску. Прибирати треба самостійно, наприклад у спостерігачі моделі;
  • ліміт розміру обмежує ще й Livewire (temporary_file_upload у config/livewire.php, за замовчуванням 12 МБ) та upload_max_filesize у PHP - maxSize() більший за ці значення не допоможе;
  • acceptedFileTypes() перевіряє MIME-тип, а не розширення - покладатися лише на нього для безпеки не варто.

Докладніше в документації: Завантаження файлів

За замовчуванням зміна поля не відправляє запит на сервер - стан піде разом із наступним запитом чи відправкою форми. Щоб форма перебудувалася одразу після зміни поля, його роблять 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 рендерить усі елементи в одній формі, і велика кількість робить її повільною.

Докладніше в документації: 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(), не валідується і не зберігається - зручно для умовних гілок, але легко пропустити обов'язкові дані.

Докладніше в документації: Майстри

Кожне live()-поле після зміни робить запит на сервер, і за замовчуванням перерендерюється весь Livewire-компонент - вся форма з усіма секціями, повторювачами, опціями селектів. На формі з сотнею полів це сотні мілісекунд на кожну зміну, і інтерфейс відчувається «важким».

1. Перемальовувати лише те, що залежить від зміни:

TextInput::make('name')
    ->live(onBlur: true)
    ->partiallyRenderComponentsAfterStateUpdated(['email']);   // лише поле email

TextInput::make('quantity')
    ->live(debounce: 500)
    ->partiallyRenderAfterStateUpdated()                       // лише саме поле
    ->belowContent(fn (Get $get): string => 'Разом: ' . $get('quantity') * $get('price'));

2. Не рендерити взагалі, якщо потрібна лише серверна дія:

TextInput::make('search')
    ->live(debounce: 300)
    ->skipRenderAfterStateUpdated()
    ->afterStateUpdated(fn (?string $state) => /* записати в лог, кеш тощо */ null);

3. Перенести логіку в браузер - без запиту зовсім:

Select::make('role')
    ->options(['user' => 'Користувач', 'staff' => 'Персонал']);

Toggle::make('is_admin')
    ->hiddenJs(<<<'JS'
        $get('role') !== 'staff'
        JS);

TextInput::make('name')
    ->afterStateUpdatedJs(<<<'JS'
        $set('slug', ($state ?? '').toLowerCase().replaceAll(' ', '-'))
        JS);

hiddenJs(), visibleJs(), afterStateUpdatedJs() виконуються в Alpine на клієнті миттєво.

Безпека JS-варіантів: рядок з JavaScript виконується в браузері, тож ніколи не вставляйте в нього дані користувача конкатенацією - це XSS. Використовувати $state і $get() як значення безпечно.

4. Інші джерела повільності:

  • options() із запитом без кешу - перераховуються на кожному рендері. Для довгих списків - searchable() з getSearchResultsUsing();
  • Repeater на сотні елементів - кожен рендериться повністю. Тут краще relation manager;
  • важкі замикання у label(), helperText(), visible() - обчислюються на кожному рендері, зокрема запити в базу;
  • preload() великих зв'язків.

Як шукати вузьке місце: вкладка Network - розмір і час відповіді запиту livewire/update; Laravel Debugbar чи Telescope - запити в базу під час одного оновлення форми.

Докладніше в документації: Рендеринг полів

RichEditor у Filament 5 побудований на TipTap. За замовчуванням він зберігає HTML, а з json() - структурований JSON у форматі TipTap (модель потребує касту array).

use Filament\Forms\Components\RichEditor;

RichEditor::make('content')
    ->json()
    ->fileAttachmentsDisk('s3')
    ->fileAttachmentsDirectory('posts')
    ->fileAttachmentsVisibility('private');

Головна загроза - XSS. Редактор надсилає на сервер сирий HTML, і зловмисник може перехопити запит і підставити будь-яку розмітку з <script> чи onerror. Тобто вміст редактора - недовірені дані, навіть якщо його вводять лише адміністратори.

Як виводити:

  • у компонентах Filament (TextColumn::make('content')->html(), TextEntry з html()/markdown()) вміст санітизується автоматично;
  • у власному Blade - це ваша відповідальність:
{!! str($post->content)->sanitizeHtml() !!}
  • для JSON-вмісту, приватних зображень чи власних блоків - RichContentRenderer, що теж санітизує:
use Filament\Forms\Components\RichEditor\RichContentRenderer;

RichContentRenderer::make($post->content)
    ->fileAttachmentsDisk('s3')
    ->fileAttachmentsVisibility('private')
    ->toHtml();

Нюанс санітайзера: він пропускає атрибути style (потрібні для кольору тексту, підсвітки, розміру зображень). Тож CSS на кшталт position: fixed чи background: url(...) переживе очищення. Для вмісту від сторонніх користувачів (коментарі, профілі) варто налаштувати суворіший санітайзер.

Зображення в редакторі:

  • за замовчуванням зберігаються публічно - так вміст можна вивести будь-де простим посиланням;
  • з private у HTML зберігається не URL, а ідентифікатор файлу в атрибуті data-id, і тимчасові підписані URL генеруються під час рендеру. Тому приватні зображення обов'язково виводити через RichContentRenderer з тими самими налаштуваннями диска;
  • data-id теж приходить від клієнта: якщо на тому самому диску лежать чужі приватні файли, підмінений ідентифікатор може «підтягнути» чужий файл. Окремий диск чи каталог під вкладення редактора зменшує ризик.

HTML чи JSON: HTML простіше виводити й шукати; JSON зручніший, коли вміст треба програмно обробляти (власні блоки, теги злиття, рендер в інші формати).

Докладніше в документації: Rich editor

Власне поле потрібне, коли жоден вбудований компонент не підходить: вибір точки на карті, редактор розкладу, інтеграція сторонньої JS-бібліотеки.

php artisan make:filament-form-field LocationPicker

Клас поля:

use Closure;
use Filament\Forms\Components\Field;

class LocationPicker extends Field
{
    protected string $view = 'filament.forms.components.location-picker';

    protected float | Closure | null $zoom = null;

    public function zoom(float | Closure | null $zoom): static
    {
        $this->zoom = $zoom;

        return $this;
    }

    public function getZoom(): ?float
    {
        return $this->evaluate($this->zoom);   // підтримка замикань з утилітами
    }
}

Шаблон поля:

<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
    <div
        x-data="{ state: $wire.{{ $applyStateBindingModifiers("\$entangle('{$getStatePath()}')") }} }"
        x-init="initMap($refs.map, state, {{ $getZoom() ?? 10 }})"
    >
        <div x-ref="map" class="h-64"></div>
    </div>
</x-dynamic-component>

Ключові моменти:

  • $getStatePath() - шлях до властивості Livewire, де живе значення поля (data.location). Саме через нього поле читає й пише стан;
  • $applyStateBindingModifiers() - щоб поле поважало live(), live(onBlur: true) тощо, як вбудовані. Без цього ->live() на вашому полі нічого не змінить;
  • $getFieldWrapperView() - стандартна обгортка з підписом, підказкою й помилками валідації;
  • $this->evaluate() в гетері - дозволяє передавати не лише значення, а й замикання з Get, $record, $operation.

Чого не робити:

  • поле - не Livewire-компонент. Публічні властивості й методи класу поля в Blade недоступні як wire:model чи wire:click. Потрібну конфігурацію віддають через гетери ($getZoom());
  • щоб викликати PHP-метод поля з JavaScript, його позначають атрибутом #[ExposedLivewireMethod] і викликають через $wire.callSchemaComponentMethod(). Без атрибута метод викликати не можна - це захист від виконання довільних методів;
  • важкі JS-бібліотеки не варто підключати глобально на кожну сторінку панелі - Filament уміє асинхронно завантажувати Alpine-компоненти через систему ресурсів, лише там, де поле є.

Валідація працює як для будь-якого поля: ->required(), ->rules([...]) на рівні PHP.

Докладніше в документації: Власні поля

Сторінки створення й редагування ресурсу - Livewire-компоненти, і Filament додає до livewire() хелпери для форм.

Створення запису:

use App\Filament\Resources\Posts\Pages\CreatePost;
use App\Filament\Resources\Posts\Pages\EditPost;
use App\Models\Post;
use App\Models\User;

use function Pest\Laravel\assertDatabaseHas;
use function Pest\Livewire\livewire;

beforeEach(fn () => $this->actingAs(User::factory()->admin()->create()));

it('creates a post', function () {
    livewire(CreatePost::class)
        ->fillForm([
            'title' => 'Нова стаття',
            'slug' => 'nova-stattia',
            'status' => 'draft',
        ])
        ->call('create')
        ->assertHasNoFormErrors()
        ->assertNotified()
        ->assertRedirect();

    assertDatabaseHas(Post::class, ['slug' => 'nova-stattia']);
});

Валідація:

it('rejects a duplicate slug', function () {
    Post::factory()->create(['slug' => 'taken']);

    livewire(CreatePost::class)
        ->fillForm(['title' => 'X', 'slug' => 'taken'])
        ->call('create')
        ->assertHasFormErrors(['slug' => 'unique']);
});

Редагування й заповнення форми:

it('fills the edit form', function () {
    $post = Post::factory()->create();

    livewire(EditPost::class, ['record' => $post->getRouteKey()])
        ->assertSchemaStateSet([
            'title' => $post->title,
        ])
        ->fillForm(['title' => 'Оновлено'])
        ->call('save')
        ->assertHasNoFormErrors();

    expect($post->refresh()->title)->toBe('Оновлено');
});

Видимість і реактивність:

livewire(CreatePost::class)
    ->fillForm(['status' => 'draft'])
    ->assertFormFieldHidden('published_at')
    ->fillForm(['status' => 'scheduled'])
    ->assertFormFieldVisible('published_at');

Майстри: goToNextWizardStep() і assertWizardCurrentStep(2); помилки кроку перевіряються тим самим assertHasFormErrors().

На що звернути увагу:

  • тестуйте валідацію Select: Filament за замовчуванням не приймає значення поза списком варіантів, і тест підтверджує, що підставлений id не пройде;
  • права: окремий тест, що користувач без політики create отримує 403 на сторінці створення;
  • mutateFormDataBeforeCreate() перевіряється через стан бази: чи записався user_id, якого у формі немає;
  • ключ запису передається як getRouteKey(), а не id, якщо ресурс використовує slug у маршрутах.

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