Run Independent Work in Parallel with Laravel's Concurrency Facade

The Laravel Concurrency facade runs independent closures in parallel child processes. Learn run(), defer(), the fork driver, and when to use it over a queue.

Steven Richardson
Steven Richardson
· 7 min read

A dashboard endpoint I inherited ran six aggregate queries back to back — users, orders, revenue, refunds, signups, churn. None of them depended on each other, yet the controller waited for each one to finish before starting the next. That's roughly 600ms of database round-trips stacked end to end when it could have been closer to 120ms. Laravel's Concurrency facade fixes exactly this: it runs independent closures at the same time in separate PHP processes and hands you the results back in order.

What the Concurrency facade actually does#

Under the hood the facade serialises each closure, ships it to a hidden Artisan command that runs in its own PHP process, invokes it there, then serialises the return value back to the parent. You get real parallelism for blocking work — wall-clock time collapses to roughly the slowest single task instead of the sum of them all.

Be clear about one thing up front: this parallelises independent work. It is not a fix for a slow query or an N+1. If a single query is the bottleneck, catch it early with Eloquent strict mode and fix the query first. Concurrency helps when you have several already-fast-enough operations that are pointlessly running one after another.

run(), defer(), and the three drivers#

The core API is Concurrency::run(). Pass it an array of closures and it returns an array of results in the same positions:

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\DB;

[$userCount, $orderCount] = Concurrency::run([
    fn () => DB::table('users')->count(),
    fn () => DB::table('orders')->count(),
]);

Positional results get fragile once you have five or six of them. Pass an associative array instead and read the results back by key:

$results = Concurrency::run([
    'users'  => fn () => DB::table('users')->count(),
    'orders' => fn () => DB::table('orders')->count(),
]);

$userCount = $results['users'];

Three drivers back the facade. process is the default and works everywhere, including inside a web request. fork is faster because it forks the running process instead of booting a fresh PHP binary per task — but it only works in the CLI (queue workers, commands, scheduled tasks), never during an HTTP request, and it needs an extra package:

composer require spatie/fork
$results = Concurrency::driver('fork')->run([
    fn () => DB::table('users')->count(),
    fn () => DB::table('orders')->count(),
]);

The third driver, sync, runs the closures sequentially in the current process. It exists for tests — more on that below.

A worked example: parallel dashboard queries#

Here's the real shape of that dashboard controller. Six independent aggregates, one parallel batch, named so the payload stays readable:

use Illuminate\Support\Facades\Concurrency;
use Illuminate\Support\Facades\DB;

public function metrics(): array
{
    $since = now()->subDays(30)->toDateTimeString(); // a plain string, not a Carbon instance

    return Concurrency::run([
        'users'    => fn () => DB::table('users')->where('created_at', '>=', $since)->count(),
        'orders'   => fn () => DB::table('orders')->where('created_at', '>=', $since)->count(),
        'revenue'  => fn () => DB::table('orders')->where('created_at', '>=', $since)->sum('total'),
        'refunds'  => fn () => DB::table('refunds')->where('created_at', '>=', $since)->sum('amount'),
        'signups'  => fn () => DB::table('users')->whereNull('verified_at')->count(),
        'churn'    => fn () => DB::table('subscriptions')->where('status', 'cancelled')->count(),
    ]);
}

Notice $since is resolved to a string before the closures. Each closure only closes over that scalar, so there's nothing awkward to serialise. If these numbers don't need to be real-time, wrap the whole call in a cache layer so most requests never spawn a single process — pair it with a sensible TTL from the complete guide to caching in Laravel.

Concurrency facade vs queues vs defer#

This is the question the docs don't answer directly, and it's the one that matters. Concurrency::run() is synchronous from the caller's point of view — it blocks until every task finishes and gives you the return values, all inside the current request. A queued job is the opposite: it's asynchronous, returns nothing to this request, outlives it, and comes with retries, backoff, and failure handling built in.

So the rule I use: if you need the results now, within the request, and the work is short and independent, reach for Concurrency. If the work is slow, can happen later, or needs to survive a failure and retry, it belongs on a queue — that's where job middleware for rate-limiting and backoff earns its keep. Concurrency has no retry semantics; one closure that throws takes the whole batch down.

Concurrency::defer() sits between the two. The closures run concurrently, but only after the HTTP response has been sent, and you don't get their return values:

use App\Services\Metrics;
use Illuminate\Support\Facades\Concurrency;

Concurrency::defer([
    fn () => Metrics::report('dashboard.viewed'),
    fn () => Metrics::warmCache('dashboard'),
]);

Reach for defer() when the work is fire-and-forget — analytics pings, cache warming, cleanup — and you'd rather the user didn't wait for it. It's lighter than dispatching a job when the task is trivial and you don't need durability.

Gotchas: serialization, exceptions, and testing#

The most common trip-up is closure serialisation. This fails, and the error is baffling the first time you see it:

class DashboardController
{
    public function index(): array
    {
        return Concurrency::run([
            fn () => $this->userCount(),  // ✗ captures $this — the whole controller
            fn () => $this->orderCount(), //   gets dragged into serialisation and dies
        ]);
    }
}

Any closure that touches $this binds the entire object, and the process driver can't serialise it. The fix is to keep each closure self-contained: resolve what you need into local variables and reference only those.

public function index(): array
{
    $since = $this->reportingWindow(); // resolve to a scalar first

    return Concurrency::run([
        fn () => DB::table('users')->where('created_at', '>=', $since)->count(),
        fn () => DB::table('orders')->where('created_at', '>=', $since)->count(),
    ]);
}

The same boundary bites in quieter ways. Each task boots a fresh framework kernel, so it does not share the parent's container state, open database transactions, or request-scoped Context data that follows your logs and jobs — set anything the closure needs explicitly inside it. And because spinning up a process per task has real overhead, don't parallelise trivial work; the coordination cost will dwarf the saving unless each task is genuinely slow.

For testing, force the sync driver so your assertions run predictably in one process. Set it in a Pest beforeEach:

use Illuminate\Support\Facades\Config;

beforeEach(function () {
    Config::set('concurrency.default', 'sync');
});

it('returns dashboard metrics', function () {
    $data = app(DashboardController::class)->metrics();

    expect($data)->toHaveKeys(['users', 'orders', 'revenue']);
});

For the whole suite, publish the config with php artisan config:publish concurrency and point the default key at sync in your testing environment.

Wrapping Up#

Start by finding one endpoint that does three or more independent things in sequence — a dashboard, a report, a fan-out of API calls — and wrap them in Concurrency::run(). Keep the closures self-contained, force sync in tests, and measure the before-and-after so you know the win is real; production tooling like Pulse, Nightwatch, and OpenTelemetry will show you the latency drop. When you hit work that's slow, deferrable, or needs retries, that's your signal to graduate it to a proper queue with chains or batches instead.

FAQ#

What is the Concurrency facade in Laravel?

It's a first-party API for running several closures at the same time in separate PHP processes and getting their return values back in order. Laravel serialises each closure, runs it in its own process, and serialises the result back to the caller. It's designed for parallelising independent, blocking work — like multiple queries or HTTP calls — inside a single request or command.

What's the difference between the process and fork drivers?

The process driver boots a fresh PHP process (via a hidden Artisan command) for each closure and works everywhere, including web requests. The fork driver forks the currently running process instead, which is faster because it skips a full bootstrap — but it relies on PHP's pcntl extension, so it only runs in the CLI and needs the spatie/fork package installed. Use process in web requests and fork in commands, queue workers, and scheduled tasks.

Is Laravel Concurrency the same as queues?

No. Concurrency::run() is synchronous — it blocks the current request until all tasks finish and returns their results immediately. Queues are asynchronous: the work runs later in a separate worker, returns nothing to the current request, and comes with retries and failure handling. Use Concurrency when you need results now within the request; use a queue when the work is slow, can be deferred, or must survive failures.

Why do my closures throw a serialization error in Concurrency::run?

Almost always because the closure captures $this or another non-serialisable value. Referencing an instance method or property binds the whole object to the closure, and the process driver can't serialise it. Resolve the values you need into local scalar variables before the closures and reference only those, so each closure is self-contained and returns plain data.

How do I test code that uses the Concurrency facade?

Switch to the sync driver so closures run sequentially in the same process, making results deterministic. Set Config::set('concurrency.default', 'sync') in a Pest beforeEach, or publish the config with php artisan config:publish concurrency and set the default key to sync for your testing environment. Your tests then exercise the same closures without spawning processes.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.