Module Lifecycle¶
How XC_VM discovers, loads, enables/disables, installs and distributes modules at runtime. To author a module see Module Authoring; for its extension hooks see Module Extension Points.
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 (not loaded) |
Panel diagnostics. The Modules page shows a yellow ⚠ Dependency issue badge next to a module's status when a required dependency is missing or not enabled (e.g.
plexreadsEnabledbutwatchisfailed). The badge tooltip lists the concrete problems. Thisdependency_warningsfield is computed byModuleManager::listModules().
To override the class resolved for a module:
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:
- Scans
src/Modules/*/module.json - Applies overrides from
config/modules.php - Filters by environment (
main/lb/any) - Resolves the load order:
pruneUnsatisfiableModules()drops modules whose required dependencies are unavailable (cascading, with a logged warning) so the load never aborts- Topological sort (DFS) over the dependency graph
- Within the same dependency group, sorts by
prioritydescending, then alphabetically - Throws
ModuleCycleExceptionon cycles (a subclass of\RuntimeException; cyclic dependencies remain fatal) - Missing optional dependencies are silently skipped
- Resolves class name:
my-module→ FQNXcVm\Module\MyModule\MyModuleModule(kebab-case → PascalCase; can be overridden viaclasskey in config) - Registers the module's PSR-4 autoloader (maps
XcVm\Module\<Name>onto the module directory) - Instantiates the module class
In web context:
bootAll($container, $router)→ callsboot(),registerRoutes(),registerNavbar(), and subscribes to events for every loaded module
In CLI context:
registerAllCommands($registry)→ callsregisterCommands()on every loaded module
Marketplace: install via C extension¶
Modules from the platform are installed via ModuleManager::downloadFromPlatform():
Under the hood:
XC_VM::module_install($slug, $version, $apiKey)— C extension downloads, decrypts, unpacksinstallModule($slug)— runsinstall()on the moduleEventDispatcher::dispatch(new PackageInstalledEvent(...))— dispatches the eventhotReload($slug, $path)— loads and boots the module in the current request without PHP-FPM restart
Isolated subsystems¶
A module can be a fully isolated subsystem with its own entry point and bootstrap
(like Ministra). This is a convention, not a marker interface — it stays an
ordinary ModuleInterface/BaseModule module:
class MyModule extends BaseModule {
public function getName(): string {
return 'my-module';
}
public function getVersion(): string {
return '1.0.0';
}
}
Isolation means the subsystem runs through its own public entry point (e.g.
my-module/portal.php, a path relative to src/ that handles its own bootstrap)
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 are typically
left as inherited no-ops.
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.