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

Junior: питання на співбесіді з теми «Форми й схеми Filament»

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

4 питання

У 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() - інакше вимкнене поле все одно збереже зв'язок.

Докладніше в документації: Select: зв'язки

FileUpload завантажує файл через Livewire у тимчасове сховище, а під час збереження форми переносить його на диск і записує в атрибут моделі шлях до файлу.

use Filament\Forms\Components\FileUpload;

FileUpload::make('cover')
    ->image()
    ->disk('public')
    ->directory('covers')
    ->visibility('public')
    ->maxSize(2048);          // кілобайти

Чому зображення не відкривається - найчастіші причини:

  1. Видимість private за замовчуванням. Файли завантажуються з приватною видимістю, якщо диск не public. На S3 чи R2 без ->visibility('public') пряме посилання на файл повертає 403.
  2. Диск за замовчуванням - той, що в FILESYSTEM_DISK, часто local. Файли з нього взагалі не віддаються вебсервером.
  3. Немає storage:link для диска public.
  4. Неправильний 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-тип, а не розширення - покладатися лише на нього для безпеки не варто.

Докладніше в документації: Завантаження файлів