Module Extension Points¶
The core extension points a module plugs into: the DI container, stream middleware, cron tasks, versioned migrations, and typed events. To author a module see Module Authoring; for load/lifecycle see Module Lifecycle.
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¶
Modules subscribe to typed events via getEventSubscribers() or the #[ListensTo] attribute. This is documented in full — dispatch, listener registration, priorities, stoppable events and the built-in event catalogue — on the dedicated Event System page.
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 |
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:
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)¶
Two mechanisms, both additive. The file-based schema described under Module directory structure (
database.sqlmaster +database_drop.sqlteardown +migrations/<semver>.sqldeltas) is the default for plain DDL/seed.MigratableInterfacebelow is the programmatic path for upgrade steps that need PHP logic (data backfills, conditional changes). A module can use either or both;ModuleManager::updateModule()runs the file deltas first, then the callable migrations.
Modules whose upgrades need PHP logic 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