- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You have a dashboard endpoint. It fetches the user, their recent orders, a recommendation blob from a slow internal service, and a billing summary from Stripe. Four calls, each around 300ms. Run them one after another and the response takes 1.2 seconds. None of the four depend on each other. You're paying for latency you don't owe.
The old answers were both awkward. Dispatch four jobs and poll for the result? Now you've turned a synchronous read into an async dance with a status endpoint. Pull in Guzzle promises or ReactPHP? A dependency and a mental model just to parallelize four reads.
Laravel 11.23 shipped a third option: the Concurrency facade. Concurrency::run() takes an array of closures, runs them in parallel, and hands back the results inline. It fits in a narrow lane. Outside that lane a queue job is still the right call, and one fork caveat will corrupt an HTTP request if you ignore it.
What Concurrency::run actually does
The shape is small. You pass closures, you get results back in the same order or under the same keys:
use Illuminate\Support\Facades\Concurrency;
[$user, $orders, $recs, $billing] = Concurrency::run([
fn () => User::findOrFail($id)->toArray(),
fn () => Order::forUser($id)->latest()->limit(10)->get()->toArray(),
fn () => app(RecommendationClient::class)->for($id),
fn () => app(BillingClient::class)->summary($id),
]);
The call blocks until all four finish. Total wall time is roughly the slowest closure plus spawn overhead, not the sum. Keyed input works too, and reads cleaner when there are more than a couple:
$data = Concurrency::run([
'user' => fn () => User::findOrFail($id)->toArray(),
'orders' => fn () => Order::forUser($id)->get()->toArray(),
'billing' => fn () => app(BillingClient::class)->summary($id),
]);
return view('dashboard', $data);
Under the hood the default process driver serializes each closure with laravel/serializable-closure, shells out to php artisan invoke-serialized-closure once per task, runs them as concurrent OS processes, then serializes each return value back to the parent. That design decides everything else about how you use it.
The serialization rule you can't skip
Each closure runs in a fresh process. Nothing is shared. That has two consequences you have to design around.
First, the closure and its captured variables must serialize. Capture a scalar, an array, an ID — fine. Capture a live PDO handle, an open socket, or a service object holding one, and the serializer throws before anything runs. Keep closures self-contained: pass IDs in, resolve services inside with app().
Second, the return value must serialize too. Return arrays and scalars. You can return an Eloquent model, but it gets serialized across the process boundary and rehydrated in the parent, which is wasted work. ->toArray() at the edge of the closure is cheaper and says what you mean.
Because the child is a fresh boot, the database connection is re-established there. The query inside the closure runs against a clean connection, not the parent's. No shared transaction, no shared state. That is exactly what you want for parallel reads and exactly what makes writes inside these closures a bad idea.
When it beats dispatching a job
The two tools solve different problems, and the split is clean.
Reach for Concurrency::run() when you need the results inside the current request or command to aggregate them. The dashboard above is the canonical case: you're fanning out reads and stitching the answers into one response. A queue job can't do that without a second round trip.
Reach for a queue job when the work is fire-and-forget, needs retries, needs durability, or runs at volume. Sending 50,000 emails is a queue's job. If the box dies mid-run, the queue picks up where it left off. Concurrency::run() has no retries and no durability. If the parent process dies, every in-flight task dies with it and you get nothing.
There's also an overhead floor. The process driver boots a fresh framework per task. For trivial work (summing an array, formatting a date) spawning a process is slower than doing it in a loop. The facade earns its cost when each task is genuinely slow and IO-bound: an external HTTP call, a heavy aggregate query, a report render. A handful of 300ms calls collapsing to 300ms total is the win. Ten 2ms calls will run slower in parallel than in sequence.
Process limits: there is no pool
This is the sharp edge nobody warns you about. Concurrency::run() spawns one process per closure, all at once. Pass it 200 closures and it tries to start 200 PHP processes at the same time. On a 4-core box that's not concurrency, it's a fork bomb. Load spikes, memory blows past the limit, the OOM killer starts making decisions for you.
There is no built-in cap. You have to chunk the work yourself:
$tasks = collect($productIds)->map(
fn (int $productId) => fn () => app(PriceClient::class)
->quote($productId),
)->all();
$quotes = [];
foreach (array_chunk($tasks, 8) as $batch) {
$quotes = array_merge($quotes, Concurrency::run($batch));
}
Eight at a time keeps you inside your core count and memory budget while still cutting total time by most of the parallel factor. Pick the chunk size from the box: start near your CPU core count for CPU-bound work, and you can go a bit higher for IO-bound work since those processes spend most of their life waiting. Treat any number over a couple dozen concurrent as a config value you tune, not a constant you hardcode.
Error handling: one throw takes the whole run down
If any closure throws, the exception propagates out of Concurrency::run() and you lose the results of the tasks that succeeded. There is no partial-results mode. For fan-out where one slow third party might time out while the other three are fine, that all-or-nothing behavior is rarely what you want.
The fix is to make each closure return a result envelope instead of throwing:
$results = Concurrency::run(
collect($endpoints)->map(fn (string $url) => function () use ($url) {
try {
return [
'ok' => true,
'body' => Http::timeout(5)->get($url)->json(),
];
} catch (\Throwable $e) {
return ['ok' => false, 'error' => $e->getMessage()];
}
})->all()
);
$good = array_filter($results, fn ($r) => $r['ok']);
Now a single timeout costs you one entry, not the whole batch. The run always completes, and you decide per task what a failure means. This is the pattern to default to any time the closures touch a network.
defer() for work the response doesn't wait on
The facade has a second method for the opposite need. Concurrency::defer() queues closures to run after the HTTP response is sent, in the background of the same request lifecycle:
Concurrency::defer([
fn () => app(MetricsClient::class)->flush($requestId),
fn () => app(AuditLog::class)->record($event),
]);
return response()->json(['status' => 'ok']);
The user gets their response immediately; the metrics flush and audit write happen afterward. It's the middle ground between blocking on run() and standing up a real queue for work that isn't worth a job but shouldn't slow the response. No result comes back, which is the point.
The fork caveat
There are three drivers: process (the safe default), sync (runs everything serially, for tests), and fork. The fork driver needs spatie/fork installed and uses pcntl_fork to clone the current process instead of booting fresh ones. Skipping the bootstrap makes it faster, so it's tempting to switch to it everywhere.
Do not use fork inside an HTTP request.
Forking a running web worker duplicates everything the request is holding: the open database connection, the buffered response, any socket mid-write. Two processes now believe they own the same connection. You get interleaved query results, corrupted output, connections closed out from under a live statement. The damage is the kind that looks like a flaky bug for weeks.
The fork driver belongs in CLI contexts only: Artisan commands, the scheduler, a queue worker running a heavy aggregation. Those are single-purpose processes with no live HTTP state to corrupt. In a web request, stay on the process driver. It costs a framework boot per task, and that boot is the price of not sharing a connection you can't safely share.
// config/concurrency.php
return [
'default' => env('CONCURRENCY_DRIVER', 'process'),
];
Leave the default as process. Override to fork per call site in a console command if you've measured that the boot overhead matters, and never through an env var that an HTTP request might read.
Where the line sits
Concurrency::run() is for synchronous fan-out where you need the answers now: a few slow, independent, IO-bound reads collapsed into one wait. Chunk it so you don't spawn a process storm, wrap each closure so one failure doesn't sink the batch, keep fork out of HTTP, and reach for a real queue the moment the work needs durability, retries, or volume. Inside that fence, it removes a whole category of "do I really need a job for this" friction.
Notice where the parallelism lives in the good version: at the edge, in the controller or command orchestrating the calls, never inside the thing being called. Each closure resolves its own client, does one job, and returns a plain array. That's the same discipline clean and hexagonal architecture asks for everywhere else — the domain operation doesn't know it's being run in parallel, and the concurrency mechanism doesn't know what the operation does. Decoupled PHP is about keeping that seam sharp, so the day you swap process for fork, or the facade for a queue, only the edge changes and the work underneath stays untouched.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)