Module API Bus

The Module API Bus enables structured, loosely-coupled communication between modules. Instead of importing another module's services directly, modules declare what they provide and consume in module.json, and communicate through a central bus that handles resolution, access control, tenant context switching, and async event fan-out.

Architecture

Module A (caller)
    │
    ▼
ModuleApiBus::call('ResellerCatalog', 'catalog.calculatePrice', $params)
    │
    ├─ Access check: Does caller declare 'ResellerCatalog' in consumes?
    ├─ Registry lookup: Resolve handler class + method
    └─ Execute:
         ├─ call()              → sync, same tenant
         ├─ callAsync()         → queued via Horizon
         ├─ callInTenant()      → cross-tenant sync
         └─ callInTenantAsync() → cross-tenant queued

Event Fan-Out

ModuleEventBus::publish('ResellerCatalog', 'pricing.updated', $data)
    │
    ├─ Fire local subscribers in current tenant
    └─ Fan-out: dispatch job per target tenant
         (strategy: descendants / ancestors / chain)

module.json API Schema

Add an api section to your module's module.json:

{
  "api": {
    "provides": {
      "catalog.calculatePrice": {
        "handler": "Modules\\ResellerCatalog\\App\\Services\\CascadingPriceCalculator",
        "method": "calculateCostForReseller",
        "description": "Calculate cascading cost for a reseller",
        "mode": "sync",
        "crossTenant": true
      }
    },
    "consumes": ["Core", "ResellerCatalog"],
    "events": {
      "publishes": {
        "catalog.pricing.updated": {
          "description": "Pricing was updated",
          "fanOut": { "strategy": "descendants", "fromTenantId": "origin" }
        }
      },
      "subscribes": {
        "ResellerOrders.chain_order.delivered": "Modules\\MyModule\\App\\Listeners\\OnDelivered"
      }
    }
  }
}

Schema Reference

Section Key Description
provides {alias} API methods this module exposes
provides.*.handler FQCN Handler class (resolved from container)
provides.*.method string Method name on the handler
provides.*.mode sync|async Recommended execution mode
provides.*.crossTenant boolean Whether this method supports cross-tenant calls
consumes string[] Module names this module is allowed to call
events.publishes {alias} Events this module can publish
events.publishes.*.fanOut object|null Cross-tenant propagation config
events.publishes.*.fanOut.strategy string descendants, ancestors, chain, or explicit
events.subscribes {eventKey}: FQCN Events to subscribe to and their listener class

Making API Calls

Sync Call (Same Tenant)

use App\Core\ModuleBus\ModuleApiBus;

$bus = app(ModuleApiBus::class);
$result = $bus->call('ResellerCatalog', 'catalog.calculatePrice', [
    'product' => $product,
    'reseller' => $tenant,
]);

Async Call (Same Tenant, Queued)

$jobId = $bus->callAsync('ResellerCatalog', 'catalog.cascadePricing', [
    'product' => $product,
    'tenant' => $tenant,
]);
// Returns a UUID for tracking in Horizon

Cross-Tenant Sync Call

$result = $bus->callInTenant(
    $targetTenantId,
    'ResellerOrders',
    'orders.getPendingOrders',
    ['tenant' => $targetTenant]
);

Cross-Tenant Async Call

$jobId = $bus->callInTenantAsync(
    $targetTenantId,
    'Orders',
    'orders.updateLocalStatus',
    ['order_id' => $orderId, 'status' => 'shipped']
);

Publishing Events

use App\Core\ModuleBus\ModuleEventBus;

$events = app(ModuleEventBus::class);
$events->publish('ResellerCatalog', 'catalog.pricing.updated', [
    'product_id' => $product->id,
    'reseller_tenant_id' => $reseller->id,
    'new_cost' => $newCost,
]);

If the event has a fanOut config in module.json, the bus automatically:

  1. Fires local subscribers in the current tenant
  2. Resolves target tenants based on the strategy (descendants, ancestors, etc.)
  3. Dispatches a ModuleEventFanOutJob per target tenant via Horizon

Subscribing to Events

Declarative (module.json)

{
  "api": {
    "events": {
      "subscribes": {
        "ResellerOrders.chain_order.delivered": "Modules\\ResellerFinance\\App\\Listeners\\ProcessCodOnDelivery"
      }
    }
  }
}

Listener Class

use App\Core\ModuleBus\ModuleEvent;

