Skip to content

Фаза 0 — Три оси: события/фильтры, очереди, настройки

Статус: спека фазы 0. Три сквозные подсистемы, на которых висит вся модульность (каталог подсистем): без них модуль не может ни вклиниться в поток, ни работать в фоне, ни хранить настройки. Если спроектировать неидеально — модульность неполноценна. Опирается на расширяемость (три недостающих примитива), донора universal (стор настроек) и контракт модуля (кто чем пользуется).

Почему именно эти три

Модуль взаимодействует с системой ровно тремя способами: синхронно вклинивается в поток (события/фильтры), уходит в фон (очереди), хранит и читает конфигурацию (настройки). Всё остальное — надстройки над этими тремя. Каждый модуль обязан пользоваться осями ядра, а не изобретать свои — иначе N модулей = N сторов настроек, N способов слушать события, хаос. Это гарантия ядра из контракта.


Ось 1. События и фильтры

Два разных примитива, которые часто путают. Событие — «случилось X, отреагируйте» (ничего не возвращает). Фильтр — «вот значение, верните изменённое» (цепочка трансформаций). Laravel даёт первое, второго нет — достраиваем.

События (с управляемым порядком)

Laravel Event::listen не даёт декларативно управлять порядком слушателей — а для CMS это критично (SEO-листенер должен отработать до кеш-прогрева). Заимствуем атрибуты before/after/priority (Drupal 11 / TYPO3):

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

Провайдер ядра читает атрибуты рефлексией, строит топологический порядок, регистрирует в нужной последовательности. Решает главную боль implicit-хуков WordPress — непредсказуемый порядок.

Событие — только DTO (данные, без логики). Листенер вызывает Service, сам логику не несёт. Изоляция: исключение в одном листенере логируется и не прерывает цепочку остальных (изоляция сбоев).

Канонический набор событий ядра

Стабильная часть cms/core-contracts — модули на них подписываются, ядро гарантирует semver-стабильность payload:

СобытиеКогдаPayload
PageSaved / PageDeletedстраница сохранена/удаленаpage, revision
PagePublishedревизия опубликованаpage, revision
LeadCreatedзаявка принятаlead
ContentEntrySavedзапись типа контента сохраненаentry, type
CacheFlushRequestedзапрошена инвалидацияtags[]
ModuleEnabled / ModuleDisabledсмена состояния модуляmodule, from, to
WidgetSaved / WidgetDeletedвиджет сохранён/удалён (инвалидация тега области)widget, area
ThemeChangedпереключена активная темаfrom, to
MediaUploaded / MediaDeletedзагружен/удалён файлmedia / id + путь
SettingChangedизменена настройкаgroup, key, old, new
ContentEntryDeletedзапись типа контента удаленаid + slug, type
MenuSaved / MenuDeletedменю сохранено/удалено (инвалидация областей)menu / id + slug
UserRegistered / UserDeletedпользователь создан/удалён (ПДн-каскад)user
UserLoggedIn / UserLoggedOutфакт входа/выхода (ядро транслирует framework-события в свои DTO)user, ip, device
UserPasswordChangedсмена пароля (инвалидация сессий/токенов)user
UserMergedслияние аккаунтов (гость → зарегистрированный, дубли)primary_user_id, secondary_user_id
LeadStatusChangedсмена статуса заявки (синхронизация с CRM, триггеры маркетинга)lead, from, to

События MediaDeleted, ContentEntryDeleted, MenuDeleted, MenuSaved, User*, LeadStatusChanged добавлены в канон ревизией 14.07.2026 (см. сводку ревизии в ТЗ ядра): события удаления нужны purge CDN, деиндексации поиска и каскаду «забыть по запросу» — их payload облегчённый (id + slug/путь, сущности уже нет), в отличие от полных объектов *Saved; PageDeleted из исходного канона также несёт id + slug. События аутентификации — для session/audit/attack-monitor/two-factor. Дополнительно PagePublished и ContentEntrySaved при смене slug несут previous_slug (nullable) — purge старого URL и 301 без самодельного диффа.

