Module Lifecycle

AutoCom dispatches events at key points during module loading and tenant-level module management. These events allow you to hook into the module lifecycle for logging, monitoring, or custom behavior.

Lifecycle Overview

Application Boot
       │
       ▼
┌──────────────────┐
│ ModuleLoading    │ ◄── Event dispatched
└────────┬─────────┘
         │
    Load Module
         │
         ▼
    ┌────┴────┐
    │         │
Success    Failure
    │         │
    ▼         ▼
┌─────────┐  ┌──────────────┐
│ Loaded  │  │ LoadFailed   │ ◄── Events
└─────────┘  └──────────────┘

For tenant-level module management:

Tenant enables module     Tenant disables module
        │                         │
        ▼                         ▼
┌───────────────┐         ┌────────────────┐
│ ModuleEnabled │         │ ModuleDisabled │
└───────────────┘         └────────────────┘

Available Events

ModuleLoading

Dispatched before a module's service providers are registered.

namespace App\Core\Events;

class ModuleLoading
{
    public function __construct(
        public readonly string $moduleName,
        public readonly array $config
    ) {}
}

Use cases:

  • Pre-load logging
  • Configuration validation
  • Performance monitoring (start timer)

ModuleLoaded

Dispatched after a module successfully loads.

namespace App\Core\Events;

class ModuleLoaded
{
    public function __construct(
        public readonly string $moduleName,
        public readonly array $config
    ) {}
}

Use cases:

  • Success logging
  • Performance monitoring (end timer)
  • Cache warming
  • Feature flag initialization

ModuleLoadFailed

Dispatched when a module fails to load.

namespace App\Core\Events;

class ModuleLoadFailed
{
    public function __construct(
        public readonly string $moduleName,
        public readonly Throwable $exception
    ) {}
}

Use cases:

  • Error logging and alerting
  • Fallback behavior
  • Admin notifications
  • Health check reporting

ModuleEnabled

Dispatched when a tenant enables a module.

namespace App\Core\Events;

class ModuleEnabled
{
    public function __construct(
        public readonly string $moduleAlias,
        public readonly ?string $tenantId = null,
        public readonly array $settings = []
    ) {}
}

Use cases:

  • Tenant activity logging
  • Initial data seeding
  • Welcome emails
  • Analytics tracking

ModuleDisabled

Dispatched when a tenant disables a module.

namespace App\Core\Events;

class ModuleDisabled
{
    public function __construct(
        public readonly string $moduleAlias,
        public readonly ?string $tenantId = null
    ) {}
}

Use cases:

  • Cleanup tasks
  • Data export reminders
  • Analytics tracking
  • Resource deallocation

Listening to Events

Creating a Listener

namespace App\Listeners;

use App\Core\Events\ModuleLoaded;
use Illuminate\Support\Facades\Log;

class LogModuleLoaded
{
    public function handle(ModuleLoaded $event): void
    {
        Log::info("Module loaded: {$event->moduleName}", [
            'version' => $event->config['version'] ?? 'unknown',
            'type' => $event->config['type'] ?? 'standard',
        ]);
    }
}

Registering Listeners

In your EventServiceProvider:

namespace App\Providers;

use App\Core\Events\ModuleLoading;
use App\Core\Events\ModuleLoaded;
use App\Core\Events\ModuleLoadFailed;
use App\Core\Events\ModuleEnabled;
use App\Core\Events\ModuleDisabled;
use App\Listeners\LogModuleLoaded;
use App\Listeners\HandleModuleFailure;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        ModuleLoaded::class => [
            LogModuleLoaded::class,
        ],
        ModuleLoadFailed::class => [
            HandleModuleFailure::class,
        ],
        ModuleEnabled::class => [
            // Your listeners
        ],
        ModuleDisabled::class => [
            // Your listeners
        ],
    ];
}

Using Closures

For quick listeners in a service provider:

use App\Core\Events\ModuleLoaded;
use Illuminate\Support\Facades\Event;

public function boot(): void
{
    Event::listen(ModuleLoaded::class, function (ModuleLoaded $event) {
        // Handle the event
    });
}

Practical Examples

Performance Monitoring

Track module load times:

