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

Senior: питання на співбесіді з теми «Filament»

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

6 питань

Filament будує таблицю з Eloquent-запиту, тож усі звичайні проблеми продуктивності лишаються - але ховаються за декларативним синтаксисом.

N+1 у колонках. Колонка зі звʼязком робить запит на рядок:

TextColumn::make('company.name')   // + 1 запит на кожен рядок

Лікується модифікацією запиту таблиці:

public static function getEloquentQuery(): Builder
{
    return parent::getEloquentQuery()->with(['company', 'technologies']);
}

Лічильники. ->counts('vacancies') замість завантаження всієї колекції заради count().

Дорогі обчислення в state(). Замикання виконується для кожного рядка, тож звернення до бази всередині - те саме N+1, лише написане інакше.

Сортування й пошук за звʼязком роблять join, і без індексу на зовнішньому ключі це помітно:

TextColumn::make('company.name')
    ->searchable()      // where по приєднаній таблиці
    ->sortable()

Що ще сповільнює адмінку:

  • ->paginated([10, 25, 50, 'all']) з опцією all на великій таблиці вивантажує все в памʼять одним кліком користувача.
  • Глобальний пошук по багатьох ресурсах виконує запит на кожен зареєстрований ресурс.
  • Мініатюри без кешу: ImageColumn, що генерує прев'ю на льоту, робить це для кожного рядка.
  • Опції Select без ->searchable() вивантажують усі записи довідника у форму.

Як шукати причину: Debugbar або Telescope на сторінці ресурсу покажуть кількість запитів. Таблиця на 25 рядків має вкладатися в одиниці запитів; десятки - ознака незавантаженого звʼязку.

Multi-tenancy у Filament - це панель, де кожен користувач працює в межах «тенанта» (команди, компанії, організації), і бачить лише його дані.

public function panel(Panel $panel): Panel
{
    return $panel->tenant(Team::class);
}

Модель користувача реалізує HasTenants: getTenants() повертає тенантів, до яких у нього є доступ, canAccessTenant() - чи можна відкрити конкретного. Поточний тенант - у URL панелі й доступний через Filament::getTenant().

Що Filament робить автоматично:

  • Глобальний скоуп на запити ресурсів панелі: таблиця показує лише записи поточного тенанта, а чужий запис за URL дає 404.
  • Прив'язка нових записів до тенанта при створенні через ресурс.

Де дані можуть протекти:

  • Моделі без ресурсу в панелі скоуп не отримують. Запит до них у віджеті чи власній сторінці поверне дані всіх тенантів. Рішення - tenant-middleware, що додає глобальні скоупи, або явна фільтрація.
  • Код поза панеллю (черги, команди, API, сервіс-провайдери) не знає поточного тенанта - там скоупу немає.
  • withoutGlobalScopes() без аргументів знімає й скоуп тенанта.
  • Валідація unique і exists у Laravel не використовує Eloquent-скоупи, тож «email уже зайнятий» перевіриться по всіх тенантах. Для повної ізоляції - scopedUnique() / scopedExists().
  • Власні властивості Livewire-сторінок з моделями Filament повторно не запитує й не авторизує - їх треба перевіряти самостійно.

Висновок: вбудована tenancy - зручний інструмент, але не гарантія ізоляції. Документація Filament прямо попереджає: безпека реалізації - відповідальність розробника. Тести на «користувач тенанта A не бачить даних тенанта B» для кожного ресурсу й віджета - обов'язкові.

Докладніше в документації: Multi-tenancy

Filament 5 має вбудовану багатофакторну автентифікацію (MFA) з двома провайдерами: застосунок-автентифікатор (TOTP-коди, Google Authenticator, 1Password) і коди на email.

Застосунок-автентифікатор:

Schema::table('users', function (Blueprint $table) {
    $table->text('app_authentication_secret')->nullable();
    $table->text('app_authentication_recovery_codes')->nullable();
});
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthentication;
use Filament\Auth\MultiFactor\App\Concerns\InteractsWithAppAuthenticationRecovery;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;

class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery
{
    use InteractsWithAppAuthentication;
    use InteractsWithAppAuthenticationRecovery;
}
use Filament\Auth\MultiFactor\App\AppAuthentication;

$panel->multiFactorAuthentication([
    AppAuthentication::make()->recoverable(),
], isRequired: true);

Користувачі налаштовують MFA на сторінці профілю, тож у панелі має бути ввімкнено ->profile(). isRequired: true змушує налаштувати MFA перед роботою з панеллю. recoverable() дає 8 кодів відновлення на випадок втрати телефона.

Важливі деталі:

  • трейт додає секрету каст encrypted і ховає його із серіалізації. Секрет зашифровано ключем APP_KEY: якщо змінити ключ без APP_PREVIOUS_KEYS, усі налаштовані автентифікатори перестануть працювати;
  • коди відновлення одноразові - використаний код видаляється, а нові користувач генерує в профілі;
  • перевірка MFA відбувається до входу в систему, тож окремий middleware на маршрути панелі не потрібен.

