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
forkdriver — 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; useProcess::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
forkinside Octane or FrankenPHP workers.