Structure:
- Split development/ (20 files) into development/ (framework reference) and guides/ (how-to)
- Moved 13 how-to files into new guides/ category (auth, cli, error-handling, etc.)
- Moved navbar-rendering.md from system/ into development/
- Deleted empty system/README.md files
- Deleted documentation_gaps.md (all gaps resolved) and redundant info/update.md
Sidebar / navbar:
- Removed all emojis from _sidebar.md (EN + RU) — fixes tree hierarchy rendering
- Removed flag emojis from _navbar.md language switcher
- Removed duplicate entries from the old "System Documentation" section
- Renamed section headers: "Development" -> "Framework Reference" + "Developer Guides"
Content fixes (modules.md EN + RU):
- Updated "Class naming" section: now documents XcVm\Module\{Pascal} namespaces
(was "global PHP namespace — Until PHP namespaces are introduced")
- Updated main module class example to use namespace declaration + use statements
- Updated class naming in ModuleLoader description to FQN
- Replaced installCrontab() manual crontab instruction with getCronEntries() API
- Updated enable/disable section: added ModuleState enum table, updated config examples
- Fixed FAQ answer for disabling a module
Content fixes (bootstrap-contexts.md EN + RU):
- Replaced CONTEXT_* string constants with BootContext enum cases throughout
- Updated boot() signature: string $context -> BootContext $context
- Updated getContext() return type: ?string -> ?BootContext
New framework reference docs (EN + RU):
- docs/{en,ru}/development/event-system.md: EventDispatcher singleton bridge,
#[ListensTo] attribute, getEventSubscribers() array API, stoppable events,
built-in event catalog, custom event authoring, ListensTo attribute reference
- docs/{en,ru}/development/exception-hierarchy.md: full XcVmException tree,
container vs module subtrees, PSR-11 compliance notes, catch-by-subsystem examples
20 KiB
Module System
Overview
A module is an isolated directory under src/modules/ with a known contract. The system
is built on Extensible Platform principles:
- Core (
core/) has no knowledge of modules - Modules may depend on
core/anddomain/, never on each other (except via declared dependencies) - Any module can be disabled from
config/modules.phpwithout touching core - Removing a module directory causes no fatal errors
Module directory structure
src/modules/my-module/
├── module.json # Metadata and manifest
├── MyModule.php # Module class (source of truth)
├── MyService.php # Business logic
├── MyController.php # Admin pages (optional)
├── MyCron.php # Cron logic (optional)
├── MyCronJob.php # CLI cron wrapper (optional)
└── views/ # Page templates (optional)
├── my_page.php
└── my_page_scripts.php
module.json
{
"name": "my-module",
"description": "Short description",
"version": "1.0.0",
"requires_core": ">=2.0",
"environment": "main",
"priority": 0,
"dependencies": [],
"optional_dependencies": [],
"has_navbar": false,
"has_settings": false
}
Manifest fields
| Field | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Unique module name (matches directory) |
description |
string |
"" |
Human-readable description |
version |
string |
— | Semver version (1.0.0) |
requires_core |
string |
— | Minimum core version (>=2.0) |
environment |
string |
"main" |
main, lb, or any |
priority |
int |
0 |
Load priority — higher loads earlier |
dependencies |
array |
[] |
Hard dependencies (must exist and be enabled) |
optional_dependencies |
array |
[] |
Soft dependencies (loaded before if present) |
has_navbar |
bool |
false |
Whether the module registers navbar items |
has_settings |
bool |
false |
Whether the module has a settings page |
Hard vs soft dependencies:
dependencies— if any is missing or disabled,ModuleLoaderthrowsRuntimeExceptionoptional_dependencies— loaded before this module if present, silently skipped if absent
Priority:
- Topological sort respects the dependency graph first, then within the same group sorts by
prioritydescending (higher number = loaded earlier), then alphabetically
Sub-interfaces
ModuleInterface splits the module's surface area into typed sub-contracts:
ModuleInterface
├── ServiceProviderInterface → boot(ServiceContainer)
├── RouteProviderInterface → registerRoutes(Router)
├── CommandProviderInterface → registerCommands(CommandRegistry)
└── NavbarProviderInterface → registerNavbar()
StreamMiddlewareProviderInterface is optional — it is NOT part of ModuleInterface.
Implement it only if the module needs to inject itself into the stream pipeline.
// Optional — not in ModuleInterface
class MyModule implements ModuleInterface, StreamMiddlewareProviderInterface {
public function getStreamMiddleware(): array {
return [new MyStreamMiddleware()];
}
}
Module class
Extend BaseModule — it provides no-op defaults for every optional method so you only
override what the module actually uses. Only getName() and getVersion() are required.
<?php
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use Router;
use CommandRegistry;
use NavbarRegistry;
use NavbarItem;
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): MyModuleService {
return new MyModuleService($c->get('db'));
});
}
public function registerRoutes(Router $router): void {
$router->get('my_page', [MyModuleController::class, 'index'], [
'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_page')
->label('my_module')
->permissions(['my_module'])
->order(60)
);
}
}
Tip: a module with no routes, no navbar items, and no CLI commands only needs
getName(),getVersion(), andboot(). ABoundaryInterfacemodule (isolated subsystem with its own entry point) typically leavesboot()andregisterRoutes()inherited as no-ops.
Method contract
| Method | Interface | Description |
|---|---|---|
getName(): string |
ModuleInterface |
Unique name (matches directory) |
getVersion(): string |
ModuleInterface |
Semver version |
boot(ServiceContainer) |
ServiceProviderInterface |
Register services in DI container |
registerRoutes(Router) |
RouteProviderInterface |
Register HTTP and API routes |
registerCommands(CommandRegistry) |
CommandProviderInterface |
Register CLI commands and cron tasks |
registerNavbar() |
NavbarProviderInterface |
Register navbar items |
install(): void |
ModuleInterface |
Run on module install (migrations, seed) |
uninstall(): void |
ModuleInterface |
Run on module remove (cleanup) |
PHP namespaces
Every module lives in a dedicated PHP namespace: XcVm\Module\{Pascal}, where {Pascal} is
the PascalCase conversion of the module directory name.
src/modules/my-module/ → namespace XcVm\Module\MyModule;
src/modules/watch/ → namespace XcVm\Module\Watch;
The main module file must declare this namespace and extend BaseModule:
<?php
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use Router;
class MyModuleModule extends BaseModule {
// ...
}
All secondary classes in the same module share the same namespace:
<?php
namespace XcVm\Module\MyModule;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
use the classes you reference:
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use NavbarRegistry;
use NavbarItem;
class MyModuleModule extends BaseModule {
public function boot(ServiceContainer $container): void {
$container->set('my-module.service', fn () => new MyModuleService());
}
}
Rules:
- Main module class filename:
<PascalName>Module.php— required (ModuleLoader convention) - All other class filenames:
<PascalName><Purpose>.php - Add
use ClassName;for every core class referenced (BaseModule, ServiceContainer, Router, etc.) - Never import classes from other modules — communicate via events or the DI container
DI container and service decoration
Services are registered in boot() via ServiceContainer. The container supports:
set(id, factory)— lazy singleton via callable, or direct valuefactory(id, callable)— new instance on everyget()decorate(id, callable, priority)— wrap an existing service
// Decorate a service (adds behaviour around the original)
$container->decorate('stream.encoder', function (mixed $inner, ServiceContainer $c): MyEncoder {
return new MyEncoder($inner, $c->get('settings'));
}, priority: 20);
Decorators are chained by priority (highest wraps outermost). Protected services
(db, settings, config, auth) cannot be decorated — any attempt throws RuntimeException.
PSR-11 compliance
ServiceContainer implements ContainerInterface:
public function get(string $id): mixed; // throws NotFoundException if missing
public function has(string $id): bool;
NotFoundException implements NotFoundExceptionInterface extends ContainerExceptionInterface.
PSR-14 events
Events are plain PHP classes. Dispatch them via EventDispatcher:
// In any module
EventDispatcher::dispatch(new MyEvent($payload));
// Subscribe
EventDispatcher::listen(MyEvent::class, function (MyEvent $e): void {
// handle
}, priority: 10);
Priority — higher integer = called first. Default 0.
Stoppable events — extend AbstractEvent and call $e->stopPropagation():
class MyGatingEvent extends AbstractEvent {
public bool $allowed = true;
}
EventDispatcher::listen(MyGatingEvent::class, function (MyGatingEvent $e): void {
if (!$this->check()) {
$e->allowed = false;
$e->stopPropagation();
}
}, priority: 100);
Built-in core events
| Event class | When dispatched | Stoppable |
|---|---|---|
ModuleLoadedEvent |
After module file is loaded | ❌ |
ModuleBootedEvent |
After boot() is called |
❌ |
PackageInstalledEvent |
After marketplace install | ❌ |
UserAuthenticatedEvent |
After successful login | ✅ |
UserLoggedOutEvent |
After logout | ❌ |
StreamStartingEvent |
Before stream starts | ✅ |
StreamStartedEvent |
After stream started | ❌ |
StreamStoppedEvent |
After stream stopped | ❌ |
SettingsChangedEvent |
After settings saved | ❌ |
Stream Middleware
Modules can inject middleware into the stream pipeline by implementing
StreamMiddlewareProviderInterface (separate from ModuleInterface):
class MyStreamMiddleware implements StreamMiddlewareInterface {
public function getPriority(): int {
return 50;
}
public function handle(StreamContext $ctx, callable $next): StreamContext {
// before — read or set attributes
$ctx->set('my.key', 'value');
$ctx = $next($ctx);
// after
return $ctx;
}
}
StreamContext is an attribute bag (get, set, has, abort, isAborted). StreamPipeline
executes middleware sorted by getPriority() descending.
Pipeline priorities
| Range | Owner |
|---|---|
80–100 |
Core (Auth, Permission, ConnectionLimit) |
0–79 |
Modules |
Reserved navbar slots
| Parent node | Module slots |
|---|---|
management.service_setup |
order ≥ 60 |
management.logs |
order ≥ 170 |
Enable / disable modules
All discovered modules load by default. Use src/config/modules.php to override state:
return [
'my-module' => ['state' => 'disabled'], // preferred
// or legacy boolean (still accepted):
'my-module' => ['enabled' => false],
];
Available state values (backed by ModuleState enum):
| Value | Meaning |
|---|---|
enabled |
Module loads and boots (default) |
disabled |
Module is discovered but skipped |
installing |
Transient state set by ModuleManager during install |
failed |
Install failed; module skipped on next request |
To override the class resolved for a module:
return [
'my-module' => ['class' => 'XcVm\\Module\\MyModuleV2\\MyModuleV2Module'],
];
config/modules.php contains only overrides. An empty or missing file means all discovered
modules load.
How loading works
ModuleLoader follows these steps on every request:
- Scans
src/modules/*/module.json - Applies overrides from
config/modules.php - Filters by environment (
main/lb/any) - Resolves the load order:
- Topological sort (DFS) over the dependency graph
- Within the same dependency group, sorts by
prioritydescending, then alphabetically - Throws
RuntimeExceptionon cycles or missing hard dependencies - Missing optional dependencies are silently skipped
- Resolves class name:
my-module→ FQNXcVm\Module\MyModule\MyModuleModule(kebab-case → PascalCase; can be overridden viaclasskey in config) - Registers per-module autoloader via
XC_Autoloader - Instantiates the module class
In web context:
bootAll($container, $router)→ callsboot(),registerRoutes(),registerNavbar(), and subscribes to events for every loaded module
In CLI context:
registerAllCommands($registry)→ callsregisterCommands()on every loaded module
Marketplace: install via C extension
Modules from the platform are installed via ModuleManager::downloadFromPlatform():
$manager->downloadFromPlatform(slug: 'my-module', version: '1.2.0', apiKey: $key);
Under the hood:
XC_VM::module_install($slug, $version, $apiKey)— C extension downloads, decrypts, unpacksinstallModule($slug)— runsinstall()on the moduleEventDispatcher::dispatch(new PackageInstalledEvent(...))— dispatches the eventhotReload($slug, $path)— loads and boots the module in the current request without PHP-FPM restart
Isolated subsystems (BoundaryInterface)
A module that is a fully isolated subsystem with its own entry point and bootstrap
(like Ministra) implements BoundaryInterface alongside ModuleInterface:
class MyModule implements ModuleInterface, BoundaryInterface {
public function getName(): string {
return 'my-module';
}
public function getEntryPoint(): string {
// Path relative to src/ — this file handles its own bootstrap
return 'my-module/portal.php';
}
public function isIsolated(): bool {
return true;
}
}
BoundaryInterface is an isolation marker. isIsolated() = true means the subsystem
runs through its own entry point with a separate bootstrap path. It shares infrastructure
(db, cache, config) but does not participate in the main Router, ModuleLoader::bootAll(),
or NavbarRegistry. The boot() and registerRoutes() implementations may be left as
no-ops in this case.
Controller
class MyController {
protected 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 {
renderUnifiedLayoutHeader('admin', ['_TITLE' => 'My Module']);
include $this->viewsPath . '/my_page.php';
renderUnifiedLayoutFooter('admin');
include $this->viewsPath . '/my_page_scripts.php';
}
}
| Rule | |
|---|---|
__DIR__ . '/views' |
viewsPath — the controller is inside the module directory |
| GET pages | call renderUnifiedLayoutHeader before view, renderUnifiedLayoutFooter after |
| API actions | no layout — return JSON and exit |
Cron task
Cron logic (MyCron.php) — business logic only, no CLI wiring.
CronJob wrapper (MyCronJob.php) — implements CommandInterface, uses CronTrait:
class MyCronJob implements CommandInterface {
use CronTrait;
public function getName(): string { return 'cron:my_task'; }
public function getDescription(): string { return 'Cron: my 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;
}
}
Registration in the module:
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
Declare the crontab entry by overriding getCronEntries() in the module class:
public function getCronEntries(): array {
return [
'*/5 * * * *' => 'cron:my_task',
];
}
ModuleLoader::collectCronEntries() aggregates all modules' entries and StartupCommand /
StatusCommand write them to the system crontab automatically — no core file changes needed.
Format: key = cron expression, value = console command name registered via registerCommands().
Versioned migrations (MigratableInterface)
Modules that change their database schema over time implement MigratableInterface:
namespace XcVm\Module\MyModule;
use BaseModule;
use MigratableInterface;
use ServiceContainer;
class MyModuleModule extends BaseModule implements MigratableInterface {
public function getMigrations(): array {
return [
'1.1.0' => function (): void {
// runs when upgrading from any version < 1.1.0 to >= 1.1.0
global $db;
$db->query("ALTER TABLE xc_my_table ADD COLUMN new_col INT DEFAULT 0");
},
'1.2.0' => function (): void {
// runs when upgrading from < 1.2.0 to >= 1.2.0
},
];
}
}
ModuleManager::updateModule() reads installed_version from the override store, filters
the map to only the entries > fromVersion && <= toVersion, sorts by semver, and runs each
callable in its own DB transaction. installModule() records installed_version after
a successful install; uninstallModule() clears it.
Key rules:
- Keys are semver strings (
'1.1.0','2.0.0') —version_compareordering is used - Each migration runs in its own transaction — failure rolls back only that step
BaseModuleprovides a defaultgetMigrations(): array { return []; }so implementingMigratableInterfaceis optional
Composer package discovery
Modules can be distributed as Composer packages with "type": "xcvm-module":
{
"name": "vendor/my-xcvm-module",
"type": "xcvm-module",
"extra": {
"xcvm": {
"module-path": "src"
}
}
}
ModuleLoader automatically scans vendor/composer/installed.json (Composer 1 and 2
formats) and discovers any installed xcvm-module packages alongside the built-in
src/modules/ directory. Packages are deduplicated — a module in both modules/ and
vendor/ is loaded only once.
Module checklist
- Create
src/modules/<name>/ - Add
namespace XcVm\Module\<PascalName>;to every class file - Create
module.jsonwithname,version,requires_core,priority,dependencies,optional_dependencies - Create
<PascalName>Module.phpextendingBaseModule - Implement
boot()for all services the module provides - Implement
registerRoutes()for HTTP / API endpoints - Implement
registerNavbar()for admin panel items (or leave empty) - (If crons) Create
MyCron.php+MyCronJob.php, register inregisterCommands() - (If crons) Override
getCronEntries()in the module class (no core file changes) - (If migrations) Implement
MigratableInterface::getMigrations() - (If pages) Create controller using
renderUnifiedLayoutHeader/Footer - (If stream middleware) Implement
StreamMiddlewareProviderInterfaceseparately - Verify:
php -l src/modules/<name>/<PascalName>Module.php - Verify:
php console.php --listshows the module's commands - Verify: removing the module directory causes no fatal error
FAQ
Q: How do I disable a module?
In src/config/modules.php add 'module-name' => ['state' => 'disabled'].
The legacy 'enabled' => false form is also accepted for backward compatibility.
Q: How do I declare that my module depends on another?
Use dependencies in module.json for hard deps (must be present) or optional_dependencies
for soft deps (loaded before yours if present, silently skipped if absent).
Q: Can I decorate a core service?
Yes — use $container->decorate('service-id', callable, priority) in boot().
Protected services (db, settings, config, auth) cannot be decorated.
Q: How do I listen to core events?
Call EventDispatcher::listen(EventClass::class, callable, priority) anywhere after bootstrap,
typically inside boot() or a dedicated subscriber class.
Q: Can I dispatch custom events from a module?
Yes. Create a plain class or extend AbstractEvent and call EventDispatcher::dispatch(new MyEvent(...)).
Q: What is StreamMiddlewareProviderInterface for?
It lets the module inject a StreamMiddlewareInterface into the stream processing pipeline
without modifying StreamProcess.php. Implement it alongside ModuleInterface when needed.