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— usespcntl_fork. Fast, zero overhead, but unavailable on Windows and inside FrankenPHP workers where forking is unsafe.process— spawns a freshphp artisansubprocess per closure. Slower startup, but works everywhere and respects.envbootstrapping 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
processdriver in environments wherepcntl_forkis unavailable or unsafe.