Тема
Фаза 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_attributesStripe, из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 | общие задачи | обычный |
notifications | email, 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), инвалидируемый на SettingChangedphp
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-ключи, токены платёжек — .env → config(), никогда в 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 не роняет цепочку/ядро.
Связи
- Расширяемость — три недостающих примитива.
- Контракт модуля — как модуль пользуется осями.
- Модель данных —
cms_settings, полиморфные точки. - Центр обновлений — queue:restart, worker-пайплайн.
- services / jobs spec — контракт job, когда событие оправдано.