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:
- Fires local subscribers in the current tenant
- Resolves target tenants based on the strategy (descendants, ancestors, etc.)
- Dispatches a
ModuleEventFanOutJobper 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 jobstype:api-call/type:cross-tenant/type:event-fanouttarget:ModuleNamemethod:methodAliastarget-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
-
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.
-
Sync by default — Most inter-module calls need the return value immediately. Async is opt-in via
callAsync(). -
Lenient access control —
consumesviolations log warnings but don't block calls. This allows gradual migration from direct imports to bus calls. -
Nestable tenant context —
TenantContextManagersaves and restores context automatically using try/finally. No more forgettingtenancy()->end(). -
Fan-out via Horizon — Cross-tenant event delivery is queued. Each tenant gets its own job, so failures are isolated. No cascading failures.
-
Backward compatible — The bus is additive. Existing direct imports continue to work. Modules can gradually adopt bus calls without breaking changes.