Skip to content

Фаза 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 на текущий момент кодада
fieldsFilament-схема формы (замыкание — ленивость важна для route:cache)да
viewBlade-шаблон, резолвится каскадом проект → тема → parent → ядрода
label / group / iconUX палитры редакторада
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):

  1. каждый экземпляр блока несёт _v;
  2. тип блока объявляет для каждого перехода класс миграции;
  3. применение — лениво при рендере (с обратной записью результата в ревизию) или батчем 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.phpblocks/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:

  1. demo() валиден против текущей fields()-схемы (форма примет демо-props без ошибок);
  2. рендер demo() не бросает исключений и не содержит непройденного {!! !!} вне whitelist;
  3. для каждого объявленного перехода версий: migrate(старые демо-props) даёт props, валидные против новой схемы (цепочка 1→2→…→current замкнута, без дыр);
  4. схема не содержит запрещённых стилевых полей (линтер Q9);
  5. контейнер (если container(true)) рекурсивно мигрирует children и уважает лимит глубины.

Открытые детали для фазы 0 (вынести в спеку до кода)

  • формат id экземпляра блока — ULID или короткий nanoid (стабильность в диффе ревизий);
  • где именно граница «ленивая миграция при рендере» vs «блокирующая при cms:upgrade» (по умолчанию: обновление модуля с ломающей схемой требует батч-миграции в процедуре апгрейда, ленивая — только страховка);
  • API предпросмотра черновика в обход page-cache (токен-ссылка) — стык с центром обновлений и Q7.

Связи

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