namespace App\Listeners;

use App\Core\Events\ModuleLoading;
use App\Core\Events\ModuleLoaded;
use Illuminate\Support\Facades\Cache;

class ModulePerformanceMonitor
{
    public function handleLoading(ModuleLoading $event): void
    {
        Cache::put("module_load_start_{$event->moduleName}", microtime(true), 60);
    }

    public function handleLoaded(ModuleLoaded $event): void
    {
        $start = Cache::pull("module_load_start_{$event->moduleName}");

        if ($start) {
            $duration = (microtime(true) - $start) * 1000;

            Log::debug("Module {$event->moduleName} loaded in {$duration}ms");

            // Report to monitoring service
            if ($duration > 100) {
                Log::warning("Slow module load: {$event->moduleName} took {$duration}ms");
            }
        }
    }
}

Register both handlers:

protected $listen = [
    ModuleLoading::class => [
        [ModulePerformanceMonitor::class, 'handleLoading'],
    ],
    ModuleLoaded::class => [
        [ModulePerformanceMonitor::class, 'handleLoaded'],
    ],
];

Error Alerting

Send alerts when modules fail:

namespace App\Listeners;

use App\Core\Events\ModuleLoadFailed;
use App\Notifications\ModuleFailedNotification;
use Illuminate\Support\Facades\Notification;

class AlertOnModuleFailure
{
    public function handle(ModuleLoadFailed $event): void
    {
        // Log the error
        Log::error("Module failed to load: {$event->moduleName}", [
            'error' => $event->exception->getMessage(),
            'trace' => $event->exception->getTraceAsString(),
        ]);

        // Alert admins for core modules
        $config = $this->getModuleConfig($event->moduleName);

        if ($config['isCore'] ?? false) {
            Notification::route('slack', config('services.slack.webhook'))
                ->notify(new ModuleFailedNotification($event->moduleName, $event->exception));
        }
    }
}

Tenant Module Analytics

Track module usage across tenants:

namespace App\Listeners;

use App\Core\Events\ModuleEnabled;
use App\Core\Events\ModuleDisabled;
use App\Models\ModuleAnalytics;

class TrackModuleUsage
{
    public function handleEnabled(ModuleEnabled $event): void
    {
        ModuleAnalytics::create([
            'module_alias' => $event->moduleAlias,
            'tenant_id' => $event->tenantId,
            'action' => 'enabled',
            'settings' => $event->settings,
            'created_at' => now(),
        ]);
    }

    public function handleDisabled(ModuleDisabled $event): void
    {
        ModuleAnalytics::create([
            'module_alias' => $event->moduleAlias,
            'tenant_id' => $event->tenantId,
            'action' => 'disabled',
            'created_at' => now(),
        ]);
    }
}

Module-Specific Initialization

Run setup when a module is enabled for a tenant:

namespace Modules\StoreShopify\Listeners;

use App\Core\Events\ModuleEnabled;
use Modules\StoreShopify\Services\ShopifyService;

class InitializeShopifyForTenant
{
    public function handle(ModuleEnabled $event): void
    {
        if ($event->moduleAlias !== 'store-shopify') {
            return;
        }

        // Run Shopify-specific initialization
        app(ShopifyService::class)->initializeForTenant(
            $event->tenantId,
            $event->settings
        );
    }
}

module.json Lifecycle Hooks

Modules can declare lifecycle hooks in module.json that are automatically invoked when a tenant enables or disables the module. This is the recommended approach for module-specific setup and teardown.

Declaring Hooks

{
  "lifecycle": {
    "onEnable": "Modules\\MyModule\\App\\Lifecycle\\OnEnable",
    "onDisable": "Modules\\MyModule\\App\\Lifecycle\\OnDisable"
  }
}

Handler Classes

Each handler must have a handle(Tenant $tenant): void method:

namespace Modules\MyModule\App\Lifecycle;

use App\Models\Tenant;

class OnEnable
{
    public function handle(Tenant $tenant): void
    {
        // Seed default data for this tenant
        // Initialize module-specific settings
        // Register webhooks, create API keys, etc.
    }
}
namespace Modules\MyModule\App\Lifecycle;

