Files
XC_VM/docs/ru/development/modules.md
T
Divarion_D 2466ddfc3e chore(tools): remove orphaned gen-module-hashes.php
The script was never wired up (no `make module-hashes` target, no CI/Makefile
caller). Module hash_id generation now lives in the standalone Module_Template
kit; for existing modules, generate inline with
`php -r 'echo bin2hex(random_bytes(16));'`.

Updated the module-dev docs (en + ru) that referenced the removed script /
non-existent `make module-hashes` target to use the one-liner.
2026-08-07 20:49:36 +03:00

41 KiB
Raw Blame History

Система модулей

Обзор

Модуль — изолированная директория в src/Modules/ с известным контрактом. Удаление модуля не ломает систему — она продолжает работать, деградируя в функциональности.

Система построена на принципах Extensible Platform:

  • Ядро (Core/) ничего не знает о модулях
  • Модули расширяют ядро через интерфейсы-контракты
  • Никакой правки файлов ядра, никакого eval, никакого monkey patching
  • Любой модуль отключается через config/modules.php без последствий для ядра

Структура директории модуля

Имя директории — по конвенции {name}_{hash5}, где hash5 — первые 5 символов hash_id. Логическое имя (module.json name, в нём никогда нет _) всегда берётся из манифеста, а не из имени папки. Благодаря этому два модуля с одинаковым именем живут в разных папках (watch_2541a, watch_9f1c0) и ставятся без конфликта на ФС. Конфиг, граф зависимостей и namespace ключуются по каноничному name, поэтому переименование папки не требует миграции данных. У модуля обязан быть hash_id: если загружаемый модуль пришёл без него, id генерируется и записывается в module.json до размещения — папка без хеша не создаётся никогда. Голая папка Modules/{name}/ из старого развёртывания ещё читается, но автоматически мигрирует в {name}_{hash5} (с генерацией hash_id, если его нет) при следующем console.php status — старый формат не сохраняется, а вытесняется.

modules/
└── my-module_9f1c0/               # {name}_{hash5}; каноничное имя — "my-module"
    ├── module.json                # Метаданные + поля загрузки
    ├── MyModule.php               # Главный класс (implements ModuleInterface)
    ├── MyService.php              # Сервисы модуля
    ├── MyController.php           # Контроллер (если есть страницы)
    ├── MyCron.php                 # Крон-логика
    ├── MyCronJob.php              # CLI-обёртка (implements CommandInterface)
    ├── MyStreamMiddleware.php     # Stream-middleware (опционально)
    ├── database.sql               # Мастер-схема — полный текущий CREATE/seed (опц.)
    ├── database_drop.sql          # Удаление — DROP всех таблиц модуля (опц.)
    ├── migrations/                # Дельты между версиями (опц.)
    │   └── 1.1.0.sql              # Применяется только при апгрейде выше 1.1.0
    └── views/
        ├── my_page.php
        └── my_page_scripts.php

Модуль владеет своей схемой через три роли — зеркало ядра (bin/install/database.sql

  • migrations/):
Файл Роль Когда выполняется
database.sql Одна мастер-схема — полный текущий CREATE/seed свежая установка
database_drop.sql Один файл удаления — DROP TABLE всех таблиц модуля удаление
migrations/<semver>.sql Папка форвардных дельт между версиями обновление, для версий в (installed, current]

Правила:

  • Свежая установка выполняет только database.sql, поэтому он всегда должен отражать ПОСЛЕДНЮЮ схему (все дельты уже влиты). Watermark installed_version гарантирует, что дельты не проигрываются повторно на свежей установке.
  • Дельты только форвардные (ALTER/INSERT), имя <semver>.sql — удаление одно (database_drop.sql), поэтому пофайловых .down больше нет.
  • Держите дельты идемпотентными (ADD COLUMN IF NOT EXISTS, INSERT IGNORE).
  • Модуль без схемы не поставляет эти файлы. Модуль только с дельтами (без database.sql) всё равно установится, проиграв все дельты ≤ своей версии.

Манифест module.json

{
    "name": "my-module",
    "hash_id": "9f1c0b7e4d2a6538c1e0a4b7d6f39e21",
    "description": "Краткое описание модуля",
    "version": "1.0.0",
    "requires_core": ">=2.0",
    "environment": "main",
    "dependencies": [],
    "optional_dependencies": [],
    "has_navbar": false,
    "has_settings": false,
    "priority": 0
}

