Skip to content

Фаза 0 — Движок полей (Field Engine)

Статус: спека фазы 0, фундаментальная. Один движок полей обслуживает три подсистемы: схему блока (Q9), произвольные типы контента (Q5) и конструктор форм (Q8). Три спеки уже на него ссылаются как на «один движок, а не три реализации» — здесь он специфицируется. Опирается на донора universal (контракт блока с filamentSchema()).

Зачем один движок на три подсистемы

Блок, тип контента и форма — это, по сути, одно и то же: набор типизированных полей, который надо (1) отредактировать в админке, (2) провалидировать, (3) сохранить, (4) отрендерить. Донор universal уже описывает поля блока массивом Filament-компонентов (TextInput, RichEditor) — но это привязка к Filament: та же схема не переиспользуется для публичного рендера, для валидации submission формы, для API. Три подсистемы, реализованные порознь, дадут три несовместимых определения «поля» — и тройную работу при каждом новом типе.

Движок полей — абстракция над Filament-компонентом, а не сам компонент. Одна декларация поля разворачивается во все четыре представления.

Определение поля

Поле — объект-описание (как тип блока — объект, а не enum). Ядро несёт каталог базовых типов; модуль может зарегистрировать свой тип поля (открытый реестр, как BlockRegistry).

php
Field::make('phone')
    ->type('tel')                       // тип из FieldTypeRegistry
    ->label('Телефон')
    ->required()
    ->rules(['regex:/^\+?[0-9\s\-()]{7,}$/'])
    ->default(null)
    ->help('В любом формате')
    ->schemaVersion(1);                  // версия схемы ЭТОГО поля (для миграции данных)

Канон имён полей (фиксируется до кода)

Урок 15-летней боли Bitrix (UF_CRM_10_… в БД против ufCrm10_… в REST, разбор §8): один формат имени на все слои:

  • snake_case, паттерн [a-z][a-z0-9_]{0,49};
  • имя одинаково в JSONB-хранении, API-ответе, Blade-токенах и Filament — никаких преобразований регистра между слоями;
  • валидация имени — в FieldEngine при регистрации поля; существующие типы проверяет cms:doctor;
  • имя и тип поля иммутабельны после появления данных (подтверждено моделью Directus) — переименование = новое поле + data-миграция, не rename.

Разделение декларации: данные / поведение / представление

По модели Directus (type / schema / meta, разбор §12) декларация поля внутренне разделяется на два слоя — это делает диффы схем чистыми и позволяет менять UI-мету без версии схемы:

  • schema-слой (влияет на данные, меняется через schemaVersion): type, хранение, индексируемость, rules, required, default;
  • meta-слой (только представление, версии не требует): label, help, placeholder, группа/ширина в форме, порядок, переводы подписей;
  • conditions (meta-слой): декларативная условная логика формы данными, а не кодом — «если поле X = Y → это поле hidden/required/readonly». Рендерится в Filament (hidden(fn ($get))), но живёт в декларации — переносимо и диффабельно.

Владение полем (owner_module)

Поле схемы типа контента несёт владельца (модель Shopify app-owned metafields, разбор §14): поля, созданные модулем (например, интеграцией 1С — external_id, sync_hash), в админке read-only для контент-менеджера и не удаляются из UI; поля клиента (owner_module = null) — редактируемые. Снимает конфликт «менеджер переименовал поле — синхронизация 1С развалилась».

Из одной декларации движок производит:

ПредставлениеЧто генерируетПотребитель
Filament-компонентTextInput/Select/RichEditor… с label/rulesадминка (редактор блока, CRUD типа, конструктор формы)
Правила валидацииLaravel-массив rulesсохранение блока, submission формы, API
Публичный рендерBlade-partial / значениетема (рендер блока, детальная типа контента)
JSON-схема / OpenAPIописание поляAPI-контракт, DX для Claude Code

Каталог типов полей (ядро)

Базовый набор, покрывающий типовой корпоративно-каталожный сайт:

ТипFilamentХранениеЗаметка
textTextInputstring+ maxLength (дефолт 255)
textareaTextareatext
richtextRichEditortext (санитизированный HTML)вывод через whitelist, не сырой {!! !!}
number / tel / email / urlTextInputnumber/stringrules по типу
boolTogglebool
select / radioSelect/Radiostring (значение enum)options из набора
multiselect / checkboxCheckboxListjson (массив)
date / datetimeDatePickerdate/datetimetranslatedFormat на выводе
mediaSpatieMediaLibraryFileUploadFK на mediaне голый FileUpload
relationSelect relationshiprelation_id в JSONB / cms_content_relations при N:Nдекларация типизированной связи — семантику и хранение определяет движок типов контента, §5; поставляется движком в общий реестр
repeaterRepeaterjson (массив объектов)повторяющиеся группы (FAQ-элементы, шаги)
groupSection/Fieldsetjson (вложенный объект)группировка
color-variantSelectstring (light/dark/accent)не свобода цвета — выбор варианта темы (Q9)

Запрещённые в схеме блока типы (линтер Q9): произвольный color, padding, font, css. Для типа контента и формы этих ограничений нет — там поля любые (это данные, не оформление страницы).

Хранение

Значения полей — JSONB (единая модель с моделью данных):

  • блок → props в JSONB внутри ревизии страницы;
  • тип контента → колонка data (jsonb) в cms_content_entries, GIN-индекс;
  • формаpayload (jsonb) в cms_leads / submission.

Поля с бизнес-смыслом (фильтрация/сортировка/URL у типов контента) выносятся в generated-колонки поверх JSONB с functional/partial индексами (migrations spec) — движок полей объявляет, какое поле «индексируемое», и миграция типа контента создаёт под него колонку.

Версионирование схемы поля

Тип поля несёт schemaVersion — та же механика, что у блока (_v). Когда движок меняет форму хранения поля (например, media перешёл с «id строкой» на «объект с alt/title»), объявляется data-миграция поля. Миграция блока/типа контента складывается из миграций его полей — не дублируется в каждом блоке.

Это разрешает скрытую проблему: 20 блоков используют поле media; смена формата media не требует 20 миграций блоков — одна миграция типа поля применяется везде, где поле используется.

Валидация

Единый источник правды — декларация поля. Из неё:

  • серверные rules (Laravel) — применяются одинаково в Filament (админ вводит блок), в submission формы (посетитель), в API (внешний клиент);
  • клиентские подсказки — тип input, required, паттерн (прогрессивное улучшение, не замена серверной);
  • защита форм — rate-limit, CSRF, honeypot/капча на уровне ядра (Q8), не на совести автора формы;
  • согласие ПДн (152-ФЗ) — чекбокс согласия с текстом и журналированием (cms_leads.consent_at) — базовый слой в ядре: формы ядра собирают ПДн с первого дня. Расширенные согласия (реестры, экспорт/удаление) — модуль 152-ФЗ.

Валидация не в контроллере — в FormRequest, порождаемом движком из схемы (controllers spec).

Три потребителя движка

1. Схема блока (Q9)

BlockType::fields() возвращает массив Field-деклараций. Ограничение средней свободы: только контентные типы, без стилевых. Рендер — темой.

2. Тип контента (Q5)

cms_content_types.schema (jsonb) хранит список Field-деклараций типа. Клиент заводит тип «вакансии» с полями (движок полей в админке — конструктор схемы), ядро генерирует Filament-CRUD и публичный список/детальную. Индексируемые поля → generated-колонки.

3. Конструктор форм (Q8)

Клиент собирает форму из тех же Field-типов (drag&drop полей). Submission → payload (jsonb) → шина заявок (Leads) → нотификации. Механика полей переиспользуется, а не удваивается — именно поэтому конструктор форм в ядре не удорожает разработку (решение Q8 опиралось на это).

Открытость: модуль регистрирует свой тип поля

php
FieldTypeRegistry::register(
    FieldType::make('geo-point')            // модуль карт добавляет поле «точка на карте»
        ->filament(MapPicker::class)
        ->rules(['array', 'size:2'])
        ->store('json')
        ->render('fields.geo-point')
);

Ядро, модуль, проект регистрируют типы полей одинаково — новый тип поля не требует форка, как и новый блок.

Контрактные тесты (cms-testing)

  1. декларация поля порождает валидный Filament-компонент (форма рендерится);
  2. rules поля применяются одинаково в трёх контекстах (админ/форма/API) — один набор;
  3. цепочка миграций схемы поля 1→…→current замкнута;
  4. запрещённые стилевые типы не проходят в схему блока (линтер Q9);
  5. индексируемое поле типа контента порождает generated-колонку + индекс;
  6. имя поля вне канона (snake_case, ≤ 50) отклоняется при регистрации;
  7. поле с owner_module read-only в Filament для контент-менеджера;
  8. изменение meta-слоя (label, группа) не требует bump schemaVersion.

Чеклист фазы 0 (движок полей)

  • [ ] Field-декларация → 4 представления (Filament / rules / рендер / JSON-схема).
  • [ ] Канон имён полей (snake_case, иммутабельность) — валидация при регистрации.
  • [ ] Разделение schema-слой / meta-слой в декларации; conditions — данными.
  • [ ] owner_module — владение полем, read-only для чужих в админке.
  • [ ] Каталог базовых типов полей + запреты для схемы блока.
  • [ ] FieldTypeRegistry — открытая регистрация типа поля модулем.
  • [ ] Хранение JSONB + generated-колонки для индексируемых полей типа контента.
  • [ ] Версионирование схемы поля (schemaVersion) + агрегация в миграцию блока/типа.
  • [ ] Единая валидация (FormRequest из схемы) для админ/форма/API.
  • [ ] Защита форм (rate-limit/CSRF/honeypot) на уровне ядра.
  • [ ] Контрактные тесты движка полей.

Связи

Внутренняя база знаний студии