class ProcessCodOnDelivery
{
    public function handle(ModuleEvent $event): void
    {
        $chainOrderId = $event->get('chain_order_id');
        $codAmount = $event->get('cod_amount');

        // Process settlement...
    }
}

The ModuleEvent object provides:

  • $event->sourceModule — Module that published the event
  • $event->eventAlias — Event name (e.g., chain_order.delivered)
  • $event->eventKey — Full key (e.g., ResellerOrders.chain_order.delivered)
  • $event->payload — Event data array
  • $event->sourceTenantId — Tenant where the event was originally published
  • $event->get('key', $default) — Helper to read payload values

Programmatic (Runtime)

$events = app(ModuleEventBus::class);
$events->subscribe('ResellerCatalog.catalog.pricing.updated', function (ModuleEvent $event) {
    // Handle pricing change...
});

Fan-Out Strategies

Strategy Target Tenants Use Case
descendants All child/grandchild tenants of the source Platform admin updates base price → all resellers notified
ancestors All parent/grandparent tenants up to root Reseller places order → propagate up the chain
chain Both ancestors and descendants Order status change → everyone in the chain sees it
explicit Caller provides tenant IDs Targeted notifications to specific tenants

Tenant Context Manager

The TenantContextManager provides safe, nestable tenant context switching:

use App\Core\ModuleBus\TenantContextManager;

$ctx = app(TenantContextManager::class);

// Switch to tenant context, auto-restore on completion
$result = $ctx->runInTenant($tenantId, function () {
    // Queries run against this tenant's database
    return Order::where('status', 'pending')->count();
});

// Nestable — switch to another tenant within a tenant context
$ctx->runInTenant($tenantA, function () use ($ctx) {
    $dataA = SomeModel::all();

    $dataB = $ctx->runInTenant($tenantB, function () {
        return SomeModel::all(); // From tenant B's database
    });
    // Back in tenant A context
});

// Switch to central database context
$users = $ctx->runInCentral(function () {
    return DB::table('users')->count();
});

Access Control

Cross-tenant calls validate the tenant hierarchy:

  • Super admin tenants can access all tenants
  • Ancestor tenants can access their descendants
  • Same tenant is always allowed
  • Central context (no tenant) can access all tenants
  • Siblings cannot access each other

Access Control (Consumes)

Modules must declare which other modules they call in the consumes array. Access control is strict by default — undeclared consumption throws a ModuleApiAccessDeniedException.

{
  "api": {
    "consumes": ["Core", "ResellerCatalog"]
  }
}

If ResellerOrders tries to call ResellerFinance without declaring it:

ModuleApiAccessDeniedException: Module 'ResellerOrders' is not allowed to consume APIs
from 'ResellerFinance'. Add 'ResellerFinance' to the consumes array in ResellerOrders'
module.json api section.

Rules:

  • Core is always consumable — no need to declare it
  • Same module can always call itself
  • Modules without a consumes declaration are denied by default (cannot call any other module)
  • Non-module app code (controllers, commands) is allowed but logged with a caller trace

The ModuleLoaderService also validates consumes at boot time — if a consumed module is not available, a warning is logged.

Rate Limiting

The ModuleBus includes built-in rate limiting to prevent queue flooding and cascade failures. Each module has per-minute limits for different operation types:

Operation Default Limit Config Key
Sync API calls 500/min api_call
Async API calls 200/min api_call_async
Cross-tenant calls 100/min cross_tenant
Event publishing 300/min event_publish
Event fan-out 50/min event_fanout

When a limit is exceeded, a ModuleBusRateLimitException is thrown.

Configuration

Override limits globally or per-module in config/module-bus.php:

'rate_limits' => [
    'default' => [
        'api_call' => 500,
        'cross_tenant' => 100,
    ],
    // Per-module override
    'ResellerNetwork' => [
        'cross_tenant' => 200,  // Higher limit for this module
    ],
],

Or via environment variables:

MODULE_BUS_RATE_API_CALL=500
MODULE_BUS_RATE_CROSS_TENANT=100

Monitoring Usage

$limiter = app(ModuleBusRateLimiter::class);
$stats = $limiter->getUsageStats('ResellerNetwork');
// Returns remaining capacity per operation type

Audit Logging

All cross-tenant API calls are automatically logged to the module_bus_audit_log table for compliance and debugging. The audit log captures:

  • Source and target tenant IDs
  • Caller and target module names
  • Method alias called
  • Call duration (in milliseconds)
  • Status (success, failed, access_denied, rate_limited)
  • Error messages (on failure)