Поля манифеста

Поле Тип По умолчанию Описание
name string — Каноничное имя (kebab-case, без _). Директория — {name}_{hash5}, но код всегда ключуется по этому значению манифеста, а не по имени папки.
hash_id string генерируется Постоянная идентичность модуля — случайный 32-hex, генерируется ОДИН раз и не меняется при смене версии/переименовании. Первые 5 символов образуют суффикс папки {name}_{hash5}. Руками не писать.
description string "" Краткое человекочитаемое описание
version string — Semver-версия (1.0.0)
requires_core string — Минимальная версия ядра (>=2.0)
environment string "main" main — основной сервер, lb — load-balancer, any — оба
dependencies array [] Обязательные зависимости: при недоступности зависимый модуль пропускается (см. ниже)
optional_dependencies array [] Мягкие зависимости: при отсутствии модуль загружается без них
has_navbar bool false Есть ли пункты навбара
has_settings bool false Есть ли страница настроек
priority int 0 Приоритет загрузки: выше значение — раньше загрузится (при топологически равном положении)

hash_id — постоянная идентичность модуля. Случайный 32-hex, генерируется один раз и никогда не меняется — переживает смену версии и переименование (потому случайный, не производный от name/version). Сгенерировать: php -r 'echo bin2hex(random_bytes(16));' и вставить в module.json при создании модуля. Руками не писать и не переиспользовать чужой. Даёт стабильную идентичность независимо от name — основа для выноса модулей в отдельные репозитории и явного источника обновлений — блок update в манифесте (ниже).

Разница между dependencies и optional_dependencies

{
    "dependencies": ["tmdb"],
    "optional_dependencies": ["plex"]
}
  • dependencies: модуль tmdb обязан быть загружен до my-module. Если tmdb недоступен (отсутствует на диске, отключён или в состоянии failed), то my-module пропускается с предупреждением в лог — каскадно (всё, что зависит от my-module, тоже пропустится). Загрузка остальных модулей и работа панели/CLI при этом не прерывается (см. «Как работает загрузка»).
  • optional_dependencies: если plex присутствует — он загрузится до my-module. Если отсутствует — загрузка продолжается без него.

Защита от рассинхрона. Отключить (disabled) модуль, от которого зависят включённые модули, через панель/ModuleManager::setState() нельзя — операция будет отклонена с пояснением, какие модули его требуют (по аналогии с запретом удаления uninstallModule()). Это не даёт создать состояние «plex включён, а его зависимость watch выключена».

Приоритет загрузки

При топологически равных позициях (нет зависимости друг от друга), модули с бо́льшим priority загружаются и бутятся первыми.

{ "name": "auth-guard", "priority": 100 }   ← загрузится первым
{ "name": "tmdb",       "priority": 50  }   ← второй
{ "name": "watch",      "priority": 0   }   ← третий (по умолчанию)

При равных priority — алфавитный порядок (детерминированность).

Источник обновлений (блок update, опционально)

Откуда модуль берёт обновления. Отсутствует → bundled (файлы приходят с панелью и обновляются вместе с ней).

"update": {
    "source": "bundled | platform | git | url",
    "repository": "https://github.com/Vateron-Media/xc_vm-module-watch",
    "channel": "stable",
    "slug": "watch",
    "url": "https://…/version.json"
}
  • source — bundled (с панелью), platform (SaaS-стор), git (релизы репо), url (самохостинг). Неизвестное значение → bundled.
  • repository — git-remote (для git); slug — slug в сторе (для platform, по умолчанию = name); url — URL версии/архива (для url); channel — stable/beta (по умолчанию stable).

Блок нормализуется в ModuleLoader и доступен через ModuleManager::listModules(). Еженедельный крон (cron:module_updates) проверяет источники git/url и записывает available_version — по нему показывается кнопка Update to X. Клик по Update вызывает ModuleManager::updateModuleFromSource():

  • bundled — файлы приходят с панелью; Update просто гоняет ожидающие миграции.
  • platform — делегируется в стор-флоу (внутри откат + рассылка на LB).
  • git — качает релиз-ассет module.tar.gz тега == новой версии (md5-верификация через hashes.md5 релиза, если есть).
  • url — перечитывает version.json за download (https) + опц. md5.