use App\Models\Tenant;

class OnDisable
{
    public function handle(Tenant $tenant): void
    {
        // Clean up tenant-specific resources
        // Deregister webhooks
        // Archive data if needed
    }
}

Automatic Migration on Enable

When a module is enabled for a tenant, the system automatically runs the module's database migrations before calling the onEnable hook. This means:

  1. Migrations in modules/MyModule/backend/database/migrations/ are executed
  2. Then the onEnable handler is called (if declared)

This ensures your onEnable handler can safely reference the module's database tables.

Automatic Permission Seeding on Enable

After the onEnable hook runs, the system walks module.json permissions{} and ensures each declared permission exists in Spatie's permissions table for the api guard. This means modules don't need to ship their own seeders for the permission keys they declare — the manifest is the source of truth.

{
  "permissions": {
    "graphql.execute":          "Run GraphQL queries",
    "graphql.introspect":       "Run schema introspection queries",
    "graphql.persisted.manage": "Create, update, and delete persisted queries"
  }
}

When the module is enabled for a tenant, three rows appear in that tenant's permissions table (idempotent — no-op on second enable). Newly-created permissions are auto-granted to the system owner role (which semantically holds *); other roles (admin, manager, agent, viewer) are deliberately untouched so operators retain control over what each curated role can do — grant them via the team UI / role editor.

If you have modules that were enabled before this auto-seeding existed, backfill them with:

# All tenants × all enabled modules
php artisan module:seed-permissions

# One module across all tenants
php artisan module:seed-permissions --module=graphql

# One tenant, all enabled modules
php artisan module:seed-permissions --tenant=acme

# Report what would be created without writing
php artisan module:seed-permissions --dry-run

Seeding failures don't block enable() — they're logged and you can re-run module:seed-permissions later. Symptom of the bug this fixes: route middleware throws Spatie\Permission\Exceptions\PermissionDoesNotExist: There is no permission named '...' for guard 'api' on first request.

Error Handling

Lifecycle hook failures are logged but non-fatal — the module is still enabled/disabled even if the hook throws an exception. This prevents a broken hook from blocking module management. Check logs for errors:

[error] Module lifecycle: Hook onEnable failed for 'my-module' — tenant: abc123 — Error message

Platform-Level Lifecycle Hooks

In addition to the per-tenant onEnable and onDisable hooks, modules can declare platform-level hooks that fire during install, upgrade, and rollback operations. These are NOT tenant-scoped — they run once per operation, regardless of how many tenants exist.

When to use platform vs tenant hooks

Hook Scope Fires on Use for
onEnable Per-tenant Tenant enables the module Seed tenant data, register tenant-scoped webhooks, create tenant API keys
onDisable Per-tenant Tenant disables the module Clean up tenant resources
onInstall Platform-wide php artisan module:install Compile binaries, warm shared caches, register central services
onUpgrade Platform-wide php artisan module:upgrade Migrate central data formats, invalidate caches across tenants
onDowngrade Platform-wide php artisan module:rollback Restore central data formats

See Module Versioning for the install/upgrade/rollback workflow.

Declaring platform hooks

{
  "lifecycle": {
    "onEnable": "Modules\\MyModule\\App\\Lifecycle\\OnEnable",
    "onDisable": "Modules\\MyModule\\App\\Lifecycle\\OnDisable",
    "onInstall": "Modules\\MyModule\\App\\Lifecycle\\OnInstall",
    "onUpgrade": "Modules\\MyModule\\App\\Lifecycle\\OnUpgrade",
    "onDowngrade": "Modules\\MyModule\\App\\Lifecycle\\OnDowngrade"
  }
}

Platform hook signature

Platform handlers receive an array $context instead of a Tenant:

namespace Modules\MyModule\App\Lifecycle;

class OnUpgrade
{
    public function handle(array $context): void
    {
        // $context contains:
        //   fromVersion → "1.0.0"
        //   toVersion   → "1.1.0"
        //   sourcePath  → "/app/storage/app/module-versions/my-module/1.1.0"
        //   manifest    → parsed MANIFEST.json from the registry artifact

        // Example: invalidate Redis cache keys that hold serialized
        // models from the old version
        Cache::tags(['my-module'])->flush();

        // Example: trigger a one-off background job
        ReindexMyModuleJob::dispatch();
    }
}

