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
processdriver on Windows or inside Swoole workers to avoidpcntlconflicts. - Return values are serialized — keep them small and serializable.