Питання на співбесіді з Livewire і Filament
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
113 питань
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 у маршрутах.
ImportAction дає користувачу завантажити CSV, зіставити його колонки з полями моделі й імпортувати рядки у фоні. Рядки, що не пройшли валідацію, збираються в окремий CSV «невдалих рядків» для завантаження.
Підготовка:
php artisan make:queue-batches-table
php artisan make:notifications-table
php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrate
php artisan make:filament-importer Product --generate
Імпорт використовує пакети джоб (job batches) і сповіщення в базі даних - без воркера черги нічого не відбудеться.
Імпортер:
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
class ProductImporter extends Importer
{
protected static ?string $model = Product::class;
public static function getColumns(): array
{
return [
ImportColumn::make('sku')->requiredMapping()->rules(['required', 'max:32']),
ImportColumn::make('name')->requiredMapping()->rules(['required', 'max:255']),
ImportColumn::make('price')->numeric()->rules(['numeric', 'min:0']),
ImportColumn::make('category')->relationship(resolveUsing: 'slug'),
];
}
public function resolveRecord(): ?Product
{
// оновлювати наявні товари за SKU, нові - створювати
return Product::firstOrNew(['sku' => $this->data['sku']]);
}
}
ImportAction::make()
->importer(ProductImporter::class)
->chunkSize(250)
->maxRows(50_000);
CSV ділиться на частини (за замовчуванням по 100 рядків), кожна обробляється окремою джобою.
Ризики й як їх закрити:
- немає авторизації кожного запису. Імпорт не викликає політики: хто може запустити імпорт, той створить чи оновить будь-який запис, який поверне
resolveRecord(). Для недовірених користувачів перевірки треба додати в хукиbeforeCreate()/beforeUpdate()імпортера; resolveRecord()за слабким ключем (назва, email без нормалізації) - дублікати чи перезапис чужих даних;- CSV-формули. У файлі невдалих рядків значення лишаються як були. Комірка
=HYPERLINK(...)чи=cmd|..., відкрита в Excel, може виконатися як формула. Варто попереджати користувачів або очищати значення, що починаються з=,+,-,@; - файл невдалих рядків за замовчуванням може завантажити лише користувач, що запустив імпорт. Власна політика
ImportPolicyзамінює цю логіку повністю - перевірку автора треба повторити в ній; - розмір.
maxRows()і розумнийchunkSize()захищають від файлу на мільйони рядків, що «покладе» чергу; - кастинг перед валідацією - ціни з комою (
12,50) чи дати вдд.мм.рррртреба привести вcastStateUsing(), інакше всі рядки впадуть на валідації.
ExportAction формує CSV чи XLSX у фоні: користувач вибирає колонки, отримує сповіщення, коли файл готовий, і завантажує його. Під капотом - пакети джоб і сповіщення в базі даних.
php artisan make:filament-exporter Order --generate
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
class OrderExporter extends Exporter
{
protected static ?string $model = Order::class;
public static function getColumns(): array
{
return [
ExportColumn::make('number'),
ExportColumn::make('customer.name')->label('Клієнт'),
ExportColumn::make('items_count')->counts('items'),
ExportColumn::make('total'),
ExportColumn::make('created_at'),
];
}
}
use Filament\Actions\ExportAction;
use Filament\Actions\Exports\Enums\ExportFormat;
use Illuminate\Database\Eloquent\Builder;
ExportAction::make()
->exporter(OrderExporter::class)
->formats([ExportFormat::Xlsx, ExportFormat::Csv])
->modifyQueryUsing(fn (Builder $query) => $query->whereBelongsTo(auth()->user()->team))
->fileDisk('s3')
->chunkSize(500)
->maxRows(100_000);
Звідки беруться дані. У таблиці експорт бере поточний запит таблиці - з пошуком, фільтрами й сортуванням. Користувач експортує те, що бачить у списку (усі сторінки).
Безпека:
- політики для кожного запису не перевіряються. Якщо таблиця показує лише «свої» записи завдяки скоупу ресурсу - добре. Але експорт поза таблицею (з кнопки на сторінці) бере всю модель. Обмеження варто дублювати в
modifyQueryUsing()або вmodifyQuery()експортера; - диск. Готові файли зберігаються на диску. Filament навмисно уникає публічного диска (якщо диск за замовчуванням
publicі єlocal, експорт піде вlocal), але на продакшені краще явно вказати приватнийs3/r2; - CSV-формули. Значення з бази потрапляють у файл як є. Текст, що починається з
=,+,-,@і вводився користувачами, Excel може виконати. Захист -formatStateUsing()на колонках з довільним текстом (наприклад, префікс'); - чутливі колонки (телефони, email, суми) варто не додавати в експортер «про всяк випадок» - користувач вибирає з того, що ви дозволили.
Продуктивність:
- експорт ділиться на частини (за замовчуванням по 100 рядків) - для простих рядків
chunkSize()можна збільшити, для важких зменшити; - зв'язки й агрегати в колонках варто жадібно завантажити в
modifyQuery()експортера, інакше кожна частина робить N+1; maxRows()не дасть випадково поставити в чергу мільйонний експорт.
У Filament три способи доставити сповіщення, і вони розв'язують різні задачі:
| Спосіб | Куди | Зберігається | Коли отримує |
|---|---|---|---|
send() |
поточний користувач, через сесію | ні | на наступному рендері |
sendToDatabase($user) |
таблиця notifications |
так | при опитуванні (30 с) чи через вебсокет |
broadcast($user) |
вебсокет-канал користувача | ні | миттєво, якщо сторінка відкрита |
Broadcast-сповіщення - тимчасовий «тост», що з'являється в браузері користувача в реальному часі, але ніде не зберігається. Якщо вкладка закрита - сповіщення втрачено.
Notification::make()
->title('Експорт готовий')
->success()
->broadcast($user);
Налаштування (окрім звичайного broadcasting у Laravel - Reverb чи Pusher):
- опублікувати конфіг Filament (
php artisan vendor:publish --tag=filament-config); - у
config/filament.phpрозкоментувати й заповнити розділbroadcasting.echo; - переконатися, що в
.envє потрібні змінніVITE_*; - мати запущені воркер черги (сповіщення ставиться в чергу) і вебсокет-сервер.
Поєднання найкращого з двох світів - сповіщення в базі даних з миттєвою доставкою:
Notification::make()
->title('Новий відгук на вакансію')
->sendToDatabase($recruiter, isEventDispatched: true);
Запис зберігається в базі (видно в дзвіночку й пізніше), а подія через вебсокет змушує панель одразу підтягнути нові сповіщення, без очікування опитування. Після цього опитування можна вимкнути: databaseNotificationsPolling(null).
Що обирати:
- результат дії, яку користувач щойно виконав, -
send(); - щось, що користувач має побачити навіть пізніше (призначене завдання, відгук, результат імпорту) -
sendToDatabase(); - миттєва, але неважлива подія для відкритої сторінки (хтось редагує той самий запис) -
broadcast().
Пастки:
- приватні канали авторизуються через
routes/channels.php- перевірте, що користувач отримує лише свій канал; - черга й вебсокет-сервер - дві окремі точки відмови: без воркера сповіщення не відправиться, без Reverb - не дійде;
- опитування на сотнях вкладок часто дорожче за один вебсокет-сервер - на великих панелях broadcast окупається.
Дії тестують через livewire() на тій сторінці чи компоненті, де вони розміщені.
Дія на сторінці (наприклад, у шапці сторінки редагування):
use App\Filament\Resources\Orders\Pages\EditOrder;
use App\Models\Order;
use function Pest\Livewire\livewire;
it('cancels an order', function () {
$order = Order::factory()->paid()->create();
livewire(EditOrder::class, ['record' => $order->getRouteKey()])
->callAction('cancel', data: ['reason' => 'Клієнт передумав'])
->assertHasNoFormErrors()
->assertNotified('Замовлення скасовано');
expect($order->refresh()->status)->toBe(OrderStatus::Cancelled);
});
Дія в рядку таблиці - через TestAction:
use Filament\Actions\Testing\TestAction;
livewire(ListOrders::class)
->callAction(TestAction::make('cancel')->table($order), data: ['reason' => '...']);
// масова дія
livewire(ListOrders::class)
->selectTableRecords($orders->pluck('id')->all())
->callAction(TestAction::make('cancel')->table()->bulk());
Валідація даних модального вікна:
livewire(EditOrder::class, ['record' => $order->getRouteKey()])
->callAction('cancel', data: ['reason' => ''])
->assertHasFormErrors(['reason' => 'required']);
Зупинка дії ($action->halt() у хуку):
livewire(EditOrder::class, ['record' => $shipped->getRouteKey()])
->callAction('cancel')
->assertActionHalted('cancel');
Видимість і права:
$this->actingAs(User::factory()->support()->create());
livewire(ListOrders::class)
->assertActionHidden(TestAction::make('refund')->table($order))
->assertActionVisible(TestAction::make('view')->table($order));
Сповіщення:
->assertNotified()/->assertNotified('Заголовок')/->assertNotNotified()- для сповіщень через сесію;- сповіщення в базі даних - це звичайні сповіщення Laravel:
Notification::fake()(фасад Laravel) іassertSentTo($user, \Filament\Notifications\DatabaseNotification::class).
Що варто перевіряти обов'язково:
- заборону, а не лише успіх: користувач без прав не бачить дію, а підроблений виклик нічого не змінює в базі;
- побічні ефекти через фейки:
Mail::fake(),Queue::fake(),Bus::fake(), - а не реальні листи й джоби; - для дій у шапці таблиці -
TestAction::make('export')->table()без запису.
Коли ресурсів і сторінок стають десятки, бічна навігація перетворюється на довгий список. Filament дає два рівні структури.
Кластери - групують ресурси й сторінки в одному пункті навігації з власною піднавігацією:
// AdminPanelProvider
$panel->discoverClusters(in: app_path('Filament/Clusters'), for: 'App\Filament\Clusters');
php artisan make:filament-cluster Settings
class SettingsCluster extends Cluster
{
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCog6Tooth;
protected static ?SubNavigationPosition $subNavigationPosition = SubNavigationPosition::Top;
}
// у ресурсі чи сторінці
protected static ?string $cluster = SettingsCluster::class;
- у головній навігації - один пункт «Налаштування», а всередині - вкладки чи бічне меню з ресурсами кластера;
- URL і назви маршрутів отримують префікс кластера (
/admin/settings/currencies); - кластер видно в навігації лише тоді, коли користувач має доступ хоча б до одного його компонента, а відкриття самого кластера переводить на перший доступний.
Кілька панелей - окремі «застосунки» всередині одного Laravel:
php artisan make:filament-panel partner
return $panel
->id('partner')
->path('partner')
->authGuard('partner')
->discoverResources(in: app_path('Filament/Partner/Resources'), for: 'App\Filament\Partner\Resources');
Кожна панель має свій шлях, свій набір ресурсів, власну тему, навігацію, middleware і навіть гард автентифікації.
Що обрати:
| Ситуація | Рішення |
|---|---|
| одна аудиторія, багато розділів | кластери (і групи навігації) |
| різні аудиторії: адміністратори, партнери, клієнти | окремі панелі |
| різні моделі користувачів чи способи входу | окремі панелі з різними гардами |
| однакові дані з різними правами | одна панель + політики, або панелі з різними ресурсами для однієї моделі |
Доступ до панелей - найважливіше місце. Модель користувача реалізує FilamentUser:
public function canAccessPanel(Panel $panel): bool
{
return match ($panel->getId()) {
'admin' => $this->is_admin,
'partner' => $this->partner_id !== null,
default => false,
};
}
Без цієї перевірки на продакшені будь-який зареєстрований користувач потрапить у будь-яку панель (локально Filament пускає всіх - звідси класична пастка).
Ризики:
- панель не ізолює дані: ресурс партнерської панелі все одно має обмежувати запит своїми записами (глобальна область видимості,
modifyQueryUsing, мультиорендність Filament), інакше зміна ID в адресі покаже чужий запис; - дублювання ресурсів для різних панелей - виносьте спільні форми й таблиці в окремі класи схем;
- кластери й панелі не замінюють політики - вони керують навігацією, а не правами на дії.
Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
- Теми
- Livewire 21 Filament 19 Дії, інфолисти й віджети 13 Продуктивність Livewire 12 Події, Alpine і навігація 12 Таблиці Filament 12 Форми й схеми Filament 12 Форми й валідація 12
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії