Тема
Фаза 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 | Хранение | Заметка |
|---|---|---|---|
text | TextInput | string | + maxLength (дефолт 255) |
textarea | Textarea | text | |
richtext | RichEditor | text (санитизированный HTML) | вывод через whitelist, не сырой {!! !!} |
number / tel / email / url | TextInput | number/string | rules по типу |
bool | Toggle | bool | |
select / radio | Select/Radio | string (значение enum) | options из набора |
multiselect / checkbox | CheckboxList | json (массив) | |
date / datetime | DatePicker | date/datetime | translatedFormat на выводе |
media | SpatieMediaLibraryFileUpload | FK на media | не голый FileUpload |
relation | Select relationship | relation_id в JSONB / cms_content_relations при N:N | декларация типизированной связи — семантику и хранение определяет движок типов контента, §5; поставляется движком в общий реестр |
repeater | Repeater | json (массив объектов) | повторяющиеся группы (FAQ-элементы, шаги) |
group | Section/Fieldset | json (вложенный объект) | группировка |
color-variant | Select | string (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)
- декларация поля порождает валидный Filament-компонент (форма рендерится);
- rules поля применяются одинаково в трёх контекстах (админ/форма/API) — один набор;
- цепочка миграций схемы поля
1→…→currentзамкнута; - запрещённые стилевые типы не проходят в схему блока (линтер Q9);
- индексируемое поле типа контента порождает generated-колонку + индекс;
- имя поля вне канона (
snake_case, ≤ 50) отклоняется при регистрации; - поле с
owner_moduleread-only в Filament для контент-менеджера; - изменение 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) на уровне ядра.
- [ ] Контрактные тесты движка полей.
Связи
- Контракт блока — потребитель №1 (схема блока).
- Модель данных — типы контента, generated-колонки.
- Решения: Q5, Q8, Q9.
- Filament spec, migrations spec, controllers spec.