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

660 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```text
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
```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:
```text
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.
```php
// 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
<?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
<?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
<?php
namespace XcVm\Module\MyModule;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
```
`use` the classes you reference:
```php
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
```php
// 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`:
```php
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`:
```php
// 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()`:
```php
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`):
```php
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:
```php
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:
```php
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()`:
```php
$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`:
```php
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
```php
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`:
```php
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:
```php
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
```
Declare the crontab entry by overriding `getCronEntries()` in the module class:
```php
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`:
```php
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"`:
```json
{
"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.