Сквозные поля payload (аддитивно, semver-совместимо; уроки Bitrix §5 и Stripe §6):

  • origin — источник изменения (admin, api:{token_id}, module:{slug}, import:1c…). Анти-эхо для двусторонних синхронизаций: webhooks-out не доставляет подписчику события с его собственным origin (иначе цикл сайт → CRM → сайт → …). Мутации API прокидывают источник заголовком X-Cms-Origin;
  • changes — для *Saved-событий: изменённые поля со значениями «до» (аналог previous_attributes Stripe, из getChanges()/getOriginal()). Подписчик синхронизируется без хранения прошлого состояния;
  • idempotency_key исходной мутации (если была) — второй слой анти-эхо: интегратор отфильтровывает события от собственных запросов.

Фильтры (FilterBus)

События не умеют «пропустить значение по цепочке и вернуть изменённое» — а это ядро расширяемости CMS: дать модулю поменять HTML контента, пункты меню, SEO-теги, props блока. Реализуется на Illuminate\Pipeline:

php
// ядро пропускает значение через все зарегистрированные фильтры
$html = app(FilterBus::class)->apply('content.render', $html, $context);

// модуль регистрирует фильтр с приоритетом (как WordPress WP_Hook)
FilterBus::add('content.render', fn ($html, $ctx) => $html.'<div>баннер</div>', priority: 20);

Внутри — сортировка по приоритету (ksort) + прогон через Pipeline. Канонические фильтры ядра: content.render, menu.items, seo.meta, block.render.{type}, sitemap.urls, mail.recipients, security.csp (источники CSP от темы и модулей-вставщиков — ревизия №2).

Реестр точек расширения

php artisan cms:hooks печатает все события и фильтры ядра: имя, тип payload, зарегистрированные слушатели, их порядок. Превращает implicit pub/sub в документированный контракт (чего нет ни у Битрикс, ни у WP, ни у Drupal). Это же — карта для Claude Code (DX).

Запреты оси событий

  • не глобальные do_action/apply_filters-функции (анти-Laravel магия Botble);
  • не observer'ы с бизнес-логикой — событие + листенер с явным вызовом сервиса;
  • событие ради одного листенера — избыточно (services spec): прямой вызов проще дебажить.

Ось 2. Очереди

Почти вся коммуникация, интеграции, обработка медиа и импорт уходят в фон. Синхронная отправка письма/вебхука в HTTP-запросе — антипаттерн (блокирует ответ на 2–5 сек).

Именованные очереди

Разделение предотвращает блокировку быстрых задач тяжёлыми:

ОчередьЧтоПриоритет
defaultобщие задачиобычный
notificationsemail, telegram, sms, pushвысокий (быстрые)
heavyимпорт, экспорт, генерация отчётов/PDF, ресайз пачкаминизкий
integrationsвнешние API (платежи, CRM, 1С) — могут падать/тормозитьобычный, изолированно

Драйвер — Redis + Horizon (мониторинг, метрики, ретраи). Модуль объявляет, в какую очередь кладёт job.

Контракт job

Каждый job ядра/модуля ОБЯЗАН (из services spec):

  • $tries и $backoffвсегда (сколько попыток, задержка);
  • failed()всегда (обработка финального провала: лог + алерт, не тихий проглот);
  • передавать ID/модель (SerializesModels), не массивы данных (stale/bloat);
  • бизнес-логика — в Service, job только вызывает Service;
  • идемпотентность там, где возможен повтор (uniqueness по ключу).

Стык с обновлениями

Тяжёлый пайплайн обновления идёт через worker, не HTTP (центр обновлений). После апгрейда — php artisan queue:restart (воркеры держат старый код в памяти). Это часть процедуры cms:upgrade.

Планировщик (примыкает к оси очередей)

Регулярные задачи (sitemap, прогрев, синки, чистка ревизий) модуль не вешает в Schedule/routes/console.php напрямую — он декларирует их через хук ядра (registerScheduleVia(...), аналог CoreRouter для роутов). Ядро собирает единый schedule, а cms:map/телеметрия видят все cron-задачи парка списком. Контроль исполнения — spatie/laravel-schedule-monitor (провал задачи виден, а не тих).


