Skip to main content
WintertraceLive demo
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

What “enabled” means (changed in 1.2.0)

ModuleManager::isEnabled() is default-deny and reads the modules database table:

// ModuleManager::isEnabled()
if (! isset($this->modules[$slug]))            return false; // not discovered
if (in_array($slug, $this->disabledModules))   return false; // crash-disabled this request
return $this->persistedEnabled()[$slug] ?? false;            // <- no row, no boot

Three consequences worth knowing before they cost you an afternoon:

  1. No database row means the module does not boot — not “boots with defaults”. Before 1.2.0 the disable flag lived only in memory, so a module switched off in the admin interface came back on the next restart. The persisted state is now the truth, and the absence of state is a “no”.
  2. A hand-copied modules/<slug> directory does nothing. It is discovered, it does not boot, and it does not appear in the admin module list either — that list is built from the catalogue plus database rows, never from the directory. So there is not even a button to switch it on.
  3. Rows are created by exactly two paths: installing through the admin interface (AdminModuleController::install()), and the install branch of ModulesSync. Note that schneespur:modules-sync downloads from the catalogue — it does not register what is already on disk. Updating a module never touches enabled, so an existing installation keeps its state across an upgrade.

For local development this means creating a row by hand for each module you are working on:

\App\Models\Module::create([
    'slug' => 'my-module',
    'version' => '1.0.0',
    'enabled' => true,
    'manifest_json' => [],
    'installed_at' => now(),
]);

A schneespur:modules-register command for this is planned but not built. Until then, php artisan tinker and the snippet above is the way in.

The state is cached for the duration of the request after the first read, and Module::saved / Module::deleted invalidates that cache — so a row you create through Eloquent takes effect on the next request.

What happens at boot

For each discovered module that is enabled, the ModuleManager::boot() method:

  1. Registers the PSR-4 autoloader — maps the module’s namespace to its src/ directory
  2. Adds translation namespace — makes lang/ directory available as {slug}::key
  3. Instantiates the ServiceProvider — class from service_provider manifest field
  4. Calls register() — bind services into the container
  5. Calls boot() — register into extension registries, load routes/views/events
  6. 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 requires dependencies are active and version-compatible
  • No conflicts entries 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:

  1. Fetches the module catalog from the configured server
  2. Verifies catalog signatures using libsodium
  3. Downloads new/updated module ZIPs
  4. Extracts to modules/{slug}/ via SchneespurModuleInstaller
  5. Runs migrations: php artisan migrate --path=modules/{slug}/database/migrations
  6. Detects orphaned modules (present locally but removed from catalog)

Removal

The schneespur:modules-remove {slug} command:

  1. Disables the module
  2. Rolls back all module migrations
  3. Cleans up module settings (Setting::where('key', 'like', '{slug}.%'))
  4. Deletes module files from disk
  5. Removes public symlink

Update flow

Updates are handled by SchneespurModuleInstaller::update():

  1. Creates a .bak backup of the current module directory
  2. Extracts the new ZIP version
  3. If extraction fails, rolls back from .bak
  4. 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:

  • modules database table — persistent record with slug, version, enabled flag, manifest, signature status
  • storage/app/schneespur_modules_state.json — catalog sync state (etag, cache, trust keys)

Dependency validation

The DependencyValidator supports version constraints in module.json:

ConstraintExampleMeaning
*"*"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:cache serialises only the routes registered at the moment the cache is built. A module enabled afterwards is not in the cache, and its runtime Route::group() registration is shadowed by the cached route collection → route('admin.<slug>.*') throws RouteNotFoundException (500 on the admin nav / dashboard widgets).
  • php artisan config:cache freezes config the same way → config('<slug>.*') returns null, so the module misbehaves at runtime.

Two ways to live with this:

  1. Bust the caches when module state changes. Re-run config:cache and route:cache after enabling/disabling modules (or run optimize:clear on each toggle). Required if you cache for performance.
  2. 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.