Тема
Фаза 0 — Контракт блока и BlockRegistry
Статус: спека фазы 0 (уровень «как именно»). Опирается на решения Q9 (средняя свобода), Q7 (версии страниц) и на расширяемость → BlockRegistry. Это самое дорогое для исправления место: props блоков лежат в JSONB у клиента, а схему меняет обновление модуля. Контракт правится свободно до 1.0, после — только аддитивно.
Зачем контракт нужен именно первым
Блок — точка, где сходятся четыре подсистемы: drag&drop-редактор (Filament), хранение (JSONB в ревизии страницы), рендер (Blade через каскад тем) и центр обновлений (миграция данных при смене схемы). Ошибка в контракте блока размножается в данные каждого клиента и становится невыносимой. Поэтому его фиксируем до первой строки кода ядра.
Определение типа блока
Тип блока — объект-описание, не значение enum (в отличие от жёсткого const BLOCKS в universal). Ядро, модули и клиентский проект регистрируют типы одинаково.
php
namespace Cms\Core\Blocks;
BlockType::make('hero')
->label('Первый экран') // подпись в галерее блоков
->group('Промо') // группа в палитре редактора
->icon('heroicon-o-photo') // иконка в палитре
->schemaVersion(3) // текущая версия схемы props
->fields(fn () => HeroFields::schema()) // Filament-схема формы (ленивая)
->view('blocks.hero') // резолвится каскадом тем
->demo(HeroFields::demo()) // props для галереи/предпросмотра
->interactive(false) // true → блоку разрешён React-остров
->container(false) // true → блок принимает вложенные блоки
->personalized(false) // true → вывод зависит от посетителя (ревизия 14.07.2026)
->cacheKeyUsing(fn (array $p) => null) // доп. вклад в ключ page-cache (обычно null)
->migrations([
1 => HeroMigrateV1ToV2::class, // миграции данных props, from-version → класс
2 => HeroMigrateV2ToV3::class,
]);Обязательные поля описания
| Поле | Назначение | Обязательно |
|---|---|---|
id (make('hero')) | Стабильный машинный идентификатор, попадает в JSONB | да |
schemaVersion | Версия схемы props на текущий момент кода | да |
fields | Filament-схема формы (замыкание — ленивость важна для route:cache) | да |
view | Blade-шаблон, резолвится каскадом проект → тема → parent → ядро | да |
label / group / icon | UX палитры редактора | да |
demo | Валидные демо-props (галерея, контрактный тест) | да |
interactive | Разрешение на React-остров (по умолчанию false, SEO-safe) | нет |
container | Блок-контейнер с child-блоками (см. ниже) | нет |
personalized | Вывод зависит от посетителя (сегмент, корзина): страница кеширует каркас, фрагмент — остров/сегментированный фрагмент-кеш ядра (персонализация, ревизия 14.07.2026; cacheKeyUsing для таких блоков не используется — фрагмент вне общего ключа) | нет (default false) |
migrations | Карта int $fromVersion → class для data-миграции props | да, если schemaVersion > 1 |
Что схема блока НЕ содержит (Q9)
Прямое следствие «средней свободы» — в схеме блока нет полей стиля:
- нет цвета, шрифта, размера, произвольного отступа/паддинга;
- нет «фонового изображения секции произвольного размера»;
- нет инлайн-CSS и класс-строк от контент-менеджера.
Вид блока задаёт тема (theme.json + Blade-переопределение). Схема несёт только контентные поля (заголовок, текст, ссылка, выбор из enum-вариантов, привязка к медиа). Допустим ограниченный выбор варианта оформления из предопределённого набора темы (variant: 'light' | 'dark' | 'accent') — это не свобода стилей, а переключение заранее нарисованных дизайнером состояний. Линтер контракта (контрактный тест) отклоняет поля с именами color, padding, margin, font*, css, style.
Блоки-контейнеры (вложенность)
Средняя свобода требует секций и колонок. Контейнер — блок с ->container(true), чьи props содержат зарезервированный ключ children (упорядоченный массив дочерних блоков той же структуры, что и корневой список страницы).
php
BlockType::make('columns')
->label('Колонки')
->container(true)
->fields(fn () => [
Select::make('layout')->options(['2' => '50/50', '3' => '33/33/33']),
// children редактируется вложенным drag&drop-репитером, не текстовым полем
])
->view('blocks.columns');Правила вложенности:
- лимит глубины — конфиг
cms.blocks.max_nesting_depth(по умолчанию 3): защита от бесконечной вложенности и тяжёлого рендера; - контейнер валидирует, какие типы допустимы внутри (
->allowedChildren([...])или «любые не-контейнеры»); - data-миграция контейнера обязана рекурсивно мигрировать
children.
Хранение props
Props блока — JSONB. Единица хранения — не отдельная таблица блоков, а массив блоков внутри ревизии страницы (следствие Q7): версионирование страницы = снимок всего дерева блоков.
jsonc
// cms_page_revisions.blocks (jsonb)
[
{ "type": "hero", "_v": 3, "id": "b1a2", "props": { "title": "…", "cta": {…} } },
{ "type": "columns", "_v": 1, "id": "c9f0", "props": {
"layout": "2",
"children": [
{ "type": "features", "_v": 2, "id": "f4e1", "props": {…} }
]
}}
]Обязательные служебные ключи каждого блока в JSONB:
type— id типа из BlockRegistry;_v— версия схемы на момент записи (ключ безопасного центра обновлений);id— стабильный идентификатор экземпляра (для якорей, аналитики, диффа ревизий).
Индексация: GIN по blocks для запросов «на каких страницах используется блок X» (нужно центру обновлений, чтобы оценить объём data-миграции). Поля с бизнес-смыслом при необходимости выносятся в generated-колонки — но у блоков это редкость (блоки — контент страницы, не фильтруемая сущность; фильтруемое — это типы контента).
Версии схем и data-миграция
Скрытый айсберг блочных CMS. Механика (аналог deprecations Gutenberg):
- каждый экземпляр блока несёт
_v; - тип блока объявляет для каждого перехода класс миграции;
- применение — лениво при рендере (с обратной записью результата в ревизию) или батчем
php artisan cms:blocks:migrate.
php
final class HeroMigrateV2ToV3 implements BlockDataMigration
{
public function migrate(array $props): array
{
// v2: cta_url + cta_text строками → v3: cta объектом
$props['cta'] = ['url' => $props['cta_url'] ?? null, 'text' => $props['cta_text'] ?? 'Подробнее'];
unset($props['cta_url'], $props['cta_text']);
return $props;
}
}Правило обратной совместимости:
- минор модуля меняет схему только аддитивно (новое поле с дефолтом) — миграция не обязательна;
- ломающее изменение (переименование/удаление/смена типа поля) = мажор модуля + обязательный класс миграции + запись в CHANGELOG;
- рендер никогда не падает на устаревшем
_v: если миграции для перехода нет — блок рендерит fallback-заглушку «блок обновляется» и пишет предупреждение в лог, а не бросает исключение на публичной странице.
Без этого механизма центр обновлений опасен — это условие его существования.
Рендер
php
// упрощённо: пайплайн рендера одного блока
$type = BlockRegistry::get($block['type']); // описание типа
$props = BlockMigrator::upgrade($type, $block); // до актуальной _v (ленивая миграция)
$props = FilterBus::apply("block.render.{$type->id}", $props, $ctx); // модули могут вмешаться
return view($type->resolveView(), ['props' => $props, 'block' => $block]);resolveView()идёт по каскаду тем с template-suggestions (blocks/hero--variant-accent.blade.php→blocks/hero.blade.php);- вывод в шаблоне — только
;{!! !!}разрешён линтером лишь для whitelisted доверенных SEO/rich-полей из админки; - интерактивный блок (
interactive(true)) отдаёт server-rendered HTML + точечно гидрируемый React-остров (см. фронт-архитектуру); блок без флага React не получает — по умолчанию всё SEO-safe.
Регистрация и открытость
Модуль/тема/проект регистрируют блок в boot() своего service-provider — ядро не редактируется:
php
public function boot(): void
{
BlockRegistry::register(HeroBlock::describe());
BlockRegistry::register(CatalogGridBlock::describe());
}Клиентский кастом-блок регистрируется из app/Local/Blocks/ тем же вызовом — форк ядра не нужен.
Контрактные тесты блока (пакет cms-testing)
Каждый тип блока обязан пройти набор из cms-testing:
demo()валиден против текущейfields()-схемы (форма примет демо-props без ошибок);- рендер
demo()не бросает исключений и не содержит непройденного{!! !!}вне whitelist; - для каждого объявленного перехода версий:
migrate(старые демо-props)даёт props, валидные против новой схемы (цепочка1→2→…→currentзамкнута, без дыр); - схема не содержит запрещённых стилевых полей (линтер Q9);
- контейнер (если
container(true)) рекурсивно мигрируетchildrenи уважает лимит глубины.
Открытые детали для фазы 0 (вынести в спеку до кода)
- формат
idэкземпляра блока — ULID или короткий nanoid (стабильность в диффе ревизий); - где именно граница «ленивая миграция при рендере» vs «блокирующая при
cms:upgrade» (по умолчанию: обновление модуля с ломающей схемой требует батч-миграции в процедуре апгрейда, ленивая — только страховка); - API предпросмотра черновика в обход page-cache (токен-ссылка) — стык с центром обновлений и Q7.
Связи
- Родительские решения: Q9, Q7, Q5 (граница блок/тип контента).
- Концепция: расширяемость, глоссарий → блок.
- Соседние спеки фазы 0: контракт модуля, три оси, контракт темы.