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

Питання на співбесіді з 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 зручніший, коли вміст треба програмно обробляти (власні блоки, теги злиття, рендер в інші формати).

Докладніше в документації: 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 у маршрутах.

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

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):

  1. опублікувати конфіг Filament (php artisan vendor:publish --tag=filament-config);
  2. у config/filament.php розкоментувати й заповнити розділ broadcasting.echo;
  3. переконатися, що в .env є потрібні змінні VITE_*;
  4. мати запущені воркер черги (сповіщення ставиться в чергу) і вебсокет-сервер.

Поєднання найкращого з двох світів - сповіщення в базі даних з миттєвою доставкою:

Notification::make()
    ->title('Новий відгук на вакансію')
    ->sendToDatabase($recruiter, isEventDispatched: true);

Запис зберігається в базі (видно в дзвіночку й пізніше), а подія через вебсокет змушує панель одразу підтягнути нові сповіщення, без очікування опитування. Після цього опитування можна вимкнути: databaseNotificationsPolling(null).

Що обирати:

  • результат дії, яку користувач щойно виконав, - send();
  • щось, що користувач має побачити навіть пізніше (призначене завдання, відгук, результат імпорту) - sendToDatabase();
  • миттєва, але неважлива подія для відкритої сторінки (хтось редагує той самий запис) - broadcast().

Пастки:

  • приватні канали авторизуються через routes/channels.php - перевірте, що користувач отримує лише свій канал;
  • черга й вебсокет-сервер - дві окремі точки відмови: без воркера сповіщення не відправиться, без Reverb - не дійде;
  • опитування на сотнях вкладок часто дорожче за один вебсокет-сервер - на великих панелях broadcast окупається.

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

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

Питання з реальних технічних співбесід - 113 питань у 8 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 41 Senior 36

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії