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

Laravel Concurrency Facade and Process Pools for Parallel Work

3 min read Mohamed Said Mohamed Said

Running Independent Work in Parallel with Laravel's Concurrency Facade

Most Laravel applications are embarrassingly sequential: fetch a remote API, transform the result, hit another API, aggregate. Each step blocks the next. The Concurrency facade (introduced in Laravel 11) and the lower-level Process facade give you two complementary tools to break that pattern without spinning up a queue.

When to Reach for Concurrency vs. Queues

Queues are the right tool when work is fire-and-forget, retryable, or needs to survive a process restart. The Concurrency facade is the right tool when:

  • You need the results now, in the same request or command.
  • Tasks are independent (no shared mutable state).
  • Total wall-clock time matters more than throughput.

The Concurrency Facade

use Illuminate\Support\Facades\Concurrency;

[$orders, $inventory, $pricing] = Concurrency::run([
    fn () => Order::forUser($userId)->latestSummary(),
    fn () => Inventory::forUser($userId)->available(),
    fn () => Pricing::forUser($userId)->current(),
]);

Each closure runs in a forked child process (via the fork driver on Linux/macOS) or a separate PHP process (via the process driver on Windows or when pcntl is unavailable). Results are serialized back to the parent automatically.

The driver is resolved from config/concurrency.php:

// config/concurrency.php
return [
    'default' => env('CONCURRENCY_DRIVER', 'fork'),
];

Switch to process in environments where pcntl is absent:

CONCURRENCY_DRIVER=process

Deferring Non-Critical Work

Concurrency::defer() schedules closures to run after the response is sent, similar to response()->then() but composable:

Concurrency::defer([
    fn () => Cache::tags('reports')->flush(),
    fn () => AuditLog::record($event),
]);

This is useful for cache warming or audit writes that must not delay the response.

Process Pools for Fine-Grained Control

When you need more control — custom environment variables, timeouts, or streaming output — the Process facade's pool API is the right abstraction:

use Illuminate\Support\Facades\Process;

$results = Process::pool(function ($pool) {
    $pool->command('php artisan report:generate --type=sales');
    $pool->command('php artisan report:generate --type=inventory');
    $pool->command('php artisan report:generate --type=returns');
})->start()->wait();

foreach ($results as $result) {
    throw_if($result->failed(), RuntimeException::class, $result->errorOutput());
    logger()->info($result->output());
}

Each pool entry is a real OS process. You get output(), errorOutput(), exitCode(), and failed() on every result.

Timeout and Error Handling

Both APIs respect timeouts. With Concurrency::run() the timeout is per-closure:

Concurrency::run([
    fn () => slowExternalApi(),
], timeout: 5); // seconds

With Process::pool() set it per command:

$pool->command('php artisan heavy:task')->timeout(30);

Always handle failures explicitly. Unhandled exceptions in forked closures are re-thrown in the parent after all tasks complete.

Practical Gotchas

Database connections are not inherited. Each forked process must reconnect. Laravel handles this automatically for Eloquent calls inside closures, but if you pass an open PDO handle explicitly you will get a broken pipe.

Avoid shared mutable state. Closures capture variables by value after forking. Writes inside a closure do not propagate back to the parent scope — only the return value does.

Octane users: Under Octane the worker process is long-lived. pcntl_fork() inside a Swoole coroutine is unsafe. Use the process driver or avoid Concurrency inside Swoole coroutine context entirely.

Takeaways

  • Use Concurrency::run() when you need parallel results synchronously in the same request.
  • Use Concurrency::defer() for fire-and-forget work that should not block the response.
  • Use Process::pool() when you need OS-level process control, streaming output, or shell commands.
  • Always set timeouts; never assume external work completes in bounded time.
  • Switch to the process driver on Windows or inside Swoole workers to avoid pcntl conflicts.
  • Return values are serialized — keep them small and serializable.

Found this useful?

Frequently Asked Questions

3 questions
Q01 Does the Concurrency facade work on shared hosting or Windows?
The default `fork` driver requires `pcntl`, which is unavailable on Windows and many shared hosts. Set `CONCURRENCY_DRIVER=process` to fall back to spawning separate PHP processes, which works everywhere PHP CLI is available.
Q02 Can I share an Eloquent model instance between Concurrency closures?
No. After forking, each child has its own memory space. Pass primitive identifiers (IDs, arrays) into closures and re-query inside them. The database connection is re-established automatically per child process.
Q03 What is the difference between Concurrency::run() and Process::pool()?
`Concurrency::run()` executes PHP closures in parallel and returns their return values. `Process::pool()` runs arbitrary shell commands as OS processes and gives you stdout, stderr, and exit codes. Use closures for PHP logic, pools for CLI commands or external binaries.

Continue reading

More Articles

View all