Тема
Движок «Типы контента» (Content Types Engine)
Статус: спека, фундаментальная. Несущая подсистема ядра — паритет с Битрикс
iblock/ инфоблоками, no-code слой над моделью данных и движком полей. Опирается на решение Q5 («в ядре, JSONB+GIN, не EAV») и на модель хранения props блока (контракт блока) — свойства записи устроены по тому же образцу. Стандарт модуля и три инварианта — modules/standard; поверхность API/событий ядра — modules/core.
1. Назначение и видение
Битрикс держит корпоративный/каталожный рынок 20 лет одним архитектурным трюком: инфоблоки — универсальная сущность «список + элемент с произвольными свойствами», на которой строится почти всё: новости, каталог, вакансии, сертификаты, отзывы, галереи. Не разработчик описывает PHP-класс и миграцию под каждую сущность — контент-менеджер создаёт тип мышкой в административной панели, и тип немедленно готов к наполнению.
Движок «Типы контента» — v2-ответ на этот паритет, но без антипаттерна EAV (классические инфоблоки — десятки JOIN на выборку одного элемента, см. decisions Q5 и bitrix-comparison). Три свойства, которые должны быть верны одновременно:
- No-code создание — тип, набор свойств, типы данных свойств, связи с другими типами и доменными сущностями настраиваются в Filament мышкой, без правки кода и без миграции на каждый тип — схема свойств хранится декларативно (JSONB), как схема блока (§3).
- Немедленная доступность — как только тип сохранён, у него есть рабочий генерик-ресурс в админке, публичные роуты list/detail, API — без деплоя.
- Генерик всюду — одна Filament-страница
ContentTypes(типы) + один ресурсContentEntries(записи любого типа), рендерящийся по схеме типа через движок полей; не «ресурс на тип».
Явная граница гибрида: что на движке, а что нет
Движок — не единственный способ моделировать сущности в CMS. Три причины разместить данные вне него: производительность/масштаб (highload-каталог), особая модель (иерархия с ценами, версионируемый контент страницы), разовость (список внутри одного блока секции).
| На движке (типы контента) | Вне движка (бесспок-модуль) | Причина исключения |
|---|---|---|
| Блог/статьи, новости, кейсы-портфолио, отзывы, галереи, переиспользуемые слайдеры/баннеры, FAQ, ленты, команда, сертификаты, справочники (города, бренды-упоминания, партнёры) | cms/catalog-showcase — лёгкая витрина; тем более cms/commerce-catalog | Фасеты, полнотекст (tsvector), масштаб (десятки-сотни тысяч строк), варианты/цены — точечные высокопроизводительные таблицы, не общая JSONB-колонка |
| — | cms/services (иерархия услуг) | 3-уровневая иерархия с ценами «от» и SEO-полями — специфичная модель дерева + прайсинга, не просто список записей |
| — | Страницы и блоки (модель данных) | Контент страницы живёт в JSONB ревизии, версионируется целиком; по нему не фильтруют списками — это не сущность, а место |
| — | Разовый презентационный список внутри секции страницы (репитер-поле блока, например 3 карточки преимуществ на конкретной странице) | Не переиспользуется как самостоятельная сущность — обычный repeater-элемент движка полей, не тип контента |
Заявки — гибкая граница, не жёсткая. Специализированный конвейер лидов ядра (cms_leads + LeadService, полиморфная leadable, modules/core) остаётся и никуда не девается: у него своя очередь уведомлений, CRM-интеграции, статусная машина (LeadStatusChanged). Движок типов контента способен смоделировать заявку как тип («Заказать звонок» с полями телефон/имя/комментарий) — это годится для простых форм без бизнес-процесса вокруг. Правило выбора: если ответ на входящий сабмишен — это воронка со статусами/уведомлениями/CRM, это лид (LeadService::create()); если это просто «сохранить структурированную запись» (заявка на просмотр объекта недвижимости — тип контента с интересом от сущности недвижимости) — можно тип контента. Обе дороги одинаково валидны, форма-конструктор ядра (Q8) умеет отправлять сабмишен в любую из них по настройке.
2. Что такое тип контента
Тип контента — декларация сущности: имя, набор типизированных свойств, признаки поведения (иерархия, есть ли детальная, участие в SEO/sitemap). Запись — экземпляр типа: конкретная строка cms_content_entries с data (jsonb), которая проходит через список → детальную → (опционально) API, как самостоятельная адресуемая сущность (/vacancies/backend-developer, а не часть страницы).
| Критерий | Блок (странице) | Тип контента | Бесспок-модуль |
|---|---|---|---|
| Единица хранения | JSONB-элемент в cms_page_revisions.blocks | Строка cms_content_entries (JSONB data) | Своя таблица(-ы), реляционная схема |
| Адресуется отдельно | нет — часть страницы | да, свой slug/роут | да, свой slug/роут |
| По нему фильтруют списком | нет | да (whitelist-фильтры/сортировка) | да, часто с фасетами |
| Версионируется | целиком со страницей (ревизия) | схема типа — _v + data-миграция (§10); запись — без ревизий (см. открытые вопросы) | по своим правилам модуля |
| Кто создаёт | разработчик регистрирует BlockType в реестре | контент-менеджер в админке (no-code) | разработчик пишет модуль |
| Когда выбирать | контент этой страницы, не переиспользуется как список | переиспользуемый набор однотипных записей без спецтребований к масштабу/модели | масштаб/фасеты/особая модель домена перерастают движок (§12) |
Тип контента и блок используют один и тот же движок полей — поэтому набор доступных типов данных свойств идентичен каталогу полей блока (§4), а не отдельная параллельная реализация.
3. No-code создание типа (центральный раздел)
3.1. Мастер создания типа в Filament
Studio-роль (или admin — по настройке прав инсталляции, см. §11) создаёт тип на странице Content Types — Filament-ресурс ContentTypeResource:
Шаг 1 — идентичность типа:
Название: Вакансии
Slug: vacancies # латиница, [a-z][a-z0-9-]{0,49} — публичный URL-сегмент
Иконка: heroicon-o-briefcase # палитра админки
Иерархический: нет # да → adjacency-list (parent_id), как «Услуги» без цен
Есть детальная страница: да # нет → только список (справочник для связей, напр. «Город»)
SEO включён: да # генерирует cms_seo_meta-запись на каждую entryШаг 2 — конструктор свойств (реюз drag&drop-конструктора форм Q8): на каждое свойство задаётся то же, что на поле формы/блока (движок полей едино), плюс атрибуты, специфичные для типа контента:
| Атрибут свойства | Назначение |
|---|---|
code | Машинное имя, канон движка полей — snake_case, иммутабельно после появления данных |
type | Тип данных из FieldTypeRegistry (§4) |
required / default | Как в движке полей |
multiple | Свойство хранит массив значений (напр. несколько ответственных менеджеров) |
in_list | Показывать колонкой в списке записей (Filament-таблица) |
filterable | Участвует в whitelist фильтров API/админ-списка → functional partial-индекс по выражению (§12) |
sortable | Участвует в whitelist сортировок → тот же индекс по выражению |
searchable | Индексируется в полнотекстовый поиск ядра (tsvector, при малом объёме — без выделенного cms/search; механика — §12) |
pii | Свойство содержит персональные данные: записи с заполненным значением исключаются из общего page-cache (§12), свойство попадает в ПДн-паспорт инсталляции (динамическое дополнение к статическому паспорту модулей) |
owner_module | Как в движке полей — поле, созданное модулем-интеграцией, read-only для контент-менеджера |
У иерархического типа (is_hierarchical = true) свойство parent_id индексируется всегда автоматически (без ручного тумблера filterable) — иначе выборка «дети узла» шла бы полным сканом JSONB, вопреки перформанс-модели §12.
Шаг 3 — связи (§5) — какие свойства-связи ссылаются на какие целевые типы/сущности.
Шаг 4 — шаблоны (§6) — какой шаблон карточки/детальной по умолчанию.
Шаг 5 — права — авто-генерация content.{slug}.view/manage (§11), назначение ролям.
3.2. Немедленная доступность — что происходит по сохранению типа
Сохранение ContentTypeResource (событие ContentTypeSaved) синхронно/в очереди выполняет:
- запись схемы в
cms_content_types.schema(jsonb) — без DDL-миграции проекта; - если есть
filterable/sortable/searchableсвойства — движок заводит индекс под индексируемое свойство поверхcms_content_entries.data(не «на тип», а «на свойство»: одна общая таблица, добавления аддитивны). Механизм — functional partial-индекс (решено 15.07.2026):CREATE INDEX CONCURRENTLY … ((data->>'code')) WHERE content_type_id = N, для типизированной сортировки/диапазонов — явный каст в выражении индекса (((data->>'code')::numeric),::timestamptz) по типу данных свойства. Так уже работаетContentTypeIndexer; generated-STORED-колонка — точечная оптимизация отдельного горячего свойства позже, не механизм по умолчанию (ALTER TABLE … GENERATED STOREDпереписывает всю общую таблицу подACCESS EXCLUSIVE— блокировала бы записи всех типов; см. §12); - публичные роуты
/{slug}(list) и/{slug}/{entry-slug}(detail) начинают отвечать — не регистрацией нового Laravel-роута, а через единый динамический резолвер slug ядра (SlugResolver, решение 15.07.2026 — см. §7.1). Ключевое: роут в приложении один и статический (route:cache-совместим), а решение «страница / тип контента / модульный префикс» принимается в рантайме по БД — поэтому новый тип отвечает без деплоя и без пересборки кеша роутов; - авто-появление типа в списке
ContentEntryResource(генерик-ресурс записей, §8) — пункт меню админки без правки навигации; - авто-доступность
GET/POST /api/v1/content/{slug}и схемыGET /api/v1/content-types/{slug}/schema(§9, уже реализовано волной API-ядра, modules/core).
Инвариант: между «сохранил тип в форме» и «тип работает во всех четырёх поверхностях» нет ручного шага разработчика. Единственная асинхронная часть — построение индекса на типе с уже большим объёмом записей (CREATE INDEX CONCURRENTLY в очереди с прогрессом в админке, не блокирующий HTTP-запрос; упавшая джоба оставляет INVALID-индекс — он пересоздаётся повторным запуском, записи других типов не блокируются).
Дополнительно по сохранению типа с seo_enabled = true и has_detail = true движок регистрирует источник sitemap (ContentEntriesSitemapSource) в SitemapRegistry ядра — записи типа попадают в /sitemap.xml без ручного шага (сбой источника изолируется реестром, как у остальных источников).
3.3. Декларативность схемы — не миграция на тип
Ключевое расхождение с классическим Laravel-подходом «своя таблица на сущность»: схема типа — строка в cms_content_types.schema, а не файл миграции. Это то же решение, что и для блока (модель данных, «расхождение с донором»): новый тип, новое свойство — 0 файлов миграций проекта, 0 деплоев. Единственная DDL-операция, которая физически происходит, — CREATE INDEX CONCURRENTLY под индексируемое свойство (шаг 2 выше), и её выполняет движок, а не разработчик клиента.
Миграция от текущего кода (переходное состояние). Волной API-ядра уже реализован каркас: модели ContentType/ContentEntry, GIN-индекс на data, read-API и плоская схема свойств с единственным флагом indexed. При реализации этой спеки существующие схемы мигрируются одноразовой data-миграцией движка: indexed → filterable + sortable; это не _v-bump типа (состав свойств не меняется). Обратная совместимость /api/v1/content/{type} сохраняется: пути и конверт не меняются, whitelist фильтров/сортировок только расширяется.
4. Свойства и типы данных
Типы данных свойств = каталог движка полей без ограничений, наложенных на схему блока (Q9 запрещает стилевые поля в блоке — для типа контента и формы этого ограничения нет, это данные, не оформление страницы):
text · textarea · richtext (санитизация Html::sanitize, двойной барьер вход/выход) · number / tel / email / url · bool · select / radio · multiselect / checkbox · date / datetime · media (FK через медиатеку) · repeater · group · color-variant.
Плюс два типа, которые поставляет сам движок типов контента в общий FieldTypeRegistry (тем же открытым механизмом, что и geo-point в примере движка полей; после регистрации доступны и блокам/формам, где уместно). Каталог движка полей упоминает relation — семантика и хранение определяются этой спекой, второй реализации нет. Порядок реализации фиксирован: сначала relation + таблица cms_content_relations (§5), затем entity-picker как UI-обёртка поверх него — обратная последовательность невозможна:
| Тип | Назначение | Хранение |
|---|---|---|
relation | Ссылка на запись другого типа контента, либо на доменную сущность ядра/модуля (страница, услуга, товар, термин таксономии) — единичная или множественная | relation_id (uuid/ulid) в JSONB data, дублируется в таблицу связей при кардинальности N:N (§5) |
entity-picker | UI-обёртка над relation для непрограммиста: модальный поиск/выбор целевой записи с превью карточки (не голый select по id) — то же хранение, что relation | как relation |
Множественные свойства (multiple: true, §3.1) хранятся JSON-массивом значений внутри data — тот же принцип, что multiselect в движке полей. Санитизация rich-text и запрет сырого {!! !!} действуют одинаково независимо от того, где richtext-поле используется — в блоке, форме или свойстве типа контента (единая точка барьера — безопасность).
5. Взаимосвязи (богатые типизированные связи)
Свойство типа relation — не голый FK-id, а декларация связи, настраиваемая мышкой при конструировании типа:
jsonc
// фрагмент cms_content_types.schema для типа "cases" (кейсы)
{
"code": "related_services",
"type": "relation",
"label": "Связанные услуги",
"cardinality": "many_to_many", // one_to_one | one_to_many | many_to_one | many_to_many
"target": ["content_type:services", "commerce_product"], // whitelist целевых типов/сущностей
"inverse": { // обратная сторона — видна на целевой записи
"code": "related_cases",
"label": "Связанные кейсы"
},
"on_delete": "restrict", // restrict | cascade | null
"max": null // лимит связанных записей (null = без лимита)
}5.1. Кардинальности и обратная сторона
- 1:1 — например, «страница услуги» ↔ «карточка в справочнике сертификатов»: редкий случай, хранится как одиночный
relation_idс обеих сторон; - 1:N / N:1 — типовое «запись → термин справочника» (запись блога → город, товар → бренд):
relation_idна стороне «N», обратная сторона — вычисляемый список (WHERE data->>'city_id' = ?, индекс по выражению приfilterable); - N:N — «кейс ↔ услуги», «товар → похожие товары» (self-relation) — требует отдельной таблицы связей, не JSONB-массива с обеих сторон (см. §5.2).
inverse объявляет, как обратная сторона называется и отображается на целевой записи (в её Filament-форме — read-only relation-manager, не редактируемое поле: редактируется только прямая сторона, чтобы не было двух источников правды для одной связи).
5.2. Хранение связи: таблица vs JSONB — обоснование выбора
| Кардинальность | Хранение | Почему |
|---|---|---|
| 1:1, N:1 | relation_id внутри data (jsonb) свойства | Одно значение, не требует отдельного индекса на связь саму по себе — читается вместе с записью |
| 1:N (обратная сторона) | Вычисляется запросом по filterable-индексу свойства | Прямая сторона хранит relation_id; обратная — не хранится дублем, а запрашивается (нет риска рассинхрона) |
| N:N | Отдельная таблица cms_content_relations | JSONB-массив id с обеих сторон при N:N даёт рассинхрон (обновил с одной стороны — забыл с другой) и не индексируется под пересечение/сортировку/пагинацию связанных записей — те же причины, что у cms_related_content в модуле рекомендаций |
cms_content_relations
id,
from_entry_id → cms_content_entries.id (cascade),
to_entry_id → cms_content_entries.id (cascade | restrict — по настройке связи),
relation_code (какое свойство-связь породило эту строку — типов связей на паре может быть >1),
sort_order,
created_at
unique (from_entry_id, to_entry_id, relation_code)
index (to_entry_id, relation_code) # обратная выборка без full scanЭто не тот же движок, что cms/related-content: там — необязательные рекомендательные пары «похожее», настраиваемые редактором произвольно и не входящие в схему типа; здесь — обязательная типизированная связь, часть декларации типа (движок валидирует кардинальность, whitelist целевых типов, on_delete). Модуль рекомендаций можно построить поверх записей любого типа контента как дополнительный слой — они не конкурируют.
5.3. Поведение on_delete
restrict(по умолчанию для обязательных связей) — удаление целевой записи блокируется с перечислением ссылающихся (тот же UX-паттерн, что блокировка удаления медиа, используемого в ревизии — modules/core); форс — только явное подтверждение;cascade— удаление целевой записи удаляет строкиcms_content_relations(сама связанная запись не трогается — каскадируется только пара);null— связь у записи-источника становится пустой, запись остаётся (для необязательных связей, напр. «рекомендованный автор» блога).
Реальный constrained()-FK на связь возможен только когда target — единственный конкретный тип (не whitelist из нескольких) — иначе действует тот же аргумент, что у полиморфной пары в ТЗ related-content: целостность держат события (ContentEntryDeleted) + джоба очистки осиротевших строк cms:content:cleanup-orphans (по аналогии с cms:related-content:cleanup-orphans).
6. Шаблоны типа
Публичный вывод записи типа проходит тот же каскад разрешения шаблона, что и блок (контракт темы):
app/Local/views/content/{slug}/{list|detail}.blade.php
→ тема: resources/views/content/{slug}/{list|detail}.blade.php
→ родительская тема
→ нейтральный fallback ядра (простой список/карточка без стилизации)Тема также может дать шаблон по умолчанию для всех типов без своего override (content/_default/list.blade.php) — аналог list.news/element.news в Битрикс, которые рендерят любой инфоблок единым компонентом, пока разработчик темы не специфицировал конкретный.
Вывод списков внутри страницы (не на собственном роуте типа) — не отдельный механизм, а штатный блок content-list (BlockRegistry ядра):
php
BlockType::make('content-list')
->label('Список записей типа')
->fields(fn () => [
Select::make('content_type')->options(ContentTypeRegistry::asOptions()),
Select::make('filter_preset')->options([...]), // ссылка на сохранённый filter-whitelist пресет
Select::make('sort')->options(['-published_at' => 'Сначала новые', ...]),
TextInput::make('limit')->numeric()->default(6),
Select::make('card_template')->options(['default', 'compact', 'grid-3']),
])
->view('blocks.content-list');Это ровно та точка, где «переиспользуемый набор» (тип контента) попадает на произвольную страницу произвольным числом раз с разными фильтрами — без создания нового блока под каждый список.
7. Маршрутизация list/detail
7.1. Публичный резолвер slug — ✅ реализован волной C
Обещание «тип доступен без деплоя» (§1 п.2) требует, чтобы новый тип начинал отвечать на /{slug} без регистрации нового Laravel-роута: роут-файлы компилируются route:cache на деплое, а генерация роутов из админки означала бы деплой-подобный шаг. Реализовано так (packages/cms/core/routes/public-catchall.php, PublicContentController):
php
// Route::fallback матчится ПОСЛЕДНИМ независимо от порядка регистрации —
// приоритет «точный роут → модульный/persistent → тип → страница» держится конструктивно
Route::fallback(PublicContentController::class)
->middleware(['web', PageCacheMiddleware::class])
->name('cms.fallback');Файл подключается в CoreServiceProvider внутри app()->booted — после persistent-роутов модулей. Route::fallback снимает классическую ловушку двух catch-all (Route::get('/{slug}') у страниц + такой же у типов: победил бы зарегистрированный первым): fallback проигрывает любому реальному роуту, даже зарегистрированному позже.
Приоритет внутри PublicContentController:
| Порядок | Что проверяется | Результат |
|---|---|---|
| 1 | Точный роут ядра/модуля (/api/*, /admin/*, /, /sitemap.xml, префиксы модулей) | Laravel разрешает сам — резолвер не вызывается |
| 2 | Сегменты не канона [a-z0-9-], их число > 2, или первый сегмент в config('cms.routing.reserved_prefixes') | 404 |
| 3 | 1 сегмент, route_pattern типа совпал | list типа (/vacancies) |
| 4 | 1 сегмент, типа нет | страница — делегирование в PageController::show() |
| 5 | 2 сегмента, тип совпал и has_detail | detail записи (published() + slug, иначе 404) |
Почему не префикс /content/{type}/{slug}: SEO-паритет с Битриксом требует чистых URL (/vacancies/backend-developer); префикс снял бы коллизии by design, но обесценил бы то, ради чего движок строится.
Коллизии slug (тип vacancies и страница vacancies) разрешаются приоритетом выше — тип раньше страницы — и предотвращаются на входе: конфликт ловится валидацией при сохранении, а не 500 в рантайме (канон modules/core, «крайние случаи»). Шаблоны — каскад темы content/{slug}/{list|detail} → content/_default/{list|detail} ядра (§6).
7.2. Правила публичного вывода
route_patternтипа определяет публичный префикс (/{slug}по умолчанию, переопределяем — «вакансии» на/careers);- фильтры и сортировка на публичном list — только по свойствам с
filterable/sortable = true(anti-hardcode, тот же принцип whitelist, что и в API ядра):spatie/laravel-query-builderсобираетallowedFilters/allowedSortsиз схемы типа динамически, неизвестный параметр — 422, не тихий игнор (specs/api); - пагинация — keyset (
cursor=),OFFSETзапрещён на публичных списках типов как и везде в API ядра; - измерения
locale/city_id(nullable) наследуются изRequestContextядра — тип контента не парсит их сам; - кеш-теги:
content:{type}(весь список инвалидируется поContentEntrySaved/ContentEntryDeletedлюбой записи этого типа) иcontent:{type}:{id}(точечная инвалидация детальной), плюс измерения в ключ page-cache по общему правилу ядра (tenant/site/locale/city — modules/core).
8. Генерик Filament
Два постоянных Filament-ресурса ядра покрывают все типы контента — новых ресурсов на тип не создаётся:
8.1. ContentTypeResource — менеджер типов
CRUD типов + конструктор свойств (§3) + конструктор связей (§5) мышкой; relation manager «Свойства этого типа» и «Связи этого типа» на форме редактирования типа. Доступен только ролям с правом управления типами (§11).
8.2. ContentEntryResource — генерик-ресурс записей
Один класс, схема формы/таблицы/фильтров строится в рантайме из ContentType::schema, а не захардкожена:
php
final class ContentEntryResource extends Resource
{
// навигационная группа/пункт — на каждый активный ContentType динамически
// (не по одному ресурсу на тип: Filament resource per-type здесь избыточен,
// группировка в меню и есть множественность «под одной крышей»)
public static function form(Form $form): Form
{
$type = static::resolveContentTypeFromRoute();
return $form->schema(
FieldEngine::toFilamentSchema($type->schema) // тот же движок, что у блока
);
}
public static function table(Table $table): Table
{
$type = static::resolveContentTypeFromRoute();
return $table
->columns(FieldEngine::toTableColumns($type->schema, onlyInList: true))
->filters(FieldEngine::toTableFilters($type->schema, onlyFilterable: true));
}
}Особые случаи генерик-ресурса:
- иерархический тип (
is_hierarchical = true) — таблица переключается на adjacency-list дерево (parent_idвнутриdata, тот же паттерн, что уcms/servicesс защитой от циклов); - связи N:N (§5.2) — на форме записи появляется relation manager по каждому
relation-свойству кардинальностиmany_to_many, аналогично ручным Filament relation manager'ам обычных ресурсов; - права — генерируются как
content.{slug}.view/content.{slug}.manageна каждый тип при его создании (авто-регистрация в permission-реестре Shield, без ручного добавления в seed прав). Семантика:.viewгейтит просмотр записей в админке (read-only роли),.manage— создание/правку/удаление; публичные GET-эндпоинты и рендер прав не требуют. В текущем кодеRbacSynchronizerгенерирует только.manage— пара.viewдобавляется этой ревизией; - навигация при десятках типов — пункт меню на каждый активный тип не масштабируется бесконечно: типы группируются в navigation groups по группе типа, дальше работает нативный Global Search Filament (выбран в решениях сессии); порог сворачивания списка в одну группу — настройка группы
content-types, не хардкод.
9. API типа
Публичные и админ-эндпоинты типов уже вошли в поверхность ядра (modules/core, «Типы контента и записи»); здесь — деталь их поведения применительно к динамической схеме:
| Метод | Путь | Доступ | Деталь |
|---|---|---|---|
| GET | /api/v1/content/{type} | публич. | filter[поле]=/sort= — whitelist строго из filterable/sortable свойств схемы; keyset; конверт {data, meta} |
| GET | /api/v1/content/{type}/{slug} | публич. | Запись + развёрнутые relation/entity-picker (минимальный набор полей целевой записи для карточки, не полная модель — тот же принцип, что у related-content) |
| GET | /api/v1/content-types/{type}/schema | публич. | JSON Schema свойств — движок полей → JSON Schema (уже в ядре, ревизия №2 п.3) |
| CRUD | /api/v1/admin/content/{type} | content.{type}.manage | Валидация — FormRequest, сгенерированный из схемы типа (та же rules-декларация, что в Filament-форме, §4 движка полей: «единая валидация для админ/форма/API») |
| CRUD | /api/v1/admin/content-types | studio/admin-роль (§11) | Управление самими типами — создание/правка схемы, hash схемы в ответе (для cms:export/cms:import, см. §10) |
Все эндпоинты покрыты OpenAPI-атрибутами; поскольку схема динамическая, OpenAPI-спека генерируется на каждый зарегистрированный тип отдельным разделом во время сборки рантайм-спеки ядра (тот же механизм runtime-OpenAPI, что появился волной 24 — modules/core), а не пишется руками на тип.
10. Версионирование схемы типа и данных
Механика — прямой аналог версионирования блока (контракт блока, «Версии схем и data-миграция»), перенесённая на тип целиком (а не на отдельное свойство — свойство уже версионируется движком полей, §10 phase0-field-engine):
cms_content_types.schemaнесёт_v— версию композиции свойств;- изменение состава свойств аддитивно (новое свойство с дефолтом) — не требует
_v-bump, применяется мгновенно ко всем существующим записям (отсутствующий ключ вdataтрактуется как дефолт свойства); - ломающее изменение (переименование/удаление свойства, смена типа данных, смена кардинальности связи) — bump
_v+ класс data-миграции для существующих записей типа; - применение — лениво при чтении записи (с обратной записью upgraded
data) или батч-командойphp artisan cms:content:migrate {type}— та же дилемма «ленивая страховка vs блокирующий батч на апгрейде», что у блоков; - удаление свойства — не мгновенное: помечается
deprecated: true(не рендерится в форме/таблице, но данные не стираются) минимум один минор, реальное удаление — отдельной data-миграцией с явным подтверждением («N записей потеряют это значение»).
Перенос схемы между инсталляциями уже реализован волной 22 (data-exchange): cms:export/cms:import с sha256-хешем схемы и optimistic-lock apply-токеном — эта спека не вводит новый механизм переноса, а фиксирует, что именно переносится (вся декларация §3–§5) и как согласуется с data-миграцией данных (§10 п.4) при разных версиях схемы на источнике/цели.
11. Безопасность
Все границы входа — стандартизированными обработчиками ядра, без исключений для динамической схемы:
- Создание типа/свойства — FormRequest конструктора: имя свойства — канон движка полей (
snake_case, ≤50, иммутабельно после появления данных — то же правило, что у поля блока); пользовательские regex-rules свойства (если контент-менеджер вписывает свой паттерн валидации) — только черезRegexGuard::assertSafeRules(data-exchange); - Заполнение записи — rules, сгенерированные из схемы типа, применяются одинаково в Filament-форме, в submission формы (если запись создаётся через конструктор форм, §1) и в API — один набор правил, один источник правды (движок полей, «валидация»);
- Rich-text свойства —
Html::sanitizeна сохранении и на выводе (двойной барьер),{!! !!}— только для studio-роли и только для явно помеченного доверенного HTML-свойства; - Импорт схемы типа (
cms:import) — hash-проверка целевой схемы перед apply (§10), граница входа та же, что в админке (Field::fromArray+RegexGuard).
Матрица ролей
| Действие | Studio | Admin | Менеджер контента | Редактор |
|---|---|---|---|---|
| Создать/удалить тип, менять состав свойств | ✅ | ✅ (если разрешено настройкой инсталляции) | — | — |
| Настраивать связи, whitelist целевых типов | ✅ | ✅ | — | — |
Наполнять записи своего типа (content.{type}.manage) | ✅ | ✅ | ✅ | ✅ (без публикации, если роль ограничена) |
| Публиковать записи | ✅ | ✅ | ✅ | по настройке permission |
| Доверенный HTML-свойство, произвольный regex в rules свойства | ✅ | — | — | — |
Просмотр схемы через API (/content-types/{type}/schema) | публично | публично | публично | публично |
Право «кто создаёт типы» по умолчанию — studio (агентство ведёт архитектуру сайта клиента), но конфигурируемо на «admin» для клиентов с собственной IT-командой — настройка content-types.type_management_role (группа content-types, влияет только на UI, не на данные).
12. Производительность
- GIN-индекс на
cms_content_entries.data(весь JSONB) — базовый поиск по произвольным ключам без индексируемых свойств; - каждое
filterable/sortableсвойство — functional partial-индекс поверхdata(migrations spec):CREATE INDEX CONCURRENTLY … WHERE content_type_id = N, с явным кастом в выражении по типу данных свойства (::numeric,::timestamptz) — это покрывает и фильтр, иORDER BY(решено 15.07.2026; generated-STORED-колонка — только точечная оптимизация, см. §3.2). Имя индекса детерминировано:cms_ce_{type_key}_{property_code}_idx— свойства с одинаковымcodeу разных типов не конфликтуют (partial-предикат разводит); - лимиты (защита общей таблицы): число типов и суммарное число индексируемых свойств ограничены настройками
content-types.max_types/content-types.max_indexed_properties_total(группаcontent-types, modules/core) — без лимита рост числа индексов деградирует запись вcms_content_entriesдля всех типов сразу; - снятие
filterable/sortableне дропает индекс молча: свойство уходит из whitelist сразу, индекс помечается устаревшим и удаляется явной командойcms:content:cleanup-indexes(DROP INDEX CONCURRENTLY) — симметричноdeprecatedу свойств (§10); searchable-свойства типа собираются в одно выражениеto_tsvector('russian', …)— функциональный GIN-индекс partial по типу; общийGET /api/v1/searchядра агрегирует записи через штатного провайдера поиска,cms/search(Meilisearch/Typesense) заменяет его как provides-провайдер без изменения схемы типа;- keyset-пагинация везде на публичных list (§7),
COUNT(*)по всей таблице запрещён в списках админки (счётчики — приближённые/кешируемые, как в остальном ядре — modules/core, бюджеты); cms_content_relations(N:N, §5.2) индексируется в обе стороны (from_entry_id,to_entry_id + relation_code) — выборка связанных записей с любой стороны пары без full scan;- страницы с ПДн-свойствами (например, тип «Заявка на просмотр объекта» с телефоном/именем) не попадают в общий page-cache — тот же инвариант, что у остального контента с персональными данными (§10 modules/standard). Источник знания «это ПДн» — атрибут
piiсвойства (§3.1): тип с хотя бы однимpii-свойством исключает свои детальные из общего кеша и попадает в динамическую часть ПДн-паспорта инсталляции;
Критерий «тип перерос движок»
Явный сигнал перехода на выделенный бесспок-модуль вместо типа контента:
| Симптом | Порог-ориентир | Куда мигрировать |
|---|---|---|
| Нужны фасетные фильтры (пересечение многих атрибутов с подсчётом кандидатов) | больше 3–4 одновременно фильтруемых JSONB-свойств с ожиданием живого счётчика | cms/catalog-showcase → при росте cms/commerce-catalog + cms/commerce-facets |
| Объём записей растёт к сотням тысяч | > ~50–100k записей одного типа с активной фильтрацией; порог применим и к сумме записей всех типов — GIN-индекс и автовакуум общей cms_content_entries растут от совокупного объёма, не только от одного типа | Выделенная реляционная таблица модуля с собственными индексами/партиционированием |
Нужен полноценный полнотекст с релевантностью, не ILIKE/tsvector-заглушка | регулярные сложные поисковые запросы, ранжирование | cms/search (Meilisearch/Typesense) как provides-провайдер поверх движка или модуля |
| Данные требуют версионируемых ревизий содержимого (не просто CRUD) | контент типа редактируется как «страница» с черновиком/публикацией дерева блоков | Не тип контента — блочная страница (модель данных) |
| Модель — иерархия с ценами/спецполями, не просто список | прайс-лист с 3-уровневой структурой | cms/services |
13. Карта пресетов
Пресеты — тонкие модули или composer-сидеры, поставляющие готовую декларацию типа (свойства + связи + шаблоны + demo-контент) поверх движка, а не «толстые» модули с собственными таблицами. Существующие ТЗ cms/blog, cms/faq, cms/galleries, cms/banners, cms/reviews сейчас описывают собственные таблицы (cms_faq_*, cms_blog_posts, …) — это исторический слой ТЗ, написанный до этой спеки; согласование зафиксировано в открытых вопросах (§16) как предмет ревизии.
| Пресет | Свойства (ключевые) | Связи | Шаблоны | Донор |
|---|---|---|---|---|
Блог/статьи (blog) | title, excerpt, body (richtext), cover (media), published_at | author (N:1 → users), tags (N:N → таксономия) | content/blog/{list,detail} + RSS-фид | universal, gulaev-dev |
Новости (news) | как блог, короче body, без excerpt | category (N:1 → справочник) | content/news/{list,detail} | universal |
Кейсы/портфолио (cases) | title, client, result (richtext), gallery (media, multiple) | services (N:N → тип «Услуги» модуля cms/services) | content/cases/{list,detail}, карточка с «до/после» | artel-23ru |
Отзывы (reviews) | author_name, rating (number), text, photo (media) | subject (relation → любой тип/сущность, полиморфно через whitelist target) | блок «Отзывы» + content/reviews/list | catalog, er |
Галереи (galleries) | title, images (media, multiple), category | related_service (N:1) | лайтбокс-блок | artel-23ru, universal |
| Переиспользуемые слайдеры/баннеры | image, title, cta_url, sort_order, active_from/to | target_page (relation → страница, опционально) | блок content-list с card_template=slide | несколько |
| FAQ | question, answer (richtext), group | pages (N:N → страницы, где показывать) | блок FAQ + Schema.org FAQPage | несколько проектов |
| Ленты (комбинированные, «новости+блог+кейсы» на одной странице) | — (композиция) | — | блок content-list с content_type[] мульти-выбором | universal |
| Команда | name, position, photo, bio | department (N:1 → справочник) | карточка сотрудника | несколько |
| Сертификаты/дипломы | title, image, issued_at, issuer | — | галерея сертификатов | несколько |
| Справочники общего назначения (город, бренд, партнёр) | name, logo (опц.) | обратная сторона связей других типов | простой список | универсально |
Пресет физически — либо composer-пакет cms/preset-<name> (регистрирует тип программно при enable, чтобы клиент мог удалить пресет без потери движка), либо профиль установки (модель данных, «Сидинг темой и модулем») — переносимый JSON/YAML-файл декларации типа + demo-записи, импортируемый cms:content:import.
14. Фазы реализации
MVP (обязателен для 1.0):
- базовый движок:
cms_content_types+cms_content_entries, конструктор свойств (§3) со всеми типами данных движка полей (§4), кроме сложных условных схем; - связи 1:1/1:N/N:1/N:N с
inverseиon_delete(§5), таблицаcms_content_relations; - генерик-ресурс
ContentEntryResource+ContentTypeResource(§8); - шаблоны через каскад тем + блок
content-list(§6); - публичные list/detail роуты с whitelist-фильтрами/сортировкой, keyset (§7);
- API типа (§9) — уже частично в поверхности ядра (
/content/{type}, схема); - версионирование схемы типа + лениво-миграция данных (§10, базовый случай);
- демо-сидер уровня инсталляции: создаёт 1–2 демонстрационных типа с записями — иначе
/_galleryи playground не могут показать движок (тип не существует, пока его не создали; обычный демо-сидер модуля тут не работает); - лимиты
content-types.max_types/max_indexed_properties_total(§12).
Позже (после 1.0):
- сложные фасетные фильтры на самом движке (до перехода в
cms/catalog-showcase— критерий §12) — возможно, ограниченная фасетная надстройка для типов среднего объёма без выделенного каталога-модуля; - конструктор шаблонов карточки/детальной мышкой (сейчас — Blade-override темой, визуальный конструктор — отдельная амбиция, не MVP);
- batch UI управления версионными миграциями схемы (сейчас — CLI
cms:content:migrate, визуальный прогресс/дифф — позже); - полнотекстовый поиск по свойствам как штатная опция типа (сейчас —
ILIKE/базовыйtsvector, богатая релевантность — черезcms/search).
15. Тесты и приёмка
- [ ] Тип, созданный в админке (без кода), сразу доступен: генерик-ресурс, публичные list/detail, API
/content/{type}— без деплоя/миграции проекта. - [ ] Property-set типа порождает согласованно Filament-форму, rules, API-схему, публичный рендер (тот же контракт, что у поля движка, применённый к композиции).
- [ ] Связь 1:N создаётся и читается корректно с обеих сторон; связь N:N создаёт строку
cms_content_relations,inverseвиден на целевой записи как read-only. - [ ]
on_delete: restrictблокирует удаление целевой записи со списком ссылающихся;cascade/null— корректно очищают/обнуляют связь, не трогая саму запись. - [ ] Публичные фильтры/сортировка работают строго по whitelist (
filterable/sortable); неизвестный параметр — 422, не игнор. - [ ] Индексируемое свойство (
filterable/sortable) порождает functional partial-индекс (CONCURRENTLY, с кастом по типу данных — §12); EXPLAIN подтверждает Index Scan, не Seq Scan по JSONB; построение индекса не блокирует запись других типов. - [ ] Тип с
seo_enabled+has_detailрегистрирует источник вSitemapRegistry; записи появляются в/sitemap.xml, сбой источника изолирован. - [ ] Лимиты
content-types.max_types/max_indexed_properties_totalсоблюдаются: превышение — 422 с внятным сообщением, не тихое создание. - [ ] Версионирование схемы типа: аддитивное изменение не требует
_v-bump; ломающее требует и применяется лениво/батчем; удаление свойства проходит черезdeprecated, не мгновенно. - [ ]
cms:export/cms:importсхемы типа — hash-проверка отклоняет apply при расхождении с целевой схемой (регресс на существующий механизм волны 22). - [ ] Страницы/записи с ПДн-свойствами исключены из общего page-cache.
- [ ] Тестовая БД — только
cms_test;migrate:fresh/refresh/resetиdb:wipeзапрещены политикой студии даже в контрактных тестах движка. - [ ] Иерархический тип: adjacency-list рендерится деревом в генерик-ресурсе, защита от циклов (
parent_id = selfи «потомок как родитель» — 422).
16. Открытые вопросы
- Ревизия существующих ТЗ-пресетов.
cms/blog,cms/faq,cms/galleries,cms/banners,cms/reviewsнаписаны как самостоятельные модули со своими таблицами (cms_blog_posts,cms_faq_*, …) — до появления этой спеки. Нужна ревизия каждого: либо модуль становится тонким пресетом поверх движка (данные — вcms_content_entries, модуль — только demo-контент/специфичный блок/интеграция), либо явно обосновывается, почему конкретный пресет остаётся отдельной таблицей. Поreviewsвопрос закрыт 15.07.2026: пресет движка,subject—relationс whitelist целей, а тяжёлое (денормализованный агрегат рейтингаcms_reviews_aggregates, антифрод-крючья) — тонкой надстройкой модуля рядом с движком, модерация — черезsuggests: cms/moderation. Кандидатура на исключение из движка снята: узкое место (атомарный инкремент агрегата) вынесено в свою таблицу и не давит на общую JSONB. Уfaqдополнительный фактор: модуль уже реализован в коде полигона (эталон фазы 3) — перенос означает data-миграцию работающих таблиц. Решение — по правилу проекта «изменился контракт ядра → сначала ревизия стандарта и затронутых ТЗ, потом код». - Ревизии записей типа контента. Страница версионируется целиком (снимок дерева блоков в ревизии); нужна ли отдельная история версий для записи типа контента (кто и когда поменял
data) — вероятно, достаточноupdated_at+cms/audit(журнал изменений), а не полноценные ревизии, но требует явного решения до реализации истории изменений в Filament-форме записи. Оптимистическая блокировка записи.✅ Решено 15.07.2026:lock_versionнаcms_content_entriesвключён всегда, одинаково для всех типов (согласованность с остальным ядром важнее точечной экономии; требование §4 standard.md). Колонка добавляется миграцией движка — в текущем коде её ещё нет.- Фасетные фильтры на самом движке (см. §12/§14) — есть ли смысл в лёгкой фасетной надстройке для типов среднего объёма, или граница «движок → каталог- модуль» должна быть жёстче и проще (без полумер) — решается на практике первых пилотных типов с реальным трафиком.
- Статус реализации против спеки (сверка с кодом
src/, 15.07.2026). База движка построена волной C — спека здесь описывает работающее, а не план: ✅ маршрутизация без деплоя (Route::fallback+PublicContentController, §7.1); ✅ механика индексации — functional partial-индексы с кастом по типу данных иsort=публичного API поверх них (§3.2/§12); ✅ атрибуты свойстваfilterable/sortable/searchable/multiple/in_list/pii/owner_module(legacyindexedнормализуется при чтении схемы); ✅ типrelation+ таблицаcms_content_relations+ContentRelationsSync/ContentRelationGuard(кардинальности,on_delete, §5); ✅ рантайм-регистрация правcontent.{slug}.view/manageпри сохранении типа (ContentType::booted, без ожиданияcms:rbac:sync, §3.1/§8.2); ✅ генерик-ContentEntryResourceстроит форму по схеме типа черезFieldEngine::filament(). Остаётся:searchable-механика (tsvector — атрибут заведён, реализация следующим срезом);entity-pickerпока MVP-обёртка надrelation(модальный пикер с превью — следующий срез); отдельный пункт меню на каждый активный тип (§8.2) — сейчас один ресурс с фильтром по типу; подтвердить runtime-OpenAPI раздел per-type (§9). Актуальный сводный статус — open-questions. - Импорт/связь с
cms/related-content. Тип контента сrelation-свойством и модуль рекомендаций решают смежные, но разные задачи (§5.2). Нужно ли позволитьrelated-contentиспользовать типизированныеcms_content_relationsкак дополнительный источник кандидатов (не только пересечение таксономий) — вопрос к ревизииrelated-content, не к этой спеке.
Связи
- Модель данных ядра — базовая таблица
cms_content_types/cms_content_entries, эта спека её детализирует. - Движок полей — источник типов данных свойств, единая валидация, версионирование поля.
- Контракт блока — образец JSONB+
_v+data-миграции, по которому смоделировано версионирование схемы типа (§10). - Контракт темы — каскад шаблонов
content/{type}/*. cms/related-content— соседний, но не дублирующий механизм рекомендаций/ручных связей (см. §5.2, §16).cms/catalog-showcase,cms/services— бесспок-соседи, критерий разграничения — §1 и §12.- ТЗ ядра, стандарт модуля — поверхность API/событий и обязательные гейты, которым эта подсистема подчиняется.
- Решение Q5.