Skip to content

Движок «Типы контента» (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). Три свойства, которые должны быть верны одновременно:

  1. No-code создание — тип, набор свойств, типы данных свойств, связи с другими типами и доменными сущностями настраиваются в Filament мышкой, без правки кода и без миграции на каждый тип — схема свойств хранится декларативно (JSONB), как схема блока (§3).
  2. Немедленная доступность — как только тип сохранён, у него есть рабочий генерик-ресурс в админке, публичные роуты list/detail, API — без деплоя.
  3. Генерик всюду — одна 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) синхронно/в очереди выполняет:

  1. запись схемы в cms_content_types.schema (jsonb) — без DDL-миграции проекта;
  2. если есть 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);
  3. публичные роуты /{slug} (list) и /{slug}/{entry-slug} (detail) начинают отвечать — не регистрацией нового Laravel-роута, а через единый динамический резолвер slug ядра (SlugResolver, решение 15.07.2026 — см. §7.1). Ключевое: роут в приложении один и статический (route:cache-совместим), а решение «страница / тип контента / модульный префикс» принимается в рантайме по БД — поэтому новый тип отвечает без деплоя и без пересборки кеша роутов;
  4. авто-появление типа в списке ContentEntryResource (генерик-ресурс записей, §8) — пункт меню админки без правки навигации;
  5. авто-доступность 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-миграцией движка: indexedfilterable + 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-pickerUI-обёртка над 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:1relation_id внутри data (jsonb) свойстваОдно значение, не требует отдельного индекса на связь саму по себе — читается вместе с записью
1:N (обратная сторона)Вычисляется запросом по filterable-индексу свойстваПрямая сторона хранит relation_id; обратная — не хранится дублем, а запрашивается (нет риска рассинхрона)
N:NОтдельная таблица cms_content_relationsJSONB-массив 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
31 сегмент, route_pattern типа совпалlist типа (/vacancies)
41 сегмент, типа нетстраница — делегирование в PageController::show()
52 сегмента, тип совпал и has_detaildetail записи (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-typesstudio/admin-роль (§11)Управление самими типами — создание/правка схемы, hash схемы в ответе (для cms:export/cms:import, см. §10)

Все эндпоинты покрыты OpenAPI-атрибутами; поскольку схема динамическая, OpenAPI-спека генерируется на каждый зарегистрированный тип отдельным разделом во время сборки рантайм-спеки ядра (тот же механизм runtime-OpenAPI, что появился волной 24 — modules/core), а не пишется руками на тип.

10. Версионирование схемы типа и данных

Механика — прямой аналог версионирования блока (контракт блока, «Версии схем и data-миграция»), перенесённая на тип целиком (а не на отдельное свойство — свойство уже версионируется движком полей, §10 phase0-field-engine):

  1. cms_content_types.schema несёт _v — версию композиции свойств;
  2. изменение состава свойств аддитивно (новое свойство с дефолтом) — не требует _v-bump, применяется мгновенно ко всем существующим записям (отсутствующий ключ в data трактуется как дефолт свойства);
  3. ломающее изменение (переименование/удаление свойства, смена типа данных, смена кардинальности связи) — bump _v + класс data-миграции для существующих записей типа;
  4. применение — лениво при чтении записи (с обратной записью upgraded data) или батч-командой php artisan cms:content:migrate {type} — та же дилемма «ленивая страховка vs блокирующий батч на апгрейде», что у блоков;
  5. удаление свойства — не мгновенное: помечается 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).

Матрица ролей

ДействиеStudioAdminМенеджер контентаРедактор
Создать/удалить тип, менять состав свойств✅ (если разрешено настройкой инсталляции)
Настраивать связи, 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_atauthor (N:1 → users), tags (N:N → таксономия)content/blog/{list,detail} + RSS-фидuniversal, gulaev-dev
Новости (news)как блог, короче body, без excerptcategory (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/listcatalog, er
Галереи (galleries)title, images (media, multiple), categoryrelated_service (N:1)лайтбокс-блокartel-23ru, universal
Переиспользуемые слайдеры/баннерыimage, title, cta_url, sort_order, active_from/totarget_page (relation → страница, опционально)блок content-list с card_template=slideнесколько
FAQquestion, answer (richtext), grouppages (N:N → страницы, где показывать)блок FAQ + Schema.org FAQPageнесколько проектов
Ленты (комбинированные, «новости+блог+кейсы» на одной странице)— (композиция)блок content-list с content_type[] мульти-выборомuniversal
Командаname, position, photo, biodepartment (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: пресет движка, subjectrelation с 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 (legacy indexed нормализуется при чтении схемы); ✅ тип 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.

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