Ось 3. Настройки (settings store)

Каждый модуль регистрирует свои настройки в единой панели. Без общего стора каждый изобретает свой — хаос в админке.

Модель (донор universal + улучшения)

Донор universal даёт проверенную модель: таблица settings с group / key / value / type / city_id, статические get()/getGroup(), typedValue() для каста (bool/int/json). Но у донора нет кеша — каждый вызов бьёт в БД. Ядро это исправляет.

Каскад резолва значения (то, чего у донора нет):

setting('mail.from')
  1. БД (cms_settings, с учётом locale/city)   ← переопределение оператором
  2. config('cms.mail.from')                    ← дефолт пакета
  3. null / объявленный default
  всё это — через кеш (тег settings), инвалидируемый на SettingChanged
php
setting('contacts.phone');                 // строка с кастом по type
setting()->group('contacts', city: $id);   // весь раздел разом (как getGroup донора)
setting()->set('contacts.phone', $v);       // пишет + бросает SettingChanged + сбрасывает кеш

Регистрация настроек модулем

Модуль декларирует свои настройки (группа, ключи, типы, UI-поле, дефолт) — ядро строит страницу настроек в Filament автоматически, модуль не верстает форму. У модуля их может быть десятки — это норма (гибкость через настройки, не через форк), но чтение всегда идёт через кеш группы, а не в БД по ключу; настройка декларирует affectsPageCache, чтобы её правка сбрасывала ровно те страницы, на которые влияет (производительность):

php
SettingSchema::group('catalog')
    ->boolean('show_prices', default: true)->label('Показывать цены')
    ->int('per_page', default: 24)
    ->json('filters');

Секреты — не в settings-store

API-ключи, токены платёжек — .envconfig(), никогда в cms_settings (это БД, попадёт в дампы/бэкапы/экспорт сайта). Разделение: cms_settings — операторские настройки контента; секреты — окружение. Запрет env() в коде (ломается при config:cache) — только config().

Локаль/город как измерение

Поле locale/city_id в cms_settings — заготовка (Q6): ядро умеет хранить настройку per-город/локаль, но активирует измерение модуль мультигорода/ мультиязычности. По умолчанию null = глобальная настройка.


Почему оси — в фазе 0, до кода

Все три пронизывают каждый модуль. Ретрофит любой из них позже = переписать интеграцию каждого модуля с ядром:

  • сменить сигнатуру события → сломать всех слушателей;
  • добавить очередь задним числом → переразметить все job;
  • сменить API настроек → переписать все регистрации.

Поэтому оси фиксируются в cms/core-contracts первыми, вместе с моделью данных и контрактом блока — до первой строки реализации ядра.

Чеклист фазы 0 (три оси)

  • [ ] CmsListener-атрибуты + топологический порядок слушателей.
  • [ ] Канонический набор событий ядра в cms/core-contracts (semver-стабильный payload).
  • [ ] Сквозные поля payload: origin (анти-эхо), changes (diff «до»), idempotency_key.
  • [ ] FilterBus на Pipeline + канонические фильтры + приоритеты.
  • [ ] cms:hooks — реестр точек расширения (событий и фильтров).
  • [ ] Именованные очереди (default/notifications/heavy/integrations) + контракт job.
  • [ ] Регистрация cron-задач модулем через хук ядра (не Schedule напрямую).
  • [ ] Settings-store: каскад БД → config → default (env только через config), кеш с тегом, SettingChanged.
  • [ ] SettingSchema — декларативная регистрация настроек модулем (+ affectsPageCache).
  • [ ] Прогрев групп настроек на деплое/cms:postupgrade; чтение мимо setting() запрещено.
  • [ ] Секреты вне cms_settings; запрет env() в коде.
  • [ ] Изоляция: сбой слушателя/job не роняет цепочку/ядро.

Связи

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