Обмеження, про які варто пам'ятати:

  • MFA захищає лише вхід через панель. Якщо застосунок має власний вхід (сайт, API), користувач, авторизований там, відкриє панель без другого фактора, якщо MFA для нього вже налаштовано, але не вимагається повторно;
  • email-коди слабші за застосунок: захищеність дорівнює захищеності поштової скриньки;
  • адміністраторам із доступом до чутливих даних MFA варто робити обов'язковою, а не опційною.

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

Filament бере на себе багато перевірок, але не всі. Корисно чітко знати межу.

Що Filament робить сам:

  • доступ до панелі - через canAccessPanel() (на продакшені обов'язковий);
  • політики ресурсів - viewAny, create, update, delete перевіряються для сторінок і вбудованих дій;
  • приховані й вимкнені дії не можна викликати навіть підробленим запитом: дія, для якої visible() повернув false, вважається вимкненою й не виконується на сервері;
  • вимкнені поля (disabled()) не зберігаються, якщо явно не дозволити saved();
  • Select за замовчуванням перевіряє, що обране значення є серед дозволених опцій;
  • HTML у TextColumn/TextEntry з html() чи markdown() санітизується.

Що лишається на вас:

  • власний Blade. Якщо виводите HTML з RichEditor у своєму шаблоні - санітизуйте (str($html)->sanitizeHtml()) або використовуйте RichContentRenderer. Санітайзер Filament пропускає атрибути style, тож для недовіреного вмісту може знадобитися суворіший;
  • імпорт і експорт не перевіряють політики для кожного запису. Користувач з доступом до експорту отримає все, що поверне запит, - обмежуйте його через modifyQueryUsing();
  • фільтри списків - не межа безпеки. Звуження опцій у модальній таблиці (tableSelect) лише відображення; дозволені записи задають запитом на кшталт recordSelectOptionsQuery();
  • unique() не враховує глобальні скоупи й тенантів - для мультиорендних даних є scopedUnique();
  • файли. preserveFilenames() на дисках local/public відкриває шлях до завантаження виконуваних файлів - краще випадкові імена й storeFileNamesIn();
  • CSV-формули. Значення, що починаються з =, +, -, @, у файлах експорту чи «невдалих рядків» імпорту Excel може виконати як формулу.

Окремий нюанс - middleware. Панель збирає власний стек middleware і не використовує групу web застосунку. Middleware, додане в web (наприклад, заголовки CSP), на маршрутах панелі не спрацює - його треба реєструвати в $panel->middleware([...]).

Практичний підсумок: політика на кожну модель ресурсу, canAccessPanel() з перевіркою конкретної панелі, і обережність скрізь, де дані виходять за межі панелі: свої шаблони, файли, експорт.

Докладніше в документації: Безпека

Плагін панелі - клас, що реалізує інтерфейс Filament\Contracts\Plugin і вміє додати до панелі ресурси, сторінки, віджети, тему, render hooks. Так розповсюджують пакети (блог, журнал активності, ролі), а в проєкті так зручно ділити велику адмінку на модулі.

use Filament\Contracts\Plugin;
use Filament\Panel;

class BlogPlugin implements Plugin
{
    protected bool $hasAuthorResource = true;

    public static function make(): static
    {
        return app(static::class);
    }

    public function getId(): string
    {
        return 'acme-blog';
    }

    public function authorResource(bool $condition = true): static
    {
        $this->hasAuthorResource = $condition;

        return $this;
    }

    public function register(Panel $panel): void
    {
        $panel
            ->resources(array_filter([
                PostResource::class,
                $this->hasAuthorResource ? AuthorResource::class : null,
            ]))
            ->pages([BlogSettings::class]);
    }

    public function boot(Panel $panel): void
    {
        // код, потрібний лише коли панель справді використовується
    }
}
$panel->plugin(BlogPlugin::make()->authorResource(false));

register() проти boot():

  • register() викликається під час конфігурації панелі. Тут лише описують, що панель містить: ресурси, сторінки, віджети, налаштування. Жодної роботи з базою чи поточним користувачем;
  • boot() виконується через middleware лише тоді, коли запит іде до цієї панелі. Тут доречна логіка, що має діяти тільки всередині панелі: наприклад, глобальні налаштування компонентів через configureUsing(), які не повинні зачепити інші панелі чи решту сайту.

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

  • getId() має бути унікальним серед усіх плагінів - короткий загальний id на кшталт blog легко конфліктує;
  • флюентна конфігурація (як authorResource(false)) - звичний для користувачів плагіна спосіб налаштування;
  • плагін, підключений до кількох панелей, отримує окремий виклик з кожною панеллю, тож стан не повинен «перетікати» між ними;
  • службовий провайдер пакета (міграції, переклади, view) - окремий від класу плагіна: плагін налаштовує панель, а провайдер - Laravel.

Альтернатива плагіну для невеликих змін - render hooks: вставити Blade-фрагмент у визначене місце панелі (PanelsRenderHook::BODY_END тощо) без власного класу.

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

Коли ресурсів і сторінок стають десятки, бічна навігація перетворюється на довгий список. 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: кластери