Для git/url hash_id из скачанного module.json должен совпасть с установленным (identity pinning — репо/URL не подменит чужой модуль), затем: бэкап → замена файлов → миграции → откат при любой ошибке → рассылка на LB.

Стандартный набор и провизия. Модули, которые панель ставит по умолчанию, перечислены в config/bundled_modules.php, ключ — hash_id (стабилен при переименовании). Сейчас все bundled (файлы в архиве панели). Когда модуль вынесут в отдельный репозиторий — переключаешь его запись на источник git/url/platform, и syncBundledModules() автоматически скачает + установит его через provisionStandardSet() (пока всё bundled на диске — no-op). ModuleManager::findModuleByHashId() находит модуль по стабильному id независимо от директории/имени.


Интерфейсы модуля

ModuleInterface — составной интерфейс, объединяющий 4 суб-интерфейса:

ModuleInterface
├── ServiceProviderInterface    boot(), getEventSubscribers()
├── RouteProviderInterface      registerRoutes()
├── CommandProviderInterface    registerCommands()
└── NavbarProviderInterface     registerNavbar()

+ getName(), getVersion(), install(), uninstall()

Пятый суб-интерфейс — опциональный, не входит в ModuleInterface:

StreamMiddlewareProviderInterface   getStreamMiddleware()

Модуль реализует его дополнительно, если хочет участвовать в стрим-pipeline.


Класс модуля

Файл src/Modules/my-module/MyModule.php.

Расширяйте BaseModule — он предоставляет пустые реализации по умолчанию для всех необязательных методов. Обязательны только getName() и getVersion().

<?php
namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;
use Router;
use CommandRegistry;

class MyModuleModule extends BaseModule {

    public function getName(): string {
        return 'my-module';
    }

    public function getVersion(): string {
        return '1.0.0';
    }

    public function boot(ServiceContainer $container): void {
        $container->set('my-module.service', function (ServiceContainer $c) {
            return new MyModuleService($c->get('db'));
        });
    }

    public function getEventSubscribers(): array {
        return [
            StreamStartedEvent::class => [MyModuleHandler::class, 'onStreamStarted'],
            // С приоритетом: [callable, int]
            UserAuthenticatedEvent::class => [[MyModuleHandler::class, 'onAuth'], 20],
        ];
    }

    public function registerRoutes(Router $router): void {
        $router->get('my-module', [MyModuleController::class, 'index'], [
            'permission' => ['adv', 'my_module'],
        ]);
        $router->api('my_action', [MyModuleController::class, 'apiAction'], [
            'permission' => ['adv', 'my_module'],
        ]);
    }

    public function registerCommands(CommandRegistry $registry): void {
        $registry->register(new MyModuleCronJob());
    }

    public function registerNavbar(): void {
        NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
            ->parent('management.service_setup')
            ->url('my_module')
            ->label('my_module')
            ->permissions(['my_module'])
            ->order(60));
    }

    // override install()/uninstall() only if migrations or cleanup are needed
}

Контракт методов

Метод Интерфейс Описание
getName(): string ModuleInterface Уникальное имя (совпадает с директорией)
getVersion(): string ModuleInterface Semver-версия
install(): void ModuleInterface Вызывается при установке из Marketplace
uninstall(): void ModuleInterface Вызывается при удалении
boot(ServiceContainer) ServiceProviderInterface Регистрация сервисов в DI-контейнере
getEventSubscribers(): array ServiceProviderInterface Подписки на типизированные события PSR-14
registerRoutes(Router) RouteProviderInterface HTTP-маршруты и API-экшены
registerCommands(CommandRegistry) CommandProviderInterface Явная регистрация CLI-команд и крон-задач
registerNavbar(): void NavbarProviderInterface Пункты меню в admin navbar

Важно — версия задаётся в двух местах. Модуль объявляет свою версию дважды: поле "version" в module.json и возвращаемое значение getVersion() в классе модуля. Держите их одинаковыми и повышайте обе перед публикацией. В рантайме приоритет у версии из манифеста — установка/обновление и watermark installed_version сначала читают module.json и лишь потом откатываются к getVersion(), поэтому устаревший getVersion() тихо рассинхронизируется и становится частой причиной багов «не та миграция выполнилась / не выполнилась». Если модуль поставляет файловую схему, database.sql (мастер) и старшая дельта migrations/<semver>.sql тоже должны совпадать с этой версией.


