2026-03-16 22:34:17 +03:00
# Module System
## Overview
2026-06-14 14:25:19 +03:00
A module is an isolated directory under `src/modules/` with a known contract. The system
is built on **Extensible Platform** principles:
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
- 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
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Module directory structure
2026-03-16 22:34:17 +03:00
2026-06-15 18:05:27 +03:00
```text
2026-06-14 14:25:19 +03:00
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
```
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## module.json
2026-03-16 22:34:17 +03:00
```json
{
"name" : "my-module" ,
2026-06-14 14:25:19 +03:00
"description" : "Short description" ,
2026-03-16 22:34:17 +03:00
"version" : "1.0.0" ,
2026-05-06 21:53:37 +03:00
"requires_core" : ">=2.0" ,
"environment" : "main" ,
2026-06-14 14:25:19 +03:00
"priority" : 0 ,
2026-05-06 21:53:37 +03:00
"dependencies" : [],
2026-06-14 14:25:19 +03:00
"optional_dependencies" : [],
2026-05-06 21:53:37 +03:00
"has_navbar" : false ,
"has_settings" : false
2026-03-16 22:34:17 +03:00
}
```
### Manifest fields
2026-06-14 14:25:19 +03:00
| 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 |
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
**Hard vs soft dependencies:**
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
- `dependencies` — if any is missing or disabled, `ModuleLoader` throws `RuntimeException`
- `optional_dependencies` — loaded before this module if present, silently skipped if absent
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
**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()
2026-05-06 21:53:37 +03:00
```
2026-06-14 14:25:19 +03:00
`StreamMiddlewareProviderInterface` is **optional** — it is NOT part of `ModuleInterface` .
Implement it only if the module needs to inject itself into the stream pipeline.
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
```php
// Optional — not in ModuleInterface
class MyModule implements ModuleInterface , StreamMiddlewareProviderInterface {
public function getStreamMiddleware () : array {
return [ new MyStreamMiddleware ()];
}
}
```
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Module class
2026-03-16 22:34:17 +03:00
2026-06-15 18:05:27 +03:00
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.
2026-03-16 22:34:17 +03:00
```php
<? php
2026-06-15 18:27:23 +03:00
namespace XcVm\Module\MyModule ;
2026-03-16 22:34:17 +03:00
2026-06-15 18:27:23 +03:00
use BaseModule ;
use ServiceContainer ;
use Router ;
use CommandRegistry ;
use NavbarRegistry ;
use NavbarItem ;
class MyModuleModule extends BaseModule {
2026-06-14 14:25:19 +03:00
2026-03-16 22:34:17 +03:00
public function getName () : string {
return 'my-module' ;
}
public function getVersion () : string {
return '1.0.0' ;
}
public function boot ( ServiceContainer $container ) : void {
2026-06-15 18:27:23 +03:00
$container -> set ( 'my-module.service' , function ( ServiceContainer $c ) : MyModuleService {
return new MyModuleService ( $c -> get ( 'db' ));
2026-06-14 14:25:19 +03:00
});
2026-03-16 22:34:17 +03:00
}
public function registerRoutes ( Router $router ) : void {
2026-06-15 18:27:23 +03:00
$router -> get ( 'my_page' , [ MyModuleController :: class , 'index' ], [
2026-03-16 22:34:17 +03:00
'permission' => [ 'adv' , 'my_module' ],
]);
}
public function registerCommands ( CommandRegistry $registry ) : void {
2026-06-15 18:27:23 +03:00
$registry -> register ( new MyModuleCronJob ());
2026-03-16 22:34:17 +03:00
}
2026-05-06 18:01:07 +03:00
public function registerNavbar () : void {
2026-06-14 14:25:19 +03:00
NavbarRegistry :: add (
( new NavbarItem ( 'management.service_setup.my_module' ))
-> parent ( 'management.service_setup' )
-> url ( 'my_page' )
-> label ( 'my_module' )
-> permissions ([ 'my_module' ])
-> order ( 60 )
);
2026-05-06 18:01:07 +03:00
}
2026-03-16 22:34:17 +03:00
}
```
2026-06-15 18:05:27 +03:00
> **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.
2026-06-14 14:25:19 +03:00
### Method contract
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
| 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) |
2026-03-16 22:34:17 +03:00
---
2026-06-15 18:27:23 +03:00
## 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:
2026-06-15 18:05:27 +03:00
2026-06-15 18:27:23 +03:00
```php
<? php
namespace XcVm\Module\MyModule ;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
```
`use` the classes you reference:
```php
namespace XcVm\Module\MyModule ;
2026-06-15 18:05:27 +03:00
2026-06-15 18:27:23 +03:00
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 ());
}
}
```
2026-06-15 18:05:27 +03:00
**Rules:**
2026-06-15 18:27:23 +03:00
- 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
2026-06-15 18:05:27 +03:00
---
2026-06-14 14:25:19 +03:00
## DI container and service decoration
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
Services are registered in `boot()` via `ServiceContainer` . The container supports:
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
- **`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
2026-03-16 22:34:17 +03:00
```php
2026-06-14 14:25:19 +03:00
// 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 );
2026-03-16 22:34:17 +03:00
```
2026-06-14 14:25:19 +03:00
Decorators are chained by priority (highest wraps outermost). Protected services
(`db` , `settings` , `config` , `auth` ) cannot be decorated — any attempt throws `RuntimeException` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
### PSR-11 compliance
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
`ServiceContainer` implements `ContainerInterface` :
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
```php
public function get ( string $id ) : mixed ; // throws NotFoundException if missing
public function has ( string $id ) : bool ;
```
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
`NotFoundException` implements `NotFoundExceptionInterface extends ContainerExceptionInterface` .
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## PSR-14 events
Events are plain PHP classes. Dispatch them via `EventDispatcher` :
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
```php
// In any module
EventDispatcher :: dispatch ( new MyEvent ( $payload ));
// Subscribe
EventDispatcher :: listen ( MyEvent :: class , function ( MyEvent $e ) : void {
// handle
}, priority : 10 );
```
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
**Priority** — higher integer = called first. Default `0` .
**Stoppable events** — extend `AbstractEvent` and call `$e->stopPropagation()` :
2026-05-06 18:01:07 +03:00
```php
2026-06-14 14:25:19 +03:00
class MyGatingEvent extends AbstractEvent {
public bool $allowed = true ;
2026-05-06 18:01:07 +03:00
}
2026-06-14 14:25:19 +03:00
EventDispatcher :: listen ( MyGatingEvent :: class , function ( MyGatingEvent $e ) : void {
if ( ! $this -> check ()) {
$e -> allowed = false ;
$e -> stopPropagation ();
}
}, priority : 100 );
2026-05-06 18:01:07 +03:00
```
2026-06-14 14:25:19 +03:00
### Built-in core events
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
| 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 | ❌ |
2026-05-06 18:01:07 +03:00
---
2026-06-14 14:25:19 +03:00
## Stream Middleware
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
Modules can inject middleware into the stream pipeline by implementing
`StreamMiddlewareProviderInterface` (separate from `ModuleInterface` ):
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
```php
class MyStreamMiddleware implements StreamMiddlewareInterface {
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
public function getPriority () : int {
return 50 ;
}
2026-05-06 18:01:07 +03:00
2026-06-15 18:05:27 +03:00
public function handle ( StreamContext $ctx , callable $next ) : StreamContext {
// before — read or set attributes
$ctx -> set ( 'my.key' , 'value' );
$ctx = $next ( $ctx );
2026-06-14 14:25:19 +03:00
// after
2026-06-15 18:05:27 +03:00
return $ctx ;
2026-06-14 14:25:19 +03:00
}
}
```
2026-05-06 18:01:07 +03:00
2026-06-15 18:05:27 +03:00
`StreamContext` is an attribute bag (`get` , `set` , `has` , `abort` , `isAborted` ). `StreamPipeline`
2026-06-14 14:25:19 +03:00
executes middleware sorted by `getPriority()` descending.
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
### Pipeline priorities
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
| Range | Owner |
| ---------- | ----------------- |
| `80– 100` | Core (Auth, Permission, ConnectionLimit) |
| `0– 79` | Modules |
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
### Reserved navbar slots
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
| Parent node | Module slots |
| ------------------- | ------------------ |
| `management.service_setup` | `order` ≥ 60 |
| `management.logs` | `order` ≥ 170 |
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
---
## Enable / disable modules
2026-06-15 18:27:23 +03:00
All discovered modules load by default. Use `src/config/modules.php` to override state:
2026-03-18 20:33:25 +03:00
```php
2026-06-14 14:25:19 +03:00
return [
2026-06-15 18:27:23 +03:00
'my-module' => [ 'state' => 'disabled' ], // preferred
// or legacy boolean (still accepted):
2026-06-14 14:25:19 +03:00
'my-module' => [ 'enabled' => false ],
];
```
2026-03-18 20:33:25 +03:00
2026-06-15 18:27:23 +03:00
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 |
2026-06-14 14:25:19 +03:00
To override the class resolved for a module:
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
```php
return [
2026-06-15 18:27:23 +03:00
'my-module' => [ 'class' => 'XcVm\\Module\\MyModuleV2\\MyModuleV2Module' ],
2026-06-14 14:25:19 +03:00
];
2026-03-18 20:33:25 +03:00
```
2026-06-14 14:25:19 +03:00
`config/modules.php` contains only overrides. An empty or missing file means all discovered
modules load.
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
---
## 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
2026-06-15 18:27:23 +03:00
5. Resolves class name: `my-module` → FQN `XcVm\Module\MyModule\MyModuleModule`
(kebab-case → PascalCase; can be overridden via `class` key in config)
2026-06-14 14:25:19 +03:00
6. Registers per-module autoloader via `XC_Autoloader`
7. Instantiates the module class
In web context:
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
- `bootAll($container, $router)` → calls `boot()` , `registerRoutes()` , `registerNavbar()` ,
and subscribes to events for every loaded module
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
In CLI context:
- `registerAllCommands($registry)` → calls `registerCommands()` on every loaded module
2026-03-18 20:33:25 +03:00
---
2026-06-14 14:25:19 +03:00
## 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 );
```
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
Under the hood:
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
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)
2026-06-15 18:05:27 +03:00
A module that is a fully isolated subsystem with its own entry point and bootstrap
(like Ministra) implements `BoundaryInterface` alongside `ModuleInterface` :
2026-03-16 22:34:17 +03:00
```php
2026-06-15 18:05:27 +03:00
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 ;
}
2026-06-14 14:25:19 +03:00
}
```
2026-03-16 22:34:17 +03:00
2026-06-15 18:05:27 +03:00
`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.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
---
## 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' ;
2026-03-16 22:34:17 +03:00
}
2026-06-14 14:25:19 +03:00
public function index () : void {
renderUnifiedLayoutHeader ( 'admin' , [ '_TITLE' => 'My Module' ]);
include $this -> viewsPath . '/my_page.php' ;
renderUnifiedLayoutFooter ( 'admin' );
include $this -> viewsPath . '/my_page_scripts.php' ;
2026-03-16 22:34:17 +03:00
}
}
```
2026-06-14 14:25:19 +03:00
| 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 |
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
---
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
## Cron task
**Cron logic** (`MyCron.php` ) — business logic only, no CLI wiring.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**CronJob wrapper** (`MyCronJob.php` ) — implements `CommandInterface` , uses `CronTrait` :
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
```php
2026-03-16 22:34:17 +03:00
class MyCronJob implements CommandInterface {
use CronTrait ;
2026-06-14 14:25:19 +03:00
public function getName () : string { return 'cron:my_task' ; }
public function getDescription () : string { return 'Cron: my task' ; }
2026-03-16 22:34:17 +03:00
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 ;
}
}
```
2026-06-14 14:25:19 +03:00
Registration in the module:
2026-03-16 22:34:17 +03:00
```php
public function registerCommands ( CommandRegistry $registry ) : void {
$registry -> register ( new MyCronJob ());
}
```
2026-06-15 18:27:23 +03:00
Declare the crontab entry by overriding `getCronEntries()` in the module class:
2026-03-16 22:34:17 +03:00
```php
2026-06-15 18:27:23 +03:00
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
},
];
}
}
2026-03-16 22:34:17 +03:00
```
2026-06-15 18:27:23 +03:00
`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.
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Module checklist
- [ ] Create `src/modules/<name>/`
2026-06-15 18:27:23 +03:00
- [ ] Add `namespace XcVm\Module\<PascalName>;` to every class file
2026-06-14 14:25:19 +03:00
- [ ] Create `module.json` with `name` , `version` , `requires_core` , `priority` , `dependencies` , `optional_dependencies`
2026-06-15 18:27:23 +03:00
- [ ] Create `<PascalName>Module.php` extending `BaseModule`
2026-06-14 14:25:19 +03:00
- [ ] 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()`
2026-06-15 18:27:23 +03:00
- [ ] (If crons) Override `getCronEntries()` in the module class (no core file changes)
- [ ] (If migrations) Implement `MigratableInterface::getMigrations()`
2026-06-14 14:25:19 +03:00
- [ ] (If pages) Create controller using `renderUnifiedLayoutHeader/Footer`
- [ ] (If stream middleware) Implement `StreamMiddlewareProviderInterface` separately
2026-06-15 18:27:23 +03:00
- [ ] Verify: `php -l src/modules/<name>/<PascalName>Module.php`
2026-06-14 14:25:19 +03:00
- [ ] Verify: `php console.php --list` shows the module's commands
- [ ] Verify: removing the module directory causes no fatal error
2026-03-16 22:34:17 +03:00
---
## FAQ
**Q: How do I disable a module?**
2026-06-15 18:27:23 +03:00
In `src/config/modules.php` add `'module-name' => ['state' => 'disabled']` .
The legacy `'enabled' => false` form is also accepted for backward compatibility.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**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).
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**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.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**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.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Can I dispatch custom events from a module?**
Yes. Create a plain class or extend `AbstractEvent` and call `EventDispatcher::dispatch(new MyEvent(...))` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**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.