Skip to content

Фаза 0 — Контракт модуля

Статус: спека фазы 0. Что обязан и что не смеет делать модуль. Разворачивает «7 пунктов» из расширяемости в проверяемый контракт. Стыкуется с жизненным циклом модулей (состояния, версии, откат — там; здесь — структура и границы), с моделью данных (владение данными) и безопасностью (capability-слой).

Что такое модуль

Модуль — приватный composer-пакет, добавляющий возможность, нужную некоторым сайтам. Он не форкает ядро: всё, что делает, — регистрирует через открытые точки ядра и реагирует на его события. Форк ядра или прямая запись в его таблицы — нарушение контракта, а не «продвинутое использование».

Контракт — это набор из обязательств (модуль ДОЛЖЕН), запретов (модуль НЕ СМЕЕТ) и гарантий ядра (ядро ОБЕЩАЕТ модулю). Проверяется пакетом cms-testing — модуль без зелёного контрактного прогона не публикуется в Satis.

Структура пакета

cms-catalog/
├── composer.json            # require + extra.cms (манифест, см. ниже)
├── src/
│   ├── CatalogServiceProvider.php     # единственная точка входа модуля
│   ├── Blocks/               # типы блоков (BlockType-описания)
│   ├── Widgets/              # типы виджетов
│   ├── Filament/             # ресурсы/страницы (Filament-плагин)
│   ├── Lifecycle/            # Install / Enable / Disable / Uninstall
│   ├── Listeners/            # реакции на события ядра
│   ├── Policies/             # свои политики (не переопределяют ядро)
│   └── Contracts/            # если модуль сам — точка расширения для других
├── database/migrations/      # только таблицы модуля (префикс catalog_)
├── content/                  # переносимый демо-контент (см. модель данных)
├── routes/                   # роут-файлы, регистрируются через хук ядра
├── resources/views/          # blade модуля (namespace = имя модуля)
├── lang/                     # строки UI модуля (через __())
└── tests/                    # + прогон контрактных тестов cms-testing

Один provider — единственная точка входа. Всё остальное он регистрирует, а не ядро его сканирует (никакого директорного PluginManager-скана — штатное composer auto-discovery).

Манифест модуля

Живёт в composer.json → extra.cms. Полная форма (поля жизненного цикла описаны в спеке жизненного цикла):

jsonc
{
  "name": "cms/catalog",
  "extra": {
    "cms": {
      "schema_version": 7,
      "minimum_core_version": "2.3.0",
      "requires":  [],
      "suggests":  ["cms/search"],
      "conflicts": [],
      "provides":  [],
      "load_after":["cms/seo"],
      "enables":   [],
      "lifecycle": { "install": "...", "enable": "...", "disable": "...", "uninstall": "..." },
      "health":    ["Cms\\Catalog\\Health\\CatalogCheck"],
      "permissions": ["catalog.view", "catalog.manage"],
      "capabilities": ["own-tables", "own-routes", "own-events", "register-blocks"]
    }
  }
}

capabilitiesявный список того, что модуль намерен делать (см. capability-слой ниже). Ядро сверяет фактические действия модуля с декларацией: модуль, пытающийся сделать недекларированное (напр. писать в users), останавливается.

Обязательства модуля (модуль ДОЛЖЕН)

1. Регистрировать вклад в boot(), уважая состояние

php
final class CatalogServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // ПЕРВЫМ делом — проверка состояния: выключенный модуль не тратит ресурсы
        if (! ModuleState::isEnabled('cms/catalog')) {
            return;
        }
        // биндинги контейнера
    }

    public function boot(): void
    {
        if (! ModuleState::isEnabled('cms/catalog')) {
            return;
        }
        BlockRegistry::register(CatalogGridBlock::describe());
        WidgetRegistry::register(CatalogFilterWidget::describe());
        $this->registerRoutesVia(CoreRouter::class);   // НЕ Route:: напрямую
        Filament::registerPlugin(CatalogPlugin::make());
        $this->registerListeners();
    }
}

