Skip to main content
Wintertrace
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:

  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.