Why Parallel Execution Matters in a Synchronous Framework
Laravel's default request lifecycle is synchronous. When you need to call three external APIs before returning a response, you pay the latency of each call sequentially. The Concurrency facade (introduced in Laravel 11) and the Process facade's pool API give you two distinct escape hatches — without reaching for a queue or a dedicated worker.
Understanding when to use each is the real skill.
The Concurrency Facade
Illuminate\Support\Facades\Concurrency runs closures in parallel using PHP's fork driver (via ext-pcntl) or a fiber driver. The fork driver is the default in CLI contexts; the fiber driver is safer in long-lived Octane workers.
use Illuminate\Support\Facades\Concurrency;
[$users, $orders, $metrics] = Concurrency::run([
fn () => User::active()->count(),
fn () => Order::pendingShipment()->count(),
fn () => app(MetricsService::class)->summary(),
]);
Each closure runs in isolation. With the fork driver, each closure executes in a child process — no shared memory, no shared Eloquent connections. Results are serialized back to the parent via a pipe.
Choosing the Right Driver
// config/concurrency.php (publish with: php artisan vendor:publish)
'default' => env('CONCURRENCY_DRIVER', 'fork'),
| Driver | Safe in Octane | Shared DB connection | Overhead |
|--------|---------------|----------------------|----------|
| fork | ⚠️ No (forks worker) | No — child gets copy | ~5–15 ms per fork |
| fiber | ✅ Yes | Yes — same process | Minimal |
The fiber driver is cooperative, not truly parallel. Use it when tasks are I/O-bound and you control the event loop (e.g., with ReactPHP or Swoole). For CPU-bound or truly independent work, fork wins.
Deferring Work with Concurrency::defer()
Concurrency::defer([
fn () => Cache::put('report:daily', $this->buildReport(), 3600),
fn () => $this->notifySlack(),
]);
// Returns immediately; tasks run after the response is sent (fork driver only)
This is a lightweight alternative to dispatching a job when you don't need retry logic.
Process Pools for Shell-Level Parallelism
When the work lives outside PHP — running shell commands, calling CLIs, spawning scripts — the Process facade's pool API is the right tool.
use Illuminate\Support\Facades\Process;
$pool = Process::pool(function ($pool) {
$pool->command('php artisan export:region --region=eu');
$pool->command('php artisan export:region --region=us');
$pool->command('php artisan export:region --region=apac');
})->start(function ($type, $output, $key) {
logger()->info("Process {$key}: {$output}");
});
$results = $pool->wait();
foreach ($results as $key => $result) {
if ($result->failed()) {
throw new \RuntimeException("Export {$key} failed: " . $result->errorOutput());
}
}
Process pools are ideal for:
- Running multiple Artisan commands in parallel during a deployment step
- Parallel data exports or imports
- Shelling out to Python/Node scripts alongside PHP work
Timeout and Error Handling
$pool = Process::pool(function ($pool) {
$pool->timeout(30)->command('vendor/bin/phpstan analyse src/');
$pool->timeout(30)->command('vendor/bin/pest --parallel');
})->start();
$results = $pool->wait();
Each process in the pool gets its own timeout. A single failure does not kill the pool — inspect each $result individually.
Common Pitfalls
1. Database connections in forked processes. The fork driver copies the parent's file descriptors. Reconnect inside the closure or use DB::purge() at the top of each child.
Concurrency::run([
function () {
DB::reconnect(); // safe in forked child
return Order::count();
},
]);
2. Serialization limits. Closures passed to Concurrency::run() are serialized with opis/closure. Avoid binding large objects; pass IDs and re-query inside.
3. Octane state leakage. Never use the fork driver inside an Octane worker. The forked child inherits the entire worker state. Use the fiber driver or dispatch a job instead.
Key Takeaways
- Use
Concurrency::run()for parallel PHP closures; pickforkfor isolation,fiberfor Octane safety. - Use
Process::pool()when the parallel work is shell commands or external processes. - Always reconnect database handles in forked children.
- Avoid passing large objects into closures — pass IDs and hydrate inside.
Concurrency::defer()is a zero-queue fire-and-forget for post-response work.