2. Привозить свои миграции — только свои таблицы

  • префикс имени модуля (catalog_products, catalog_categories);
  • down() обязателен (условие отката из жизненного цикла);
  • additive/expand на минорах; ломающее изменение схемы = мажор + план expand-contract;
  • FK на таблицы ядра допустимы, но модуль не меняет схему ядра (только читает через контракт).

3. Регистрировать роуты через хук ядра, не Route:: напрямую

Прямой вызов Route:: в провайдере несовместим с route:cache — это зафиксированный инцидент TrailingSlashUrlGenerator в universal. Модуль отдаёт роут-файл ядру:

php
$this->registerRoutesVia(function (CoreRouter $r) {
    $r->group(['prefix' => 'catalog', 'middleware' => ['web', 'cms.city']], base_path('...routes/web.php'));
});

Ядро собирает все роуты модулей в один кешируемый набор с корректным UrlGenerator.

4. Поставлять Filament-плагин, а не лезть в чужую панель

Ресурсы/страницы/виджеты модуля — через FilamentPlugin. Регистрация в общую панель, свои permissions декларируются (Shield подхватывает). Модуль не редактирует ресурсы ядра или других модулей — только добавляет свои (или расширяет через события Filament render-hooks).

5. Реагировать на события ядра, а не патчить его код

Лид создан, страница сохранена, кеш сброшен — модуль слушает (события — ось 1 из трёх):

php
#[CmsListener(event: PageSaved::class, after: [SeoListener::class])]
final class WarmCatalogCache { public function handle(PageSaved $e): void { /* ... */ } }

Никаких «monkey-patch ядра», глобальных do_action, правок vendor/cms/core.

6. Декларировать совместимость и права

  • require: {"cms/core-contracts": "^2.0"} — зависимость от контрактов, не реализации;
  • minimum_core_version в манифесте (человекочитаемая ошибка в UI);
  • permissions[] — Shield-права модуля (обязательно, если у модуля есть админ-действия);
  • capabilities[] — что модуль намерен делать (сверяется ядром).

7. Декларировать настройки, health-чеки и документацию

  • настройки — только через SettingSchema (ось 3): свой стор/таблица настроек запрещены; десятки настроек — норма, но все читаются через кеш группы;
  • health-чеки — поле health манифеста (самодиагностика);
  • документация — модуль несёт CLAUDE.md (для агента) и docs/module.md: что делает, схема данных, точки расширения, зависимости и обратные зависимости, какие настройки/события/права декларирует, что произойдёт при выключении. Из docs/module.md собирается страница модуля в docs-site — по одному MD-файлу на модуль, источник живёт в пакете рядом с кодом (DX).

8. Проходить контрактные тесты cms-testing

Модуль подключает набор проверок и держит его зелёным (см. ниже). Красный контракт = не публикуется.

Запреты модуля (модуль НЕ СМЕЕТ)

Из «модуль ≠ ядро по правам» и владения данными:

ЗапретПочемуЧто вместо
Писать в таблицы ядра (cms_*) напрямуюЯдро свободно меняет схему → сломает модульКонтракт cms/core-contracts (репозитории/сервисы)
Читать схему ядра (SQL к cms_pages и т.п.)Привязка к внутренней структуреТе же контракты + события
Менять схему ядра миграциейОбновление ядра затрётСвои таблицы + полиморфные точки (cms_seo_meta)
Переопределять политики/права ядраТихая эскалация привилегийТолько добавлять свои Policy/permissions
Route:: напрямую в провайдереЛомает route:cache (инцидент universal)Хук CoreRouter
Трогать пользователей/роли ядраОбход RBACСвои сущности + связь по FK
env() в кодеЛомается при config:cache.envconfig()
{!! $userInput !!} в bladeXSS (91% CVE WP — в плагинах); линтер запрещает вне whitelist

Capability-слой (песочница прав)

