Browse the documentation
Building modules
Module lifecycle
From discovery to boot to removal: how a module is found, registered, enabled, disabled and uninstalled, and what happens to its data at each step.
A module passes through several stages: from being discovered on disk, through booting, to being removed cleanly. Understanding these stages explains why certain things are only permitted in boot() and why a cached route set can make modules invisible.
Discovery
The ModuleManager discovers modules by scanning for module.json files:
modules/*/module.json
Each valid manifest is stored internally with its slug (directory name) and path.
// ModuleManager::discover()
$dirs = glob($this->modulesPath . '/*/module.json');
// For each: parse JSON, extract slug from directory name, store manifest
The modules path defaults to base_path('modules') and is configurable in config/schneespur_modules.php.
Boot sequence
For each discovered module that is enabled, the ModuleManager::boot() method:
- Registers the PSR-4 autoloader — maps the module’s
namespaceto itssrc/directory - Adds translation namespace — makes
lang/directory available as{slug}::key - Instantiates the ServiceProvider — class from
service_providermanifest field - Calls
register()— bind services into the container - Calls
boot()— register into extension registries, load routes/views/events - Registers module assets — reads
dist/manifest.json, creates public symlink
If any step fails, the module is auto-disabled and a diagnostic event is reported. This is intentional: a broken module should not bring down the application — it should remove itself quietly so the rest of the system keeps running.
try {
$provider->register();
$provider->boot();
$this->registerModuleAssets($slug, $manifest['path']);
} catch (\Throwable $e) {
$this->autoDisable($slug, $e->getMessage());
$this->reportDiagnostic('module_boot_failed', $slug, $e);
}
Enable / Disable
Enabling a module
$manager = app(ModuleManager::class);
$result = $manager->enable('my-module');
if ($result === true) {
// Success — module is now enabled
} else {
// $result is array of error strings (dependency failures, not_found)
}
Before enabling, the DependencyValidator checks:
- All
requiresdependencies are active and version-compatible - No
conflictsentries are active - Circular dependencies are detected via DFS
Disabling a module
$result = $manager->disable('my-module');
if ($result === true) {
// Success — module is now disabled
} else {
// $result is array of module slugs that depend on this one
}
Before disabling, reverse dependencies are checked — you cannot disable a module that another active module requires. Without this guard, a dependent module would suddenly lose its foundation.
Installation (from catalog)
The schneespur:modules-sync artisan command handles remote installation:
- Fetches the module catalog from the configured server
- Verifies catalog signatures using libsodium
- Downloads new/updated module ZIPs
- Extracts to
modules/{slug}/viaSchneespurModuleInstaller - Runs migrations:
php artisan migrate --path=modules/{slug}/database/migrations - Detects orphaned modules (present locally but removed from catalog)
Removal
The schneespur:modules-remove {slug} command:
- Disables the module
- Rolls back all module migrations
- Cleans up module settings (
Setting::where('key', 'like', '{slug}.%')) - Deletes module files from disk
- Removes public symlink
Update flow
Updates are handled by SchneespurModuleInstaller::update():
- Creates a
.bakbackup of the current module directory - Extracts the new ZIP version
- If extraction fails, rolls back from
.bak - Migrations are run after successful extraction
The .bak backup is what prevents a failed update from leaving the module in a half-extracted state. Either the new version is fully in place, or the old one is restored.
State management
Module state (what is installed and what is enabled) is tracked in two places:
modulesdatabase table — persistent record with slug, version, enabled flag, manifest, signature statusstorage/app/schneespur_modules_state.json— catalog sync state (etag, cache, trust keys)
Dependency validation
The DependencyValidator supports version constraints in module.json:
| Constraint | Example | Meaning |
|---|---|---|
* | "*" | Any version |
>=X | ">=2.0.0" | At least version X |
^X | "^1.2.0" | Compatible (same major, >= minor.patch) |
~X | "~1.2.0" | Same major.minor, >= patch |
Example:
{
"requires": {
"notifications-core": "^1.0.0",
"another-module": ">=2.1.0"
},
"conflicts": ["legacy-module"]
}
Routes, config, and caching
This is where most module problems in production originate.
Modules register their routes (Route::group()) and merge their config (mergeConfigFrom) dynamically at boot, based on which modules are enabled in the database. This interacts badly with Laravel’s static caches:
php artisan route:cacheserialises only the routes registered at the moment the cache is built. A module enabled afterwards is not in the cache, and its runtimeRoute::group()registration is shadowed by the cached route collection →route('admin.<slug>.*')throwsRouteNotFoundException(500 on the admin nav / dashboard widgets).php artisan config:cachefreezes config the same way →config('<slug>.*')returnsnull, so the module misbehaves at runtime.
Two ways to live with this:
- Bust the caches when module state changes. Re-run
config:cacheandroute:cacheafter enabling/disabling modules (or runoptimize:clearon each toggle). Required if you cache for performance. - Don’t cache routes/config on deployments that install modules at runtime. This is what the official Docker/Coolify image does — it caches only views and events.
Module authors should additionally pass routeCheck: to NavigationRegistry::addItem() (the item’s own route name) so a missing route degrades to a hidden nav item instead of a 500. See Registries — NavigationRegistry.