<? phpukraine СТАТТІ
Пошук по платформі
LARAVEL 3 вересня 2026 · 7 хв читання

Filament 5 для адмінки контентного сайту: що дає з коробки і де межа

Панель phpukraine живе на /panel: Filament v5.7 поверх Laravel 13 і Livewire 4. Вакансії й ліди в ній редагуються, а контент — ні, бо його джерело у файлах репозиторію. Розбираємо, що генерує make:filament-resource, як зробити ресурс лише для перегляду, звідки віджети беруть цифри і в якій точці Filament перестає допомагати.

РP
Редакція phpukraine
Редакція платформи

Адмінка контентного сайту — це не «CRUD для всього». Частина сутностей справді живе в базі й редагується руками: вакансії, компанії, ліди, прогони синхронізації. Частина приходить із файлів: статті, документація і питання зі співбесід лежать у content/, імпортер розкладає їх по таблицях. Для другої групи форма редагування — це пастка: наступний php artisan content:import мовчки перезапише правку.

Нижче — як це виглядає в панелі phpukraine на Filament v5.7 (Laravel 13, Livewire 4): що дає генератор, як обрізати ресурс до перегляду, звідки беруться числа на дашборді і де закінчується зона, у якій фреймворк допомагає.

Ресурс за хвилину і що він генерує

Модель QuestionModel лежить не в App\Models, а в src/Content/Infrastructure/Persistence. Генератор це вміє:

php artisan make:filament-resource QuestionModel \
  --model-namespace='PhpUkraine\Content\Infrastructure\Persistence' \
  --generate --view --record-title-attribute=question --no-interaction

--generate читає колонки з бази й розкладає їх у поля форми й колонки таблиці, --view додає сторінку перегляду з інфолістом. На виході — тека app/Filament/Resources/QuestionModels/ з класом ресурсу, Pages/ (List, Create, View, Edit), Schemas/ (Form, Infolist) і Tables/. Сам ресурс — це декларація:

class QuestionModelResource extends Resource
{
    protected static ?string $model = QuestionModel::class;

    protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedAcademicCap;

    protected static ?string $navigationLabel = 'Питання зі співбесід';

    protected static string|\UnitEnum|null $navigationGroup = 'Контент';

    protected static ?string $recordTitleAttribute = 'question';
}

Панель підхоплює клас без реєстрації: ->discoverResources(in: app_path('Filament/Resources'), for: 'App\Filament\Resources'). Модель з енумами в casts() віддає бейджі й фільтри без додаткового коду.

Одразу після генерації код треба читати. --generate ставить ->searchable() на кожну текстову колонку — включно з content_hash, source_url і license. Кожен такий виклик додає умову в WHERE ... LIKE '%…%' при глобальному пошуку в таблиці; шукати по хешу вмісту не буде ніхто, а сканування таблиці ви отримаєте. Технічні колонки або прибираються, або йдуть з ->toggleable(isToggledHiddenByDefault: true), як згенеровані created_at/updated_at.

Read-only ресурси для контенту як коду

Питання зі співбесід — файли content/interview/{topic}/{slug}.md. Імпортер тримає в рядку content_hash і при кожному запуску порівнює його з хешем файлу: змінився — перезаписати, зник файл — видалити рядок. Отже, редагування через панель має рівно один ефект: розсинхрон до наступного імпорту.

Тому ресурс закривається на рівні авторизації, а не приховуванням кнопок:

/** Questions are authored as files in content/interview and imported; the panel is read-only. */
public static function canCreate(): bool
{
    return false;
}

public static function canEdit(Model $record): bool
{
    return false;
}

Далі Filament робить усе сам. Кожна дія перед рендером питає ресурс через Resource::can(), тож CreateAction у заголовку списку і EditAction у рядку просто не з'являться — згенерований QuestionModelsTable можна не чіпати. DeleteBulkAction вимикається так само, через canDeleteAny().

Маршрути create і edit при цьому лишаються в getPages(). Це не діра: CreateRecord::authorizeAccess() викликає abort_unless(static::getResource()::canCreate(), 403), EditRecord — те саме з canEdit($record), і перевірка повторюється в save(), а не тільки в mount(). Прямий захід на /panel/question-models/1/edit дає 403. Якщо хочеться, щоб маршруту не існувало взагалі, приберіть відповідні рядки з getPages() разом із класами сторінок.

Того ж принципу варті ресурси з даними синхронізації: прогони імпорту вакансій цікаво переглядати й фільтрувати, але редагувати запис про минулий прогон — безглуздо.

Віджети зі статистикою з власних query-класів

Дашборд — головна спокуса написати JobModel::query()->where('status', 'open')->count() прямо у віджеті. Тоді на сайті й у панелі з часом будуть різні числа, бо «відкрита вакансія» — це не одна колонка, а набір умов, який живе в каталозі.

php artisan make:filament-widget PortalStats --stats-overview дає кістяк, у який підставляються ті самі read-інтерфейси, що обслуговують публічні сторінки:

class PortalStats extends StatsOverviewWidget
{
    protected static ?int $sort = 0;

