The Problem: Sequential Work Masquerading as Async
Most Laravel applications that need to "do several things at once" end up dispatching jobs and hoping for the best. That works until you need the results of those jobs before moving on — think generating a multi-section report, enriching a batch of records from three external APIs, or running independent validation pipelines in parallel.
Laravel 11 shipped the Concurrency facade to solve exactly this. Combined with job batching, you can express parallel work clearly and handle failures without writing a custom orchestration layer.
The Concurrency Facade in One Minute
Concurrency::run() accepts an array of closures and executes them in separate PHP processes (via the fork driver on Linux/macOS, or a queue-backed driver elsewhere). Each closure is isolated — no shared memory, no race conditions on your objects.
use Illuminate\Support\Facades\Concurrency;
[$orders, $inventory, $pricing] = Concurrency::run([
fn () => Order::whereUserId($userId)->get(),
fn () => Inventory::forUser($userId)->available()->get(),
fn () => PricingService::currentRates(),
]);
The return value is an array of results in the same order as the input. Exceptions bubble up as an \Illuminate\Process\Exceptions\ProcessFailedException — catch it or let it propagate.
Choosing the Right Driver
| Driver | When to use |
|---|---|
| fork | CLI/queue workers on Linux; fastest, zero overhead |
| process | Same as fork but spawns a full PHP process; safer for memory |
| sync | Testing; runs closures sequentially, no forking |
Set the driver in config/concurrency.php or per-call:
Concurrency::driver('fork')->run([...]);
Combining Concurrency with Job Batching
Concurrency is great for short-lived, CPU-bound or I/O-bound tasks that return values. Job batching is better for long-running, queue-distributed work where you need callbacks on completion or failure.
The sweet spot: use Concurrency to fan out fast preparatory work, then dispatch a Bus::batch() for the heavy lifting.
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Concurrency;
// Step 1: fetch metadata in parallel (fast, returns values)
[$segments, $schema] = Concurrency::run([
fn () => DataSegmentRepository::forExport($exportId),
fn () => SchemaRegistry::resolve($exportId),
]);
// Step 2: dispatch a batch for the heavy per-segment work
$batch = Bus::batch(
$segments->map(fn ($seg) => new ProcessSegmentJob($seg, $schema))
)->then(function (Batch $batch) use ($exportId) {
Export::find($exportId)->markComplete();
})->catch(function (Batch $batch, Throwable $e) use ($exportId) {
Export::find($exportId)->markFailed($e->getMessage());
})->allowFailures()
->dispatch();
Controlling Concurrency on the Batch Itself
Batches don't limit concurrency by default — your queue workers do. If you need to throttle, attach the WithoutOverlapping middleware or a rate-limited middleware to the job:
public function middleware(): array
{
return [
new RateLimited('segment-processing'),
];
}
public function retryUntil(): DateTime
{
return now()->addMinutes(10);
}
Pitfalls to Avoid
1. Passing Eloquent Models into Concurrency Closures
Closures are serialized before being forked. Eloquent models serialize fine, but their open database connections do not. Always pass IDs and re-query inside the closure:
// Bad
Concurrency::run([fn () => $user->enrichProfile()]);
// Good
$userId = $user->id;
Concurrency::run([fn () => User::find($userId)->enrichProfile()]);
2. Assuming Shared Cache State
Forked processes inherit the parent's memory snapshot but not live cache writes made after the fork. Treat each closure as a fresh request.
3. Ignoring the sync Driver in Tests
The sync driver runs closures sequentially, which is exactly what you want in Pest tests. Bind it in TestCase::setUp or use Concurrency::fake().
Concurrency::fake([
fn () => collect([/* stubbed segments */]),
fn () => new SchemaStub(),
]);
Takeaways
- Use
Concurrency::run()for fast, value-returning parallel tasks; useBus::batch()for distributed, long-running work. - Always pass primitive IDs into concurrency closures — never live model instances or open connections.
- The
forkdriver is fastest on Linux workers; fall back toprocessif you hit memory issues. - Test with
Concurrency::fake()to keep your Pest suite deterministic. - Combine batch
->then()/->catch()callbacks with->allowFailures()for resilient pipelines that report partial success.