Context fields

Field onInstall onUpgrade onDowngrade Notes
version Version being installed
fromVersion Previous version
toVersion New version
sourcePath Absolute path to the staged module source
manifest Parsed MANIFEST.json from the registry artifact

Error semantics

Unlike tenant-scoped hooks, platform hook failures DO halt the operation. A failed onUpgrade causes module:upgrade to exit non-zero so the operator notices and decides whether to roll back. The on-disk swap and database row updates have already happened by then — only the post-swap hook failed.


Module Service Provider Hooks

In addition to lifecycle hooks, modules can implement lifecycle methods in their service providers:

Register Phase

Called when the module is being registered (before boot):

class MyModuleServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // Merge config
        $this->mergeConfigFrom(__DIR__ . '/../module.json', 'my-module');

        // Register services (not available yet to other modules)
        $this->app->singleton(MyService::class, fn() => new MyService());
    }
}

Boot Phase

Called after all modules are registered:

class MyModuleServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Load routes (after all services are available)
        $this->loadRoutes();

        // Load migrations
        $this->loadMigrationsFrom(__DIR__ . '/database/migrations');

        // Register with other modules
        $this->registerWidgets();
        $this->registerWorkflowNodes();
    }
}

Conditional Registration

Check if other modules are available:

public function boot(): void
{
    // Register widgets only if CoreDashboard is loaded
    if (class_exists(\Modules\CoreDashboard\Registries\WidgetRegistry::class)) {
        $this->registerWidgets();
    }

    // Register workflow nodes only if Workflows is loaded
    if (class_exists(\Modules\Workflows\Registries\WorkflowNodeRegistry::class)) {
        $this->registerWorkflowNodes();
    }
}

Error Handling

Platform-level hooks (onInstall, onUpgrade, onDowngrade) fire during php artisan module:install / module:upgrade / module:rollback, but they run after the file swap has already been committed. That means the order of events on a failed install is:

  1. ModuleInstaller downloads the artifact, verifies its SHA256, and extracts it to a temp dir
  2. The current modules/<Name>/ directory is moved aside as a backup
  3. The new extracted content is swapped in (this is the "atomic-ish" point — if the process crashes here, ModuleInstaller attempts to restore from the backup)
  4. The swap is committed, the DB modules row is updated with the new version
  5. The onInstall / onUpgrade hook runs

If step 5 throws, nothing automatically rolls back. The new code is already on disk and the DB row already points at the new version. The operator gets the exception in the logs and has to make a judgment call:

  • Hook failed but module is still functional — e.g. the hook tried to send a Slack notification that errored. Leave it, mark the incident, move on.
  • Hook failed and left the module in an inconsistent state — e.g. a schema migration partially applied. Run php artisan module:rollback <alias> to restore the previous version, then investigate and fix the hook before retrying.

There is intentionally no automatic rollback on hook failure because "hook failed" doesn't imply "module is broken" — the operator is the only one who can make that call. A future iteration could add an opt-in onInstall.rollback_on_failure: true flag in module.json, but right now it's purely manual.

If you're writing an onInstall hook: keep it idempotent and side-effect-minimal. If it has to do something irreversible (drop a table, call an external API), do the reversible work first, log a checkpoint, then do the irreversible work last — so a later retry can pick up where the previous run left off.

Debugging Lifecycle

Enable Debug Logging

Add to your .env:

LOG_LEVEL=debug

Then check logs:

tail -f storage/logs/laravel.log | grep ModuleLoader

Output:

[2024-01-20 10:00:00] local.DEBUG: ModuleLoader: Loading module 'Core'
[2024-01-20 10:00:00] local.DEBUG: ModuleLoader: Registered provider 'Modules\Core\CoreServiceProvider'
[2024-01-20 10:00:00] local.INFO: ModuleLoader: Successfully loaded module 'Core'
[2024-01-20 10:00:01] local.INFO: ModuleLoader: Loading complete {"loaded":5,"errors":0}

Check Load Order

php artisan module:list --graph

Next Steps