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.
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. 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:
Coreis always consumable — no need to declare it- Same module can always call itself
- Modules without a
consumesdeclaration 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 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
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
-
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(). -
Strict access control —
consumesviolations throw exceptions. Modules must explicitly declare their dependencies. -
Nestable tenant context —
TenantContextManagersaves and restores context automatically using try/finally. Context stacks are scoped per execution fiber, so concurrent Horizon jobs don't interfere. -
Fan-out via Horizon — Cross-tenant event delivery is queued. Each tenant gets its own job, so failures are isolated. No cascading failures.
-
Rate limited — Built-in per-module rate limiting prevents queue flooding. Configurable globally and per-module.
-
Audited — All cross-tenant calls are logged with timing, status, and error details for compliance and debugging.