Files
XC_VM/docs/en/development/modules.md
T
Divarion-D f4555e7943 docs: reorganize structure, sync with current code, add missing framework reference
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
2026-06-15 18:27:23 +03:00

20 KiB
Raw Blame History

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/ and domain/, never on each other (except via declared dependencies)
  • Any module can be disabled from config/modules.php without 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, ModuleLoader throws RuntimeException
  • optional_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 priority descending (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(), and boot(). A BoundaryInterface module (isolated subsystem with its own entry point) typically leaves boot() and registerRoutes() 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 value
  • factory(id, callable) — new instance on every get()
  • 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:

  1. Scans src/modules/*/module.json
  2. Applies overrides from config/modules.php
  3. Filters by environment (main / lb / any)
  4. Resolves the load order:
    • Topological sort (DFS) over the dependency graph
    • Within the same dependency group, sorts by priority descending, then alphabetically
    • Throws RuntimeException on cycles or missing hard dependencies
    • Missing optional dependencies are silently skipped
  5. Resolves class name: my-module → FQN XcVm\Module\MyModule\MyModuleModule (kebab-case → PascalCase; can be overridden via class key in config)
  6. Registers per-module autoloader via XC_Autoloader
  7. Instantiates the module class

In web context:

  • bootAll($container, $router) → calls boot(), registerRoutes(), registerNavbar(), and subscribes to events for every loaded module

In CLI context:

  • registerAllCommands($registry) → calls registerCommands() 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:

  1. XC_VM::module_install($slug, $version, $apiKey) — C extension downloads, decrypts, unpacks
  2. installModule($slug) — runs install() on the module
  3. EventDispatcher::dispatch(new PackageInstalledEvent(...)) — dispatches the event
  4. hotReload($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_compare ordering is used
  • Each migration runs in its own transaction — failure rolls back only that step
  • BaseModule provides a default getMigrations(): array { return []; } so implementing MigratableInterface is 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.json with name, version, requires_core, priority, dependencies, optional_dependencies
  • Create <PascalName>Module.php extending BaseModule
  • 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 in registerCommands()
  • (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 StreamMiddlewareProviderInterface separately
  • Verify: php -l src/modules/<name>/<PascalName>Module.php
  • Verify: php console.php --list shows 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.