liip/multiplex-bundle
LiipMultiplexBundle lets a Symfony project serve multiple applications or websites from one codebase, routing requests by host or configuration so each app can keep separate settings and templates while sharing common logic.
First, the honest framing: you cannot install this. liip/multiplex-bundle is a Symfony2 bundle and the repository is archived. composer require liip/multiplex-bundle will fail against any modern illuminate/* component because the bundle pins symfony/symfony to the 2.x range. Forcing it with --ignore-platform-reqs gets you a bundle that Laravel's kernel will never boot. So "getting started" here means reading it as a reference implementation and building the two useful ideas natively. Budget 1–2 days.
Where to look first (in order):
DependencyInjection/ extension and Resources/config/ — this shows how kernels were registered and selected. Skim only.kernel.request event listener — this is the actual multiplexer entry point. It parses the request payload into sub-request descriptors and dispatches them.HttpKernelInterface::handle() — this is the ~100 lines worth copying conceptually.JsonResource / Responsable, so read it for the envelope shape and move on.Minimal viable Laravel equivalent — your first use case. A dashboard that renders 8–10 widgets, each backed by its own endpoint, currently firing 10 requests per screen load. Replace with one batched call. Start with a route and a FormRequest that validates an array of descriptors:
// routes/api.php
Route::post('/v1/batch', BatchController::class)->middleware('auth:sanctum');
// BatchRequest
public function rules():array {
return [
'operations' => ['required','array','max:10'],
'operations.*.id' => ['required','string','distinct'],
'operations.*.method' => ['required','in:GET,POST'],
'operations.*.path' => ['required','string','starts_with:/api/'],
'operations.*.body' => ['array'],
];
}
First use case to ship: internal-only aggregation. Do not start with external fan-out — internal sub-requests against your own routes are cheap and safe; external fan-out drags timeouts, retries, and circuit breakers into a single request and is a much bigger design exercise. Prove the round-trip win first.
Pattern 1 — external fan-out with Http::pool(). The common case: the aggregator calls other services or your own HTTP surface concurrently.
$responses = Http::pool(function (Pool $pool) use ($ops, $baseUrl) {
return collect($ops)->map(fn ($op) => $pool
->timeout(2)
->withHeaders(['X-Request-Id' => (string) Str::uuid()])
->{strtolower($op['method'])}($baseUrl.$op['path'], $op['body'] ?? [])
->as($op['id'])
)->all();
});
Http::pool() returns per-request responses including connection failures, which is exactly what you need for partial-failure semantics. Do not let a single failed sub-request throw and discard the rest.
Pattern 2 — the response envelope. This is the part the old bundle got right and the part you must copy. Every sub-result carries its own status:
return response()->json([
'results' => collect($ops)->map(fn ($op) => [
'id' => $op['id'],
'status' => $responses[$op['id']]->status(),
'ok' => $responses[$op['id']]->successful(),
'body' => $responses[$op['id']]->successful()
? $responses[$op['id']]->json()
: ['error' => $responses[$op['id']]->reason()],
])->values(),
], 207); // 207 Multi-Status — a 200 with silent nulls is an incident waiting to happen
The top-level HTTP status should be 207 for partial success, 200 for all-success, 502/500 only when everything failed. Never return 200 with null bodies.
Pattern 3 — internal dispatch when you must hit your own controllers. Laravel was not designed for re-entrant kernel handling; nested app(Kernel::class)->handle() calls multiply middleware execution and memory per inbound request. Prefer restructuring the target actions into service classes and calling them directly. If you truly need sub-requests:
$request = Request::create($op['path'], $op['method'], $op['body'] ?? []);
$request->headers->set('Authorization', $original->header('Authorization')); // propagate auth explicitly
$response = app(\Illuminate\Contracts\Http\Kernel::class)->handle($request);
Guard this behind a hard cap (5–10 sub-requests) and a wall-clock budget.
Pattern 4 — uniform JSON-ification. The bundle's second idea. Laravel-native equivalent is a Responsable or a JsonResource wrapper, plus content negotiation via Accept header middleware. Do not build a listener that sniffs return types; JsonResource already does this cleanly and is testable.
Pattern 5 — allowlist, not arbitrary URIs. The client must never specify a raw URL. Accept an operation name and resolve it server-side:
// config/batch.php
return ['operations' => [
'orders.recent' => ['method' => 'GET', 'path' => '/api/orders?limit=5'],
'profile.summary'=> ['method' => 'GET', 'path' => '/api/me/summary'],
]];
This kills SSRF, closes the endpoint to third-party abuse, and keeps the contract stable when internal paths change.
Integration tips:
id, never array position. Clients reorder the operations array; matching by index is a subtle class of bug.Composer and platform traps.
composer require fails on constraint resolution against Symfony 6/7. Do not "fix" this by widening constraints or vendoring the bundle — you will be maintaining a permanent internal fork of dead code.Security and maintenance — the reason this is a reference only.
Failure-mode design — decide these before writing a line.
->timeout(2) per request and a bounded aggregate deadline (e.g. Http::pool overall cap) are mandatory, not optional.POST from the contract entirely until you have idempotency keys.Scaling — multiplexing shifts load, it does not remove it. A batch endpoint pins N downstream calls to a single PHP worker. A handful of concurrent batch clients can saturate both your PHP-FPM pool and your downstream services simultaneously. Set Http::pool concurrency limits (e.g. ->concurrency(5) on the pool) and consider queuing genuinely slow fan-outs rather than holding the worker.
Testing. Http::fake() gives you deterministic coverage of per-item success, failure, and timeout without touching the network:
Http::fake([
'*/api/orders*' => Http::response(['orders' => []], 200),
'*/api/me/*' => Http::response(null, 503),
]);
Assert the 207, the per-item status, and that the failed item carries a machine-readable error rather than a null.
Extension points worth borrowing from the original: the descriptor schema (id, method, path, body) and the per-item response envelope. Both are small, well-shaped, and version them explicitly (/api/v1/batch) so you can evolve them without breaking clients. Everything else — kernel selection, DI wiring, JSON coercion — is either obsolete or already first-class in Laravel; do not reimplement it.
When to reach for something else instead. If the set of screen compositions changes frequently, a hardcoded multiplexer route bakes each composition into server code and becomes a bottleneck. GraphQL (Lighthouse) or letting the client issue parallel calls over HTTP/2 solves the round-trip problem without server-side orchestration. If you only need to standardize JSON responses, that has nothing to do with multiplexing — use JsonResource and stop.
How can I help you explore Laravel packages today?