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