Laravel Concurrency Facade and Process Pools for Parallel Work
#laravel #concurrency #performance #php

Laravel Concurrency Facade and Process Pools for Parallel Work

4 min read Mohamed Said Mohamed Said

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; pick fork for isolation, fiber for 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.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Can I use the Concurrency facade inside a Livewire component or Octane request?
Use the fiber driver in Octane contexts. The fork driver forks the entire worker process, which corrupts Octane's long-lived state. Set CONCURRENCY_DRIVER=fiber in your Octane environment, and note that fiber concurrency is cooperative, not truly parallel.
Q02 How does Concurrency::run() differ from dispatching multiple queued jobs?
Concurrency::run() blocks until all tasks complete and returns results inline — no queue infrastructure needed. Queued jobs are fire-and-forget with retry logic and Horizon visibility. Use Concurrency for latency-sensitive, in-request parallelism; use queues for durable, retriable background work.
Q03 Is Process::pool() suitable for production use or just local tooling?
It is production-ready. Laravel's Process facade wraps Symfony Process, which is battle-tested. Process pools are commonly used in deployment scripts, data pipeline commands, and scheduled tasks that benefit from parallelism without queue overhead.

Continue reading

More Articles

View all