17 KiB
Module System
Overview
A module is an isolated directory under src/modules/ with a known contract. Removing a module does not break the system — it continues working with degraded functionality.
Architecture
modules/
├── my-module/
│ ├── module.json # Metadata (name, description, version, requires_core)
│ ├── MyModule.php # Source of truth (implements ModuleInterface)
│ ├── MyService.php # Module services
│ ├── MyController.php # Controller (if pages exist)
│ ├── MyCron.php # Cron logic (if any)
│ ├── MyCronJob.php # CLI cron wrapper (implements CommandInterface)
│ ├── views/ # Page templates
│ │ ├── my_page.php
│ │ └── my_page_scripts.php
│ └── migrations/ # SQL migrations (if any)
│ └── 001_create_table.sql
Principles
| Rule | Description |
|---|---|
| PHP is the source of truth | All behavior is defined in the module class, not in JSON |
| module.json is metadata only | name, description, version, requires_core |
| Auto-discovery | ModuleLoader scans modules/*/module.json — no config registration needed |
| Isolation | Module depends on core/ and domain/, but NEVER on other modules |
| Graceful degradation | Removing the module directory causes no errors |
| No reverse dependencies | Core (core/) is unaware of modules |
| DI via container | Services registered in boot(), not via globals |
| Explicit command registration | Module registers commands in registerCommands(), no filesystem scanning |
Step 1. Create a directory
mkdir -p src/modules/my-module
Directory name = module name. Use kebab-case: my-module.
Step 2. Create the manifest module.json
{
"name": "my-module",
"version": "1.0.0",
"requires_core": ">=2.0",
"environment": "main",
"dependencies": [],
"has_navbar": false,
"has_settings": false
}
Manifest fields
| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Unique module name (matches directory name) |
description |
string |
⛔ | Short human-readable module description |
version |
string |
✅ | Semver version (1.0.0) |
requires_core |
string |
✅ | Minimum core version (>=2.0) |
environment |
string |
✅ | Environment: main (primary server), lb (load-balancer), any (both) |
dependencies |
array |
✅ | Array of module names that this module depends on. Empty array [] if no dependencies |
has_navbar |
boolean |
✅ | Whether this module has navbar items in admin panel |
has_settings |
boolean |
✅ | Whether this module has a settings page |
Important:
- All dependencies must be strings (module names).
- Dependencies must exist or ModuleLoader will throw an error during loadAll().
- Cyclic dependencies are automatically detected and cause load failure.
- Modules are loaded in dependency order: if A depends on B, then B is loaded first.
environmentmust bemain,lb, orany. Ifmain, module only loads on primary servers.
Manifest examples:
{
"name": "watch",
"description": "Watch activity tracking and statistics",
"version": "1.2.0",
"requires_core": ">=2.0",
"environment": "main",
"dependencies": [],
"has_navbar": true,
"has_settings": true
}
{
"name": "plex",
"description": "Plex integration module",
"version": "2.0.0",
"requires_core": ">=2.0",
"environment": "any",
"dependencies": ["ministra"],
"has_navbar": true,
"has_settings": true
}
{
"name": "load-balancer-stats",
"description": "Load balancer statistics collector",
"version": "1.0.0",
"requires_core": ">=2.0",
"environment": "lb",
"dependencies": [],
"has_navbar": false,
"has_settings": false
}
Note on dependencies: If your module requires functionality from another module, list its name in
dependencies. ModuleLoader automatically ensures the required module is loaded before yours, and will throw an error if the required module is missing or disabled.
Note on environment: Use
mainby default for modules on primary servers,lbfor load-balancer,anyif the module works everywhere. In practice,anyis rarely used — most modules are specific to one environment.
Step 3. Create the module class
File src/modules/my-module/MyModule.php:
<?php
class MyModule implements ModuleInterface {
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', 'MyService');
}
public function registerRoutes(Router $router): void {
$router->get('my-module', [MyController::class, 'index'], [
'permission' => ['adv', 'my_module'],
]);
$router->api('my_action', [MyController::class, 'apiAction'], [
'permission' => ['adv', 'my_module'],
]);
}
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
public function getEventSubscribers(): array {
return [];
}
public function install(): void {
// Create tables, seed data, etc.
}
public function uninstall(): void {
// Clean up module data
}
public function registerNavbar(): void {
// Register nav items via NavbarRegistry::add()
// Keep this method empty if the module has no nav entries
}
}
ModuleInterface contract
| Method | Description |
|---|---|
getName(): string |
Unique name (matches directory) |
getVersion(): string |
Semver version |
boot(ServiceContainer) |
Register services. Called once on load |
registerRoutes(Router) |
HTTP routes and API actions |
registerCommands(CommandRegistry) |
Explicit registration of CLI commands and cron tasks |
getEventSubscribers(): array |
Core event subscriptions |
install(): void |
Module installation (migrations, seed data) |
uninstall(): void |
Module data cleanup |
registerNavbar(): void |
Register module items in admin navbar |
Step 4. Automatic registration
No config registration needed. ModuleLoader automatically discovers all modules from modules/*/module.json.
To disable a module — add to src/config/modules.php:
return [
'my-module' => ['enabled' => false],
];
config/modules.php contains only overrides. If the file is empty or missing — all discovered modules are loaded.
How loading works
ModuleLoader::loadAll()scansmodules/*/module.json- Checks overrides in
config/modules.php - Filters modules by environment (main/lb/any)
- Sorts topologically by dependencies (throws error if cyclic or missing dependency)
- Resolves class by convention:
my-module→MyModule(kebab-case → PascalCase + Module) - Creates module instance
In web context (public/index.php for admin/reseller):
loadAll()→ loads and instantiates all modulesbootAll($container, $router)→ callsboot(),registerRoutes(),registerNavbar(), subscribes to events
Status M-1: ✅ Complete. Web module boot is fully integrated in front controller.
In CLI context (console.php):
loadAll()→ loads and instantiates all modulesregisterAllCommands($registry)→ callsregisterCommands()on each module
Step 4b. Register navbar buttons and menu items
Do not add module menu items directly in header.php.
Each module must register its own entries in registerNavbar() using NavbarRegistry::add().
Example (module adds one item to service setup and one item to logs):
public function registerNavbar(): void {
NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
->parent('management.service_setup')
->url('my_module')
->label('my_module')
->permissions(['my_module'])
->order(60));
NavbarRegistry::add((new NavbarItem('management.logs.my_module_log'))
->parent('management.logs')
->url('my_module_logs')
->label('', 'My Module Logs')
->permissions(['my_module'])
->order(170));
}
Rules for modules:
keymust be unique and stable (prefersection.group.item).parentmust point to an existing core tree node.ordercontrols position inside the same parent (smaller = higher).- Use
label('translation_key')for translatable text. - Use
label('', 'Literal Text')for literal text. - If the module has no menu entries, keep
registerNavbar()empty.
How navbar rendering works
Rendering is fully declarative and built from NavbarRegistry:
ModuleLoader::bootAll()starts withCoreNavbarProvider::register().- Then each module contributes items via
registerNavbar(). - In
public/Views/admin/header.php, top-level items come fromNavbarRegistry::getTopLevel(). - Child items are rendered recursively via
NavbarRegistry::getChildren().
Visibility rules (helper _xc_nav_visible):
desktopOnlyhides the item on mobile.settingDisabledhides the item when the corresponding setting flag is enabled.permissionsare checked viaAuthorization::check('adv', ...)using OR logic.- A group with
url='#'is shown only if it has at least one visible child.
Special rendering cases:
dividerrenders as a separator without a link.submenuClass('megamenu')switches to two-column rendering for long lists.noMobileSubmenudisables submenu expansion on mobile.
Step 4a. Create a controller (optional)
If the module has admin pages, create a controller class. The controller uses the global layout system via renderUnifiedLayoutHeader() / renderUnifiedLayoutFooter().
File src/modules/my-module/MyController.php:
<?php
class MyController {
protected $viewsPath;
protected $layoutsPath;
public function __construct() {
$this->viewsPath = __DIR__ . '/views';
$this->layoutsPath = MAIN_HOME . 'public/Views/layouts/';
require_once $this->layoutsPath . 'admin.php';
require_once $this->layoutsPath . 'footer.php';
}
public function index(): void {
$_TITLE = 'My Module';
renderUnifiedLayoutHeader('admin', ['_TITLE' => $_TITLE]);
include $this->viewsPath . '/my_page.php';
renderUnifiedLayoutFooter('admin');
include $this->viewsPath . '/my_page_scripts.php';
}
public function apiAction(): void {
// API actions (POST) — no layout needed
$action = $_GET['sub'] ?? '';
// ...
echo json_encode(['result' => true]);
exit;
}
}
Layout rules
| Rule | Description |
|---|---|
| viewsPath | Always __DIR__ . '/views' — the controller is inside the module directory |
| layoutsPath | MAIN_HOME . 'public/Views/layouts/' — shared across all modules |
| GET pages | Must call renderUnifiedLayoutHeader() before and renderUnifiedLayoutFooter() after the view |
| API actions | No layout — return JSON directly |
| Scripts include | Module-specific JS is loaded via <module>_scripts.php after the footer |
Important: Use
__DIR__ . '/views'for viewsPath — notdirname(__DIR__) . '/modules/...'. The controller file is already inside the module directory.
renderUnifiedLayoutHeader('admin', [...])andrenderUnifiedLayoutFooter('admin')are defined inpublic/Views/layouts/admin.phpandfooter.php. They extract the necessary global variables ($rSettings,$rUserInfo,$db, etc.) and render the shared admin header/footer.
Step 5. Add a cron task (optional)
5.1 Cron class (logic) — in the module
File src/modules/my-module/MyCron.php:
<?php
class MyCron {
public static function run(): void {
$items = Database::query("SELECT * FROM my_table WHERE status = 'pending'");
foreach ($items as $item) {
self::processItem($item);
}
}
private static function processItem(array $item): void {
// Process item
}
}
5.2 CronJob wrapper — in the module directory
File src/modules/my-module/MyCronJob.php:
<?php
require_once MAIN_HOME . 'cli/CronTrait.php';
class MyCronJob implements CommandInterface {
use CronTrait;
public function getName(): string {
return 'cron:my_task';
}
public function getDescription(): string {
return 'Cron: task description';
}
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;
}
}
5.3 Registration in the module
Commands are registered explicitly in registerCommands():
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
Important: Filesystem scanning of modules is not used. Each module knows its own commands and registers them in
registerCommands().
5.4 Add to crontab
In src/cli/Commands/StartupCommand.php method installCrontab(), add:
$rCrons[] = '*/5 * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:my_task # XC_VM';
Step 6. Build configuration (Makefile)
The modules/ directory is not included in LB_DIRS — all modules are only present in MAIN builds by default. Module files (crons, commands, views) are automatically excluded from LoadBalancer builds.
Complete examples
Minimal module (no crons, no routes)
modules/my-module/
├── module.json
└── MyModule.php
module.json:
{
"name": "my-module",
"version": "1.0.0",
"requires_core": ">=2.0"
}
MyModule.php — implements all ModuleInterface methods. Methods without behavior are left empty.
Full module (services + routes + commands + events)
Example: plex, watch.
modules/my-module/
├── module.json
├── MyModule.php
├── MyService.php
├── MyRepository.php
├── MyController.php
├── MyCron.php
├── MyCronJob.php
└── views/
├── my_page.php
└── my_page_scripts.php
All module files live inside its directory. CronJob wrappers are registered via registerCommands().
Controllers use the global layout system — see Step 4a for the pattern.
Module with events
public function getEventSubscribers(): array {
return [
'stream.started' => [MyHandler::class, 'onStreamStarted'],
'stream.stopped' => [MyHandler::class, 'onStreamStopped'],
'user.connected' => [MyHandler::class, 'onUserConnected'],
];
}
Module addition checklist
- Create directory
src/modules/<name>/ - Create
module.json(name,version,requires_core) - Create
<Name>Module.php(implementsModuleInterface) - (If crons) Create
<Name>Cron.php+<Name>CronJob.phpin the module - (If crons) Register in
registerCommands() - (If crons) Add to crontab via
StartupCommand - (If pages) Create controller with
renderUnifiedLayoutHeader/Footer - (If pages) Create
views/directory with page templates - (If pages) Register routes in
registerRoutes()(and temporarily inpublic/routes/admin.php) - Verify:
php -l src/modules/<name>/<Name>Module.php - Verify: module loads with
php console.php --list - Verify: removing module directory causes no fatal error
Available core events
| Event | Description | Data |
|---|---|---|
stream.started |
Stream started | ['stream_id' => int] |
stream.stopped |
Stream stopped | ['stream_id' => int] |
user.connected |
User connected | ['user_id' => int, 'stream_id' => int] |
cache.rebuilt |
Cache rebuilt | [] |
FAQ
Q: How do I disable a module?
A: In src/config/modules.php add 'module-name' => ['enabled' => false].
Q: Do I need to register the module in config?
A: No. ModuleLoader automatically discovers all modules from modules/*/module.json. Config is only needed for disabling.
Q: My module depends on another module — how?
A: Use the dependencies array in module.json to declare inter-module dependencies. ModuleLoader performs topological sorting and will throw a RuntimeException if a declared dependency is missing. That said, prefer depending on core/ and domain/ when possible — extract shared functionality to core rather than creating deep inter-module dependency chains.
Q: Can I use $db directly?
A: Technically yes (via global $db), but architecturally correct is to use Database through ServiceContainer or Repository.
Q: How does a module access settings?
A: Via SettingsManager::getAll()['my_key']. Module settings keys are stored in the shared settings table.
Q: My module is MAIN-only — what do I do?
A: All modules are already MAIN-only by default — modules/ is not included in LB_DIRS.