Laravel Concurrency Facade and Process Pools for Parallel Work
#laravel #concurrency #process-pools #performance

Laravel Concurrency Facade and Process Pools for Parallel Work

4 min read Mohamed Said Mohamed Said

Why Parallelism Matters in a Synchronous PHP World

PHP runs one request at a time per worker, but that doesn't mean every task inside a request must be sequential. Laravel ships two distinct primitives for parallelism: the Concurrency facade (introduced in Laravel 11) and the Process facade's pool API. Knowing which to reach for — and where each one breaks — separates a senior engineer from someone who just throws everything onto a queue.


The Concurrency Facade

The Concurrency facade executes an array of closures in parallel by forking child processes under the hood (via spatie/fork or the built-in driver). Each closure runs in an isolated process, so there is no shared memory between them.

use Illuminate\Support\Facades\Concurrency;

[$orders, $inventory, $pricing] = Concurrency::run([
    fn () => Order::where('user_id', $userId)->count(),
    fn () => Inventory::lowStock()->get(),
    fn () => PricingService::currentRates(),
]);

The return value is an array of results in the same order as the input. Exceptions in any child bubble up as a Throwable on the corresponding index — wrap individual closures if you need fault isolation.

Choosing a Driver

Laravel ships two drivers:

  • fork — uses pcntl_fork. Fast, zero overhead, but unavailable on Windows and inside FrankenPHP workers where forking is unsafe.
  • process — spawns a fresh php artisan subprocess per closure. Slower startup, but works everywhere and respects .env bootstrapping cleanly.

Set the driver in config/concurrency.php or per-call:

Concurrency::driver('process')->run([...]);

What Concurrency Is Not

The Concurrency facade is not a queue. It blocks the current request until all children finish. Use it for fan-out reads that would otherwise be sequential — not for fire-and-forget work or tasks that take more than a few seconds.


Process Pools for Heavier Parallelism

When you need to run shell commands, external scripts, or long-running subprocesses in parallel, reach for Process::pool().

use Illuminate\Support\Facades\Process;

$results = Process::pool(function ($pool) {
    $pool->command('php artisan import:chunk --start=0 --end=5000');
    $pool->command('php artisan import:chunk --start=5000 --end=10000');
    $pool->command('php artisan import:chunk --start=10000 --end=15000');
})->start()->wait();

foreach ($results as $result) {
    if ($result->failed()) {
        logger()->error($result->errorOutput());
    }
}

Each pool entry is a fully independent OS process. You get stdout, stderr, and exit codes per process. This is ideal for data pipeline stages, asset compilation steps, or any work that maps cleanly onto CLI commands.

Concurrency vs Process Pool — Decision Matrix

| Concern | Concurrency::run() | Process::pool() | |---|---|---| | PHP closures | ✅ | ❌ | | Shell commands | ❌ | ✅ | | Shared Eloquent models | ❌ (isolated) | ❌ (isolated) | | Return values | ✅ native PHP | stdout only | | Timeout control | Via driver config | Per-process |


Avoiding Shared-State Bugs

Both primitives fork or spawn new processes, so database connections, Redis connections, and in-memory singletons are not shared. Each child re-bootstraps the application. This means:

  • Open transactions in the parent are invisible to children.
  • Singleton state (e.g., a tenant resolver) must be re-hydrated inside the closure.
  • File handles opened before the fork are duplicated — close them before forking.
// ❌ Wrong: connection opened before fork may corrupt state
$conn = DB::connection();
Concurrency::run([
    fn () => $conn->table('orders')->count(), // unsafe
]);

// ✅ Correct: let each child open its own connection
Concurrency::run([
    fn () => DB::table('orders')->count(),
]);

Practical Pattern: Parallel Dashboard Aggregates

A common use case is a dashboard endpoint that needs five independent aggregate queries. Without concurrency, these run sequentially. With Concurrency::run(), they fan out and the total latency is roughly the slowest single query.

[$revenue, $signups, $churn, $mrr, $tickets] = Concurrency::run([
    fn () => RevenueMetric::thisMonth(),
    fn () => SignupMetric::thisMonth(),
    fn () => ChurnMetric::thisMonth(),
    fn () => MrrMetric::current(),
    fn () => SupportTicket::open()->count(),
]);

This pattern is clean, readable, and requires zero queue infrastructure.


Key Takeaways

  • Use Concurrency::run() for fan-out PHP closures that block until all results are ready.
  • Use Process::pool() for parallel shell commands where you need stdout/stderr.
  • Neither is a queue replacement — both block the calling process.
  • Always let children open their own DB/Redis connections; never share handles across a fork.
  • Prefer the process driver in environments where pcntl_fork is unavailable or unsafe.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does the Concurrency facade work with Laravel Octane?
The `fork` driver is unsafe inside Octane workers (Swoole/RoadRunner) because forking a long-lived worker process can corrupt shared state. Use the `process` driver instead, which spawns a fresh subprocess and is safe in any environment.
Q02 How do I handle exceptions thrown inside a Concurrency closure?
If a child closure throws, `Concurrency::run()` re-throws the exception in the parent. Wrap individual closures in try/catch and return a Result object if you need per-task fault tolerance rather than an all-or-nothing failure.
Q03 When should I use Process::pool() instead of dispatching multiple queued jobs?
Process pools are best for synchronous, bounded work where you need results immediately and the total runtime is short (seconds, not minutes). Queued jobs are better for fire-and-forget work, retries, prioritisation, and tasks that may take arbitrarily long.

Continue reading

More Articles

View all