Why Concurrency Matters in a Synchronous Framework
Laravel is built on a synchronous request/response cycle. That works beautifully for most CRUD work, but it becomes a bottleneck the moment you need to call three external APIs, crunch two independent data sets, or generate several reports before returning a response. The naive solution is to fire jobs and poll — but that adds latency, infrastructure, and complexity.
Laravel 11 shipped a first-class answer: the Concurrency facade backed by a fork-based driver, and the mature Process facade with pool support. Together they let you express parallel work as plain PHP closures and collect the results synchronously.
The Concurrency Facade
The Concurrency facade (available since Laravel 11.x) forks the current PHP process for each task, runs them in parallel, and merges the results.
use Illuminate\Support\Facades\Concurrency;
[$users, $revenue, $churn] = Concurrency::run([
fn () => User::query()->count(),
fn () => Order::query()->sum('total'),
fn () => $this->churnService->rate(),
]);
Each closure runs in its own forked child process. The parent blocks until all children finish, then returns an array of results in the same order as the input.
Handling Failures Gracefully
If a child throws, the exception is serialized back to the parent and re-thrown. Wrap individual tasks when partial failure is acceptable:
$results = Concurrency::run([
fn () => $this->fetchFromServiceA(),
function () {
try {
return $this->fetchFromServiceB();
} catch (\Throwable $e) {
return null; // degrade gracefully
}
},
]);
Driver Awareness
The default driver uses pcntl_fork. On environments without pcntl (Windows, some shared hosts) Laravel falls back to a synchronous driver — tasks run sequentially. Always verify extension_loaded('pcntl') in your production environment.
// config/concurrency.php (published via artisan vendor:publish)
'default' => env('CONCURRENCY_DRIVER', 'fork'),
Process Pools for External Commands
When the parallel work involves shelling out — running ffmpeg, a Python script, or a CLI tool — the Process facade's pool API is the right tool.
use Illuminate\Support\Facades\Process;
$pool = Process::pool(function ($pool) use ($files) {
foreach ($files as $file) {
$pool->command("ffmpeg -i {$file} -vn output/{$file}.mp3");
}
})->start();
$results = $pool->wait();
foreach ($results as $result) {
if ($result->failed()) {
logger()->error($result->errorOutput());
}
}
start() launches all processes immediately. wait() blocks until every process exits and returns a ProcessResultCollection.
Limiting Concurrency
Unbounded pools will exhaust file descriptors and CPU. Use a chunked approach:
foreach (array_chunk($files, 5) as $batch) {
Process::pool(function ($pool) use ($batch) {
foreach ($batch as $file) {
$pool->command("convert {$file}");
}
})->start()->wait();
}
Concurrency vs. Queues: When to Choose What
| Scenario | Best tool |
|---|---|
| Parallel API calls in a request | Concurrency::run() |
| CPU-bound shell commands | Process::pool() |
| Work that can be deferred | Queue jobs |
| Long-running or retryable work | Horizon + jobs |
The Concurrency facade is synchronous from the caller's perspective — the HTTP response waits. Use it only when the combined parallel time is shorter than sequential time and the total wall-clock time is still acceptable for a web request (typically under 10 seconds).
Production Checklist
- Confirm
pcntlis enabled on every app server and in your Docker image. - Child processes inherit the parent's database connections — connections are not automatically re-established. Call
DB::reconnect()at the top of each closure if you query the database inside a forked task. - Memory is copied on fork (copy-on-write). Large in-memory collections before the fork inflate each child's footprint.
- Set
max_execution_timeand process timeouts defensively to avoid zombie processes.
Key Takeaways
Concurrency::run()forks PHP processes for parallel closure execution and returns results in order.Process::pool()runs external commands in parallel;wait()collects all results.- Both tools are synchronous from the caller's view — they block until all work completes.
- Re-establish database connections inside forked closures to avoid shared-socket corruption.
- Fall back to queues for work that is deferrable, long-running, or needs retry semantics.