PHP-пространства имён

Каждый модуль живёт в своём PHP-пространстве имён: XcVm\Module\{Pascal}, где {Pascal} — PascalCase-вариант имени директории модуля.

src/Modules/my-module/   →  namespace XcVm\Module\MyModule;
src/Modules/watch/       →  namespace XcVm\Module\Watch;

Главный файл модуля обязан объявлять это пространство имён и расширять BaseModule:

<?php
namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;

class MyModuleModule extends BaseModule {
    // ...
}

Все вспомогательные классы в том же модуле разделяют одно пространство имён:

<?php
namespace XcVm\Module\MyModule;

class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }

Для каждого используемого класса ядра добавляйте use:

namespace XcVm\Module\MyModule;

use BaseModule;
use ServiceContainer;
use NavbarRegistry;
use NavbarItem;

Правила:

  • Имя файла главного класса: <PascalName>Module.php — обязательно (соглашение ModuleLoader)
  • Имена остальных файлов: <PascalName><Purpose>.php
  • Добавляйте use для каждого класса ядра, на который есть ссылка
  • Никогда не импортируйте классы из других модулей — общайтесь через события или DI-контейнер

DI-контейнер и декорирование сервисов

Регистрация сервисов

public function boot(ServiceContainer $container): void {
    // Ленивая фабрика (singleton)
    $container->set('my-module.service', function (ServiceContainer $c) {
        return new MyService($c->get('db'), $c->get('settings'));
    });

    // Фабричный сервис (новый экземпляр при каждом get)
    $container->factory('my-module.request', function (ServiceContainer $c) {
        return new MyRequest($_GET, $_POST);
    });
}

Декорирование чужих сервисов

Модуль может обернуть любой незащищённый сервис декоратором без правки его кода:

public function boot(ServiceContainer $container): void {
    $container->decorate(
        'stream.service',
        MyLoggingDecorator::class,  // class-string: new Decorator($inner)
        priority: 20
    );

    // Или callable-форма
    $container->decorate('stream.service', function ($inner, ServiceContainer $c) {
        return new MyLoggingDecorator($inner, $c->get('logger'));
    }, priority: 20);
}

Защищённые сервисы — декорировать нельзя: db, settings, config, auth.

Попытка задекорировать защищённый сервис выбросит RuntimeException.

Порядок применения декораторов: наибольший priority = самый внешний слой (вызывается первым).


PSR-14 События

Система событий — типизированные классы, а не строки.

Подписка на события

В getEventSubscribers() возвращайте карту EventClass::class → callable:

public function getEventSubscribers(): array {
    return [
        // Простой callable
        StreamStartedEvent::class  => [MyHandler::class, 'onStreamStarted'],
        StreamStoppedEvent::class  => [MyHandler::class, 'onStreamStopped'],

        // С приоритетом: [callable, int] — больше приоритет = вызывается раньше
        UserAuthenticatedEvent::class => [
            [MyHandler::class, 'onAuth'],
            50
        ],

        // Замыкание
        SettingsChangedEvent::class => function (SettingsChangedEvent $e): void {
            if (in_array('my_setting', $e->changedKeys())) {
                MyCache::flush();
            }
        },
    ];
}

Диспетчеризация событий из модуля

use EventDispatcher;

EventDispatcher::dispatch(new PackageInstalledEvent(
    slug:        'my-module',
    version:     '1.0.0',
    path:        '/path/to/module',
    installedAt: time(),
));

Прерываемые события (StoppableEventInterface)

Если слушатель вызвал $event->stopPropagation(), остальные слушатели не вызываются.

EventDispatcher::listen(StreamStartingEvent::class, function (StreamStartingEvent $e): void {
    if ($this->isBlocked($e)) {
        $e->abort('blocked by my-module');  // специфичен для StreamStartingEvent
        $e->stopPropagation();
    }
});

Встроенные события ядра

