Laravel Concurrency Facade and Process Pools for Parallel Work
#laravel #concurrency #performance #php

Laravel Concurrency Facade and Process Pools for Parallel Work

4 min read Mohamed Said Mohamed Said

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 pcntl is 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_time and 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does the Concurrency facade share the database connection with child processes?
Yes — forked children inherit the parent's open sockets. You must call DB::reconnect() (and Redis::reconnect() if applicable) at the start of each closure to get a fresh connection, otherwise you risk corrupted responses from a shared socket.
Q02 Can I use the Concurrency facade on Laravel Octane?
With caution. Octane workers are long-lived, and forking inside a persistent worker can cause unexpected state inheritance. Test thoroughly and prefer the queue-based approach for Octane deployments unless you fully control what state exists before the fork.
Q03 What happens if one task in Concurrency::run() takes much longer than the others?
The parent waits for all children to finish, so the total wall-clock time equals the slowest task. There is no built-in timeout per task in the fork driver; wrap long-running closures with your own alarm signal or pcntl_alarm if you need a hard deadline.

Continue reading

More Articles

View all