Skip to content

Laravel Concurrency Facade and Process Pools for Parallel Work

Laravel's Concurrency facade and process pools let you run independent tasks in parallel without reaching for a queue. Learn when to use each, how to avoid pitfalls, and how to structure real workloads.

Published · Updated · 3 min read

Laravel Concurrency Facade and Process Pools for Parallel Work

Running Tasks in Parallel with Laravel's Concurrency Facade

Laravel ships a Concurrency facade that lets you execute multiple closures in parallel without spinning up a queue worker. Under the hood it forks the current PHP process (using the fork driver) or spawns isolated sub-processes (the process driver). Knowing which driver fits your workload is the first decision you need to make.

The Two Drivers

| Driver | Mechanism | Shared memory | Best for | |--------|-----------|---------------|----------| | fork | pcntl_fork | No — copy-on-write | CPU-bound, short-lived | | process | symfony/process | No — separate PHP process | I/O-bound, DB calls, HTTP |

The fork driver is faster to spin up but is unavailable on Windows and inside FrankenPHP workers (where forking is unsafe). The process driver is portable and safer for anything that touches a database connection, because each child gets its own bootstrapped application.

Basic Usage

use Illuminate\Support\Facades\Concurrency;

[$users, $orders, $metrics] = Concurrency::run([
    fn () => User::whereActive()->count(),
    fn () => Order::wherePending()->sum('total'),
    fn () => Cache::get('dashboard.metrics'),
]);

run() blocks until every closure finishes and returns results in the same order. If any closure throws, the exception is re-thrown in the parent after all tasks complete.

Choosing the Driver Explicitly

// Force the process driver for DB-heavy work
$results = Concurrency::driver('process')->run([
    fn () => DB::table('events')->where('type', 'click')->count(),
    fn () => DB::table('events')->where('type', 'view')->count(),
]);

Always use driver('process') when closures open Eloquent connections. The fork driver inherits the parent's open PDO handles, and sharing a socket across processes causes protocol corruption.

Process Pools for Fan-Out Work

When you have a variable-length list of tasks, reach for Pool directly:

use Illuminate\Process\Pool;
use Illuminate\Support\Facades\Process;

$results = Process::pool(function (Pool $pool) use ($tenantIds) {
    foreach ($tenantIds as $id) {
        $pool->command("php artisan tenants:sync {$id}");
    }
})->start()->wait();

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

Process pools are ideal for running Artisan commands or shell scripts in parallel. Each process is fully isolated — no shared state, no memory leaks back to the parent.

Concurrency Limits

Unbounded parallelism will exhaust file descriptors and memory. Chunk your work:

$chunks = collect($tenantIds)->chunk(10);

foreach ($chunks as $chunk) {
    Concurrency::driver('process')->run(
        $chunk->map(fn ($id) => fn () => app(TenantSyncAction::class)->execute($id))->all()
    );
}

This keeps at most 10 processes alive at once, which is a safe ceiling for most VPS deployments.

What Not to Parallelize

  • Tasks that share a writable resource (same cache key, same file) without a lock.
  • Tasks shorter than ~5 ms — process spawn overhead will dominate.
  • Tasks inside Octane workers using the fork driver — Octane's persistent state makes forking dangerous.

Testing Parallel Code

The Concurrency facade is fakeable:

Concurrency::fake();

// Closures run sequentially in the test process
$results = Concurrency::run([
    fn () => 'a',
    fn () => 'b',
]);

expect($results)->toBe(['a', 'b']);

Faking forces sequential execution, which makes assertions deterministic and keeps your CI pipeline single-process.

Takeaways

  • Use Concurrency::run() for a fixed set of independent closures; use Process::pool() for dynamic fan-out.
  • Always pick driver('process') when closures touch the database.
  • Chunk large workloads to cap concurrency and prevent resource exhaustion.
  • Fake the facade in tests to keep assertions deterministic.
  • Avoid fork inside Octane or FrankenPHP workers.

Frequently asked questions

Can I use the Concurrency facade inside a queued job?

Yes, but prefer the `process` driver. Queue workers are long-lived processes, and forking inside them can cause unexpected behavior with open connections. The `process` driver spawns a completely fresh PHP process, which is safe regardless of the parent's state.

How does error handling work when one concurrent task fails?

With `Concurrency::run()`, all tasks run to completion before any exception is surfaced. The first exception encountered is re-thrown in the calling process. If you need per-task error handling, wrap each closure in a try/catch and return a result object instead of throwing.

Is there a performance benefit over queues for short-lived parallel work?

Yes. Queues introduce serialization, network round-trips to Redis or SQS, and worker polling latency. For work that must complete within the current request or command and takes under a few seconds total, the Concurrency facade is significantly lower overhead.

Mohamed Said

About the author

Senior Backend Engineer specializing in Laravel, scalable SaaS platforms, APIs, and cloud infrastructure. I build secure, high-performance web applications that help businesses grow.

About GitHub LinkedIn WhatsApp Email Address My CV

Have a technical challenge?

Tell me what you’re building. I reply within two working days.

Discuss your project