Класс события Когда диспетчеризуется Прерываемое
ModuleLoadedEvent После успешной загрузки файла модуля ❌
ModuleBootedEvent После вызова boot() у модуля ❌
PackageInstalledEvent После установки через Marketplace ❌
UserAuthenticatedEvent Успешная аутентификация ❌
UserLoggedOutEvent Выход пользователя ❌
StreamStartingEvent Перед запуском стрима ✅
StreamStartedEvent Стрим успешно запущен ❌
StreamStoppedEvent Стрим остановлен ❌
SettingsChangedEvent Изменение настроек панели ❌

Все типизированные события находятся в src/Core/Events/.


Stream Middleware (опционально)

Если модуль хочет участвовать в обработке стрим-запросов, он реализует StreamMiddlewareProviderInterface (не входит в ModuleInterface):

class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {

    // ... обязательные методы ModuleInterface ...

    public function getStreamMiddleware(): array {
        return [
            new MyAuthMiddleware(),
            new MyTheftDetectionMiddleware(),
        ];
    }
}

Реализация middleware

class MyTheftDetectionMiddleware implements StreamMiddlewareInterface {

    public function handle(StreamContext $ctx, callable $next): StreamContext {
        if ($this->isTheft($ctx)) {
            $ctx->abort('theft detected', 403);
            return $ctx;  // pipeline останавливается
        }

        // Сохранить данные в context
        $ctx->set('my-module.fingerprint', $this->getFingerprint($ctx));

        return $next($ctx);  // передать управление следующему
    }

    public function getPriority(): int {
        return 60;  // core: 80-100, modules: 0-79, terminal: -1
    }
}

Приоритеты в pipeline

Диапазон Кому принадлежит
80–100 Ядро (Auth, Permission, ConnectionLimit)
0–79 Модули
-1 Terminal middleware (финальное выполнение стрима)

StreamContext

// Прочитать параметры запроса
$streamId = $ctx->get('stream_id');
$userId   = $ctx->get('user_id');

// Записать произвольный атрибут (передаётся по цепочке middleware)
$ctx->set('my-module.checked', true);

// Прервать выполнение
$ctx->abort('reason', 403);
if ($ctx->isAborted()) {
    return $ctx;
}

Navbar

Добавление пунктов меню

Метод registerNavbar() вызывается один раз при boot. Используйте NavbarRegistry::add():

public function registerNavbar(): void {
    // Пункт в Service Setup
    NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
        ->parent('management.service_setup')
        ->url('my_module')
        ->label('my_module')
        ->permissions(['my_module'])
        ->order(60));

    // Пункт в Logs (megamenu)
    NavbarRegistry::add((new NavbarItem('management.logs.my_module_log'))
        ->parent('management.logs')
        ->url('my_module_logs')
        ->label('', 'My Module Logs')
        ->permissions(['my_module'])
        ->order(170));
}

Зарезервированные слоты для модулей

Родительский узел Слоты для модулей
management.service_setup order ≥ 60
management.logs order ≥ 170
Прочие секции Не зарезервировано, уточняйте с core

Правила

  1. key — уникальный, стабильный, формат section.group.item
  2. parent — должен ссылаться на существующий узел core-дерева
  3. order — позиция внутри одного parent (меньше = выше в списке)
  4. label('key') — переводимый текст, label('', 'Literal') — фиксированный
  5. permissions(['perm']) — видимость по разрешению (OR-логика)
  6. Если нет пунктов меню — оставьте registerNavbar() пустым

Отключение и включение модулей

Добавьте в src/config/modules.php:

return [
    'my-module' => ['state' => 'disabled'],   // предпочтительно
    // или legacy-форма (обратная совместимость):
    'my-module' => ['enabled' => false],
];

Допустимые значения state (enum ModuleState):

Значение Смысл
enabled Модуль загружается и стартует (по умолчанию)
disabled Обнаружен, но пропускается
installing Переходное состояние при установке
failed Установка завершилась ошибкой; пропускается (не загружается)

Файл содержит только overrides. Если пустой или отсутствует — все найденные модули загружаются.

Диагностика в панели. На странице Modules рядом со статусом модуля показывается жёлтый бейдж ⚠ Dependency issue, если у модуля есть обязательная зависимость, которая отсутствует или не включена (например, plex числится Enabled, но watch в состоянии failed). В подсказке бейджа перечислены конкретные проблемы. Это поле (dependency_warnings) вычисляет ModuleManager::listModules().

Можно также переопределить класс модуля:

return [
    'my-module' => ['class' => 'XcVm\\Module\\MyModuleCustom\\MyModuleCustomModule'],
];

