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. 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.