Явная модель того, что модулю разрешено, — заготовка под открытый маркетплейс, которую нельзя ретрофитить потом.

CapabilityДаёт правоПроверка ядром
own-tablesсоздавать/мигрировать таблицы своего префиксамиграции вне префикса — отклоняются
own-routesрегистрировать роуты через CoreRouterпрямой Route:: — линтер контракта
own-eventsпубликовать свои события, слушать события ядра
register-blocksдобавлять типы в BlockRegistry/WidgetRegistry
filament-pluginдобавлять ресурсы в панельправка чужих ресурсов — запрещена
outbound-httpходить во внешние API (интеграции)через integration-core (креды/ретраи/логи)

Модуль без декларации capability на действие — не может это действие выполнить. Сейчас (закрытый маркетплейс) проверки мягкие (предупреждение в CI/cms:doctor); при открытии маркетплейса (фаза 5) — жёсткий enforcement. Модель уже на месте.

Гарантии ядра (ядро ОБЕЩАЕТ модулю)

Контракт двусторонний. Ядро обязуется:

  • стабильность cms/core-contracts — публичные интерфейсы/события меняются только по semver с deprecation-циклом (≥1 minor, модель Symfony);
  • изоляция сбоев — исключение в boot()/рендере/слушателе модуля не роняет ядро и соседей (жизненный цикл);
  • порядок загрузки по load_after (топологическая сортировка);
  • единые оси — модуль не изобретает свой стор настроек / очередь / шину событий, а пользуется тремя осями ядра;
  • реестр точек расширенияphp artisan cms:hooks показывает все события/фильтры с типами payload, чтобы модуль знал, куда вклиниваться.

Модуль как точка расширения (модуль для модулей)

Модуль может сам быть основой для других (provides): например, cms/payments объявляет контракт payment-gateway, а cms/payment-yookassa его реализует. Тогда модуль-основа:

  • кладёт свой контракт в src/Contracts/ и публикует его как под-пакет *-contracts;
  • регистрирует свой реестр реализаций (аналог BlockRegistry);
  • объявляет provides: ["payment-gateway"], реализации — requires на него.

Так лестница расширяемости работает не только «ядро → модуль», но и «модуль → под-модуль» без изменения контракта.

Контрактные тесты (cms-testing)

Набор, который модуль обязан держать зелёным:

  1. манифест валиден — все обязательные поля, requires/conflicts разрешимы, semver корректен;
  2. миграции обратимыup() затем down() не оставляют следов; префикс соблюдён;
  3. блоки/виджеты проходят контракт блока — demo валиден, цепочки _v замкнуты, нет стилевых полей;
  4. роуты кешируемыroute:cache не падает (регистрация через CoreRouter);
  5. нет запрещённых действий — статический анализ: нет записи в cms_*, нет Route::, нет env(), нет {!! !!} вне whitelist;
  6. capability соблюдены — фактические действия ⊆ декларированных capabilities;
  7. lifecycle идемпотентенinstalluninstallinstall чист; enable/disable обратимы;
  8. события изолированы — брошенное в слушателе исключение не ломает цепочку.

Чеклист фазы 0 (контракт модуля)

  • [ ] Структура пакета + единственный service provider со state-guard.
  • [ ] Манифест extra.cms c requires/provides/lifecycle/health/permissions/capabilities.
  • [ ] docs/module.md + CLAUDE.md — обязательная документация модуля (файл на модуль).
  • [ ] Регистрация роутов через CoreRouter (не Route::) — защита route:cache.
  • [ ] Filament-плагин; модуль не правит чужие ресурсы.
  • [ ] Capability-слой: декларация + сверка ядром (мягкая сейчас, жёсткая в фазе 5).
  • [ ] Запреты (таблицы ядра, схема ядра, права, env, XSS) — статическим анализом.
  • [ ] Двусторонние гарантии ядра (стабильность контрактов, изоляция, порядок, оси).
  • [ ] Контрактные тесты cms-testing зелёные — условие публикации в Satis.

Связи

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