Configuration

// config/module-bus.php
'audit' => [
    'enabled' => true,
    'log_cross_tenant' => true,
    'log_async_dispatch' => false,
    'retention_days' => 90,
],

Querying the Audit Log

use App\Core\ModuleBus\Models\ModuleBusAuditLog;

// Recent cross-tenant calls from a specific tenant
$calls = ModuleBusAuditLog::where('source_tenant_id', $tenantId)
    ->where('call_type', 'cross_tenant')
    ->orderByDesc('created_at')
    ->limit(50)
    ->get();

// Failed calls in the last hour
$failures = ModuleBusAuditLog::where('status', 'failed')
    ->where('created_at', '>=', now()->subHour())
    ->get();

Old audit records are automatically pruned based on the retention_days setting via Laravel's Prunable trait. Run php artisan model:prune in your scheduler.

Job Timeouts

All ModuleBus queue jobs have enforced timeouts to prevent hung handlers from blocking queue workers:

Job Timeout Retries Backoff
ModuleApiCallJob 60s 3 30s
CrossTenantApiCallJob 90s 3 60s
ModuleEventFanOutJob 30s 3 30s

If a handler exceeds its timeout, the job is killed and retried. After all retries are exhausted, the job is moved to the failed jobs table.

Configure timeouts via environment:

// config/module-bus.php
'timeouts' => [
    'api_call' => 60,
    'cross_tenant' => 90,
    'event_fanout' => 30,
],

Horizon Integration

Module bus jobs run on a dedicated module-bus queue with its own Horizon supervisor:

  • Queue: module-bus
  • Supervisor: supervisor-module-bus
  • Max processes: 2 (local) / 5 (production)
  • Retries: 3

All jobs are tagged for Horizon filtering:

  • module-bus — all bus jobs
  • type:api-call / type:cross-tenant / type:event-fanout
  • target:ModuleName
  • method:methodAlias
  • target-tenant:id

Declare module-bus in your module's queue list if you want Horizon to auto-discover it:

{
  "backend": {
    "queues": ["module-bus"]
  }
}

CLI Commands

List All Registered APIs

php artisan module:bus:list

Outputs tables showing:

  • All registered API endpoints (module, method alias, handler, mode, cross-tenant)
  • All registered events (event key, module, description, fan-out strategy, subscriber count)
  • Module dependency graph (consumes)

Filter by module:

php artisan module:bus:list --module=ResellerCatalog

View Audit Log

php artisan module:bus:audit

View recent audit entries with filters:

# Filter by module
php artisan module:bus:audit --module=ResellerAdmin

# Filter by tenant
php artisan module:bus:audit --tenant=abc123

# Show only failures
php artisan module:bus:audit --status=failed

# Aggregated statistics
php artisan module:bus:audit --stats

# Purge old records
php artisan module:bus:audit --purge

Module Health Endpoint

Super admins can check module system health via API:

GET /api/v1/modules/health

Returns:

{
  "status": "healthy",
  "modules": {
    "loaded": 15,
    "registered_in_bus": 8,
    "load_errors": {}
  },
  "bus": {
    "endpoints": 24,
    "events": 12,
    "handler_issues": [],
    "listener_issues": []
  },
  "consumers": {
    "ResellerAdmin": ["Core", "ResellerCatalog"],
    "ResellerNetwork": ["Core", "ResellerAdmin", "ResellerCatalog", "ResellerOrders", "ResellerFinance"]
  }
}

If any handler classes or listener classes are missing from disk, the endpoint reports "degraded" status with details.

Key Design Decisions

  1. In-process calls, not HTTP — Modules share the same Laravel process. The bus resolves handlers from the container and calls methods directly. No HTTP overhead.

  2. Sync by default — Most inter-module calls need the return value immediately. Async is opt-in via callAsync().

  3. Strict access controlconsumes violations throw exceptions. Modules must explicitly declare their dependencies.

  4. Nestable tenant contextTenantContextManager saves and restores context automatically using try/finally. Context stacks are scoped per execution fiber, so concurrent Horizon jobs don't interfere.

  5. Fan-out via Horizon — Cross-tenant event delivery is queued. Each tenant gets its own job, so failures are isolated. No cascading failures.

  6. Rate limited — Built-in per-module rate limiting prevents queue flooding. Configurable globally and per-module.

  7. Audited — All cross-tenant calls are logged with timing, status, and error details for compliance and debugging.