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. Currently in lenient mode — violations log a warning but the call still succeeds.

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

If ResellerOrders tries to call ResellerFinance without declaring it:

[warning] ModuleBus: Module 'ResellerOrders' is calling 'ResellerFinance' API without declaring it in consumes

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

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)
  • Timeout: 120 seconds
  • 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

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. Lenient access controlconsumes violations log warnings but don't block calls. This allows gradual migration from direct imports to bus calls.

  4. Nestable tenant contextTenantContextManager saves and restores context automatically using try/finally. No more forgetting tenancy()->end().

  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. Backward compatible — The bus is additive. Existing direct imports continue to work. Modules can gradually adopt bus calls without breaking changes.