    protected function getStats(): array
    {
        $catalog = app(JobCatalog::class);
        $tasks = app(ContentTaskRepository::class)->countByStatus();
        $lastSync = $catalog->lastUpdatedAt();

        return [
            Stat::make('Відкритих вакансій', $catalog->count(new JobFilter))
                ->description($lastSync !== null ? 'синхронізовано '.$lastSync->format('d.m H:i') : 'ще не синхронізовано')
                ->color('success'),
            Stat::make('Нових за 7 днів', $catalog->countPublishedSince(new \DateTimeImmutable('-7 days'))),
            Stat::make('Черга переписування', ($tasks['pending'] ?? 0) + ($tasks['leased'] ?? 0))
                ->description(sprintf('%d готово · %d помилок', $tasks['done'] ?? 0, $tasks['failed'] ?? 0))
                ->color(($tasks['failed'] ?? 0) > 0 ? 'warning' : 'gray'),
        ];
    }
}

JobCatalog, InterviewCatalog, ContentTaskRepository — інтерфейси в Application/Domain, реалізовані запитами до бази. Віджет не знає про Eloquent і тестується підміною реалізації в контейнері, без завантаження панелі. Побічний ефект приємний: якщо визначення «відкритої вакансії» зміниться, дашборд поїде разом із сайтом, а не окремо.

Де Filament починає заважати

Перша межа — все, що не CRUD. Локальна сторінка «Черга контенту» показує файли, які написали воркери, і має чотири дії: подивитись на сайті, прийняти (коміт), відхилити, задеплоїти. Ресурс тут не допомагає — джерело даних git і файлова система. Від Filament лишається хром: layout, іконки, Notification. Решта — свій Blade-шаблон і публічні методи Livewire-компонента:

class ContentQueue extends Page
{
    protected string $view = 'filament.local.content-queue';

    public static function canAccess(): bool
    {
        return app()->isLocal();
    }

    public function accept(string $path): void
    {
        $this->run(function (ReviewQueue $queue) use ($path) {
            $item = $queue->find($path) ?? throw new \RuntimeException('Файл уже не в черзі.');
            $queue->accept($item, /* ... */);

            return 'Закомічено: '.$item->title;
        });
    }
}

Це нормальний результат, але оцінюйте його чесно: на такій сторінці Filament економить верстку й нотифікації, не логіку. Сторінка ще й не реєструється поза локальним оточенням — у провайдері панелі список ->pages() збирається умовно, а canAccess() страхує другим рубежем.

Друга межа — авторизація. Панель із самим Authenticate у authMiddleware() пускає будь-кого, хто має обліковий запис. Щойно користувачів більше одного, модель юзера має реалізувати Filament\Models\Contracts\FilamentUser::canAccessPanel(), а ресурси — спиратися на політики; інакше редактор статей бачить ліди.

Третя межа — прив'язка до мажорної версії. У v5 схеми форм і інфолістів описуються через Filament\Schemas\Schema, дії живуть у Filament\Actions\*, таблиця розділяє recordActions() і toolbarActions(). Це інші сигнатури, ніж у попередніх мажорах, і апгрейд означає перегляд кожного ресурсу. Практичний висновок: тримайте в app/Filament тільки опис інтерфейсу. Чим менше там логіки, тим дешевший апгрейд.

Четверта — форми не місце для доменних правил. Filament заповнює й зберігає модель напряму, повз конструктори й фабрики агрегатів. Валідація в схемі — про UX; інваріанти лишаються там, де вони були.

Що робити з DDD-шарами

Filament мислить Eloquent-моделями: таблиця будується на Model::query(), пагінація, сортування й фільтри — це query builder. Спроба підсунути йому доменні об'єкти закінчується власними реалізаціями половини Filament\Tables.

Тому в проєкті ухвалено просте розмежування. Ресурси дивляться прямо на Eloquent-моделі з Infrastructure\Persistence — і app/Filament єдиний шар, якому це дозволено. Архітектурний тест забороняє це решті:

arch('the web layer never touches infrastructure directly')
    ->expect(['App\Http', 'App\Livewire'])
    ->not->toUse(['PhpUkraine\Hiring\Infrastructure', 'PhpUkraine\ContentOps\Infrastructure']);

App\Filament у цьому списку немає свідомо: панель — це адмін-інструмент над таблицями, а не публічна модель домену. Але виняток обмежений трьома правилами:

  1. Читання списків — прямо через Eloquent-модель ресурсу. Це те, для чого Filament зроблений.
  2. Будь-яка дія з побічним ефектом (перезапустити синхронізацію, поставити задачу в чергу, прийняти файл) — виклик Application-сервісу з контейнера, як у контролері. Ресурс не пише в базу повз домен.
  3. Агрегація і будь-яке число на екрані — через Application-інтерфейси, бо ці ж числа показані на публічних сторінках і мають збігатися.

І окремо — контент, чиє джерело у файлах: canCreate() і canEdit() повертають false, а редагування відбувається там, де живе оригінал, у content/ і в git. Панель для нього — оглядове вікно: перевірити, що імпорт побачив файл, знайти питання за темою, глянути content_hash. Цього достатньо, і це рівно та частина роботи, яку Filament робить безкоштовно.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті