Питання на співбесіді: Форми й схеми 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()- інакше вимкнене поле все одно збереже зв'язок.
FileUpload завантажує файл через Livewire у тимчасове сховище, а під час збереження форми переносить його на диск і записує в атрибут моделі шлях до файлу.
use Filament\Forms\Components\FileUpload;
FileUpload::make('cover')
->image()
->disk('public')
->directory('covers')
->visibility('public')
->maxSize(2048); // кілобайти
Чому зображення не відкривається - найчастіші причини:
- Видимість
privateза замовчуванням. Файли завантажуються з приватною видимістю, якщо диск неpublic. На S3 чи R2 без->visibility('public')пряме посилання на файл повертає 403. - Диск за замовчуванням - той, що в
FILESYSTEM_DISK, частоlocal. Файли з нього взагалі не віддаються вебсервером. - Немає
storage:linkдля дискаpublic. - Неправильний
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 рендерить усі елементи в одній формі, і велика кількість робить її повільною.
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 зручніший, коли вміст треба програмно обробляти (власні блоки, теги злиття, рендер в інші формати).
Власне поле потрібне, коли жоден вбудований компонент не підходить: вибір точки на карті, редактор розкладу, інтеграція сторонньої 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 у маршрутах.