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']
);

Handler signatures

Params cross the bus as one array, and there are two conventions behind that. The bus picks by looking at the handler.

Declare the fields you want, and they arrive by name:

public function chat(string $message, ?string $conversationId = null): array
$bus->call('AI', 'ai.chat', ['message' => 'hello', 'conversationId' => 'c-1'], 'Calls');

Or take the whole payload, when the fields are data rather than a signature:

public function findOrCreate(array $data): array
$bus->call('Customers', 'customers.findOrCreate', ['phone' => '+91…', 'source' => 'call'], 'Calls');

The rule: a list spreads positionally; an associative array goes whole to a handler whose first parameter is an array and which requires nothing else, and by name to everything else. The answer is memoised per handler method.

The bus used to always spread, directly beneath a comment saying it did not. Every payload-style endpoint was therefore uncallable across a module boundary — `customers.findOrCreate`, `customers.recordInteraction`, `calls.originate`, `calls.transferToAgent` and `calls.hangup` all failed with `Unknown named parameter $phone`. They were published, they were listed, and they could not be called, which is the worst shape a fault can take: nothing looks wrong until you need it.

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.