Как работает загрузка

ModuleLoader::loadAll()
    │
    ├── glob('Modules/*/module.json')
    ├── читает overrides из config/modules.php
    ├── фильтрует по environment (main/lb/any)
    ├── readManifest() → normalizes: dependencies, optional_dependencies, priority
    │
    ├── resolveLoadOrder()  — топологическая сортировка DFS
    │   ├── pruneUnsatisfiableModules() → модули с недоступной обязательной
    │   │     зависимостью отбрасываются (каскадно, с предупреждением в лог)
    │   ├── optional deps → пропускается если отсутствует
    │   └── при равной позиции: sort по priority desc, затем alphabetically
    │
    └── для каждого модуля в порядке:
        ├── registerModuleAutoloader($path)
        ├── resolveClassName('my-module') → 'MyModule'
        └── new MyModule()

ModuleLoader::bootAll($container, $router, $pipeline)
    ├── (new CoreNavbarProvider())->registerNavbar()   ← core navbar первым
    │
    └── для каждого модуля:
        ├── instanceof ServiceProviderInterface → boot($container)
        │                                       → registerEventSubscribers()
        ├── instanceof StreamMiddlewareProviderInterface → pipeline->pipe(middleware)
        ├── instanceof RouteProviderInterface → registerRoutes($router)
        └── instanceof NavbarProviderInterface → registerNavbar()

Соглашение по имени класса: my-module → FQN XcVm\Module\MyModule\MyModuleModule (kebab-case → PascalCase; можно переопределить через ключ class в конфиге).

Переопределить класс можно через config/modules.php:

return [
    'my-module' => ['class' => 'MyModuleV2'],
];

Marketplace: установка через C-расширение

Модули из платформы устанавливаются через ModuleManager::downloadFromPlatform():

$manager->downloadFromPlatform(slug: 'my-module', version: '1.2.0', apiKey: $key);

Под капотом:

  1. XC_VM::module_install($slug, $version, $apiKey) — C-расширение скачивает, дешифрует и распаковывает модуль
  2. installModule($slug) — запускает install() у модуля
  3. EventDispatcher::dispatch(new PackageInstalledEvent(...)) — диспетчеризует событие
  4. hotReload($slug, $path) — загружает и бутит модуль в текущем запросе без рестарта PHP-FPM

Изолированные подсистемы (BoundaryInterface)

Если модуль является изолированной подсистемой с собственным bootstrap (как Ministra), он реализует BoundaryInterface:

class MyModule extends BaseModule implements BoundaryInterface {

    public function getName(): string { return 'my-module'; }
    public function getVersion(): string { return '1.0.0'; }

    public function getEntryPoint(): string {
        return 'my-module/portal.php';
    }

    public function isIsolated(): bool {
        return true;
    }
}

BoundaryInterface — маркер изоляции. isIsolated() = true означает, что подсистема запускается через собственный entry point с отдельным bootstrap.


Контроллер (опционально)

class MyController {

    private string $viewsPath;

    public function __construct() {
        $this->viewsPath = __DIR__ . '/views';
        require_once MAIN_HOME . 'Public/Views/layouts/admin.php';
        require_once MAIN_HOME . 'Public/Views/layouts/footer.php';
    }

    public function index(): void {
        $_TITLE = 'My Module';
		renderUnifiedLayoutHeader('admin', ['_TITLE' => $_TITLE]);
        include $this->viewsPath . '/my_page.php';
        renderUnifiedLayoutFooter('admin');
        include $this->viewsPath . '/my_page_scripts.php';
    }

    public function apiAction(): void {
        echo json_encode(['result' => true]);
        exit;
    }
}
Правило
__DIR__ . '/views' viewsPath — контроллер внутри директории модуля
GET-страницы renderUnifiedLayoutHeader до view, renderUnifiedLayoutFooter после
API-экшены Без layout — JSON напрямую

Крон-задача (опционально)

// src/Modules/my-module/MyCronJob.php

class MyCronJob implements CommandInterface {
    use CronTrait;

    public function getName(): string    { return 'cron:my_task'; }
    public function getDescription(): string { return 'My module background task'; }

    public function execute(array $rArgs): int {
        if (!$this->assertRunAsXcVm()) { return 1; }

        require INCLUDES_PATH . 'admin.php';
        require_once __DIR__ . '/MyCron.php';

        $this->initCron('XC_VM[MyTask]');
        MyCron::run();

        return 0;
    }
}

Регистрация в модуле:

public function registerCommands(CommandRegistry $registry): void {
    $registry->register(new MyCronJob());
}

Объявить расписание через getCronEntries() в классе модуля:

public function getCronEntries(): array {
    return [
        '*/5 * * * *' => 'cron:my_task',
    ];
}

ModuleLoader::collectCronEntries() агрегирует записи всех модулей, StartupCommand / StatusCommand автоматически записывают их в системный crontab — изменять файлы ядра не нужно.

Формат: ключ = cron-выражение, значение = имя команды из registerCommands().


PSR-11: ContainerInterface

ServiceContainer реализует ContainerInterface:

public function get(string $id): mixed;  // throws NotFoundException если не найден
public function has(string $id): bool;

NotFoundException реализует NotFoundExceptionInterface → ContainerExceptionInterface.

Интерфейсы находятся в src/Core/Container/Psr/. Composer не используется — файлы включены в проект напрямую.


Чеклист добавления модуля

  • mkdir -p src/Modules/<name>/
  • Создать module.json (name, version, requires_core, priority, optional_dependencies)
  • Проставить постоянный hash_id (php -r 'echo bin2hex(random_bytes(16));'; руками не писать)
  • Создать <Name>Module.php (extends BaseModule)
  • Задать версию в обоих местах — "version" в module.json и getVersion() — они должны совпадать (повышать обе перед публикацией)
  • boot() — зарегистрировать сервисы через $container->set()
  • getEventSubscribers() — подписки на типизированные события
  • registerRoutes() — маршруты (или пустой метод)
  • registerNavbar() — пункты меню (или пустой метод)
  • registerCommands() — крон-задачи (или пустой метод)
  • (опц.) implements StreamMiddlewareProviderInterface + getStreamMiddleware()
  • (опц.) Контроллер + views/
  • (опц.) CronJob + регистрация в StartupCommand
  • (если своя схема) database.sql (мастер), database_drop.sql (удаление), migrations/<semver>.sql (дельты)
  • (если миграции с PHP-логикой) implements MigratableInterface + getMigrations()
  • Проверить: php -l src/Modules/<name>/<Name>Module.php
  • Проверить: php console.php --list показывает команды модуля
  • Проверить: удаление директории не вызывает ошибок ядра

FAQ

Q: Как отключить модуль? A: src/config/modules.php → 'my-module' => ['state' => 'disabled']. Legacy-форма 'enabled' => false тоже принимается для обратной совместимости.

Q: Нужна ли регистрация в конфиге для загрузки? A: Нет. ModuleLoader сам находит все модули по Modules/*/module.json.

Q: Мой модуль зависит от другого. Как объявить? A: В module.json через dependencies (обязательно) или optional_dependencies (мягко). Предпочитайте выносить общую логику в Core/ вместо межмодульных зависимостей.

Q: Как задекорировать сервис другого модуля? A: $container->decorate('service.id', MyDecorator::class, priority: 10) в своём boot().

Q: Как подписаться на событие с приоритетом? A: [EventClass::class => [[MyHandler::class, 'method'], 50]] в getEventSubscribers(). Можно также вызвать EventDispatcher::listen() напрямую.

Q: Как модуль получает $db? A: $db = $container->get('db') в boot(). Прямой global $db — устарело.

Q: Как модуль получает настройки? A: $settings = $container->get('settings') или SettingsManager::getAll()['key'].

Q: Почему мой middleware не вызывается? A: Проверьте, что модуль реализует StreamMiddlewareProviderInterface (не ModuleInterface — это разные контракты). bootAll() должен быть вызван с $pipeline аргументом.

Q: Мой модуль MAIN-only — что делать? A: Ничего. Все модули MAIN-only по умолчанию — modules/ не входит в LB_DIRS. Для LB используйте "environment": "lb" или "any".

Связанные файлы

Файл Роль
src/Core/Module/ModuleLoader.php Обнаружение, сортировка и загрузка модулей; PSR-4-резолвер
src/config/modules.php Конфиг включения / переопределения класса модуля
src/Modules/ Каталоги модулей
src/Core/Module/Contract/ Под-интерфейсы модулей