illuminate/concurrency
Laravel's official concurrency component. Run closures in parallel using process pools through the Concurrency facade, collect results with run(), fire background tasks with defer(), and tune drivers and limits. Ideal for fan-out workloads without queues.
illuminate/concurrency ships with Laravel 11+, so you rarely install it directly — just composer require illuminate/concurrency if you're on a bare framework (illuminate/support, illuminate/container, and a Laravel app skeleton are assumed by the facade). It exposes a single entry point:
use Illuminate\Support\Facades\Concurrency;
[$users, $orders] = Concurrency::run([
fn () => User::count(),
fn () => Order::count(),
]);
That's the whole mental model: pass closures, get back an array of results in the same order. Use it the first time you have two or more independent, I/O-bound operations that currently run back-to-back (multiple third-party API calls, several aggregate queries on separate connections, filesystem/Redis checks).
Before writing code, verify the environment:
php -m | grep -E 'pcntl|posix'
Both extensions must be present and you must be on a Unix-like OS. There is no Windows support — the package forks processes via pcntl_fork. If pcntl is missing (common on shared hosting), stop here and use Http::pool(), Bus::batch(), or the queue instead.
Look first at config/concurrency.php (publish with php artisan vendor:publish --tag=concurrency-config if it's absent) to see the configured driver, and at the Concurrency facade itself (Illuminate\Support\Facades\Concurrency) — its run() and defer() methods are the entire public surface.
Named result sets. Return associative keys from run() to avoid destructuring mistakes when the task list grows:
$results = Concurrency::run([
'users' => fn () => User::query()->count(),
'revenue' => fn () => Order::query()->sum('total'),
'health' => fn () => Http::get('https://api.example.com/health')->ok(),
]);
echo $results['revenue'];
Chunking for bounded parallelism. Each closure forks one process, so a 500-element array creates 500 processes. Chunk into batches sized to your CPU/memory budget:
$chunks = collect($ids)->chunk(8);
$all = [];
foreach ($chunks as $chunk) {
$all = array_merge($all, Concurrency::run(
$chunk->map(fn ($id) => fn () => $this->fetch($id))->all()
));
}
Post-response work with defer(). Use it for fire-and-forget logging, metrics flushes, or cache warming that shouldn't block the response:
Route::get('/dashboard', function () {
Concurrency::defer([
fn () => Log::channel('audit')->info('dashboard viewed', ['user' => auth()->id()]),
fn () => Cache::put('dash:last', now(), 3600),
]);
return view('dashboard');
});
Treat defer() as best-effort — it is not durable, retryable, or observable across restarts. Anything that must complete belongs on a queue.
Wrap it in a service. Rather than scatter Concurrency::run() across controllers, centralize:
final class ParallelFetcher
{
public function run(array $tasks): array
{
return Concurrency::run($tasks);
}
}
This gives you a seam to fake/mock in tests and to swap the driver later.
Pair it with queue jobs and Artisan commands for one-off parallel workloads (backfills, report generation). Avoid it inside already-parallel queue workers — you'll fork the worker's own connections and memory.
Don't mix it with Http::pool(). pool() already runs HTTP requests concurrently in a single process. Concurrency is for heterogeneous work: one branch hits an API, another runs a DB aggregate, another reads a file.
Database connections are forked, not reopened. A child inherits the parent's PDO handle, which is unsafe once multiple processes touch the same socket. Reconnect at the top of each child:
fn () => tap(DB::connection()->reconnect(), fn () => Order::count())
For SQLite, forked writers will hit database is locked fast — don't parallelize writes.
Return values are serialized. Anything that can't cross serialize()/unserialize() — closures, PDO/Redis handles, resource, open streams, Guzzle handlers — will fail or corrupt. Return primitives, arrays, or Eloquent models (they serialize) only when the payload is small.
State is not shared. Mutating a captured $array or $this->property inside a child has no effect in the parent. Return data; don't try to write back by reference. static counters increment per-process, not globally.
Exceptions surface at collection time. If a child throws, the error propagates when you read results, aborting the rest. Wrap child bodies in try/catch and return a sentinel (e.g. ['ok' => false, 'e' => $e->getMessage()]) if you need partial results.
Memory is copy-on-write per fork. Fat closures that load large models or big arrays in the parent get duplicated on any write. Keep closures lean and avoid loading large collections before forking.
Output interleaves or disappears. echo, dump(), var_dump() from children can be swallowed or spliced together. Log to a file or return diagnostics in the result array.
Testing actually forks. The default driver runs real pcntl_fork calls under PHPUnit, which can hang or produce bizarre diffs. Options, in order of preference:
Concurrency::fake() (and assertRan/assertDeferred), call it in setUp() — it swaps to a synchronous driver.ParallelFetcher above) and mock that seam.phpunit.xml.defer() is unreliable for critical paths. It runs after the response flushes; a fatal error, timeout, or worker restart silently drops the work. Use dispatchAfterResponse() / queue jobs when durability matters.
Don't nest Concurrency::run(). A child that calls run() will try to fork its own children, multiplying process count and connection problems. Flatten the work.
Debugging forks is awkward. Use posix_getpid() in logs to distinguish processes, error_log() instead of dump(), and strace -ff -o /tmp/trace php artisan your:command to observe actual forks and signal handling. Child exit codes are usually swallowed — instrument explicitly.
Config quirks. The behavior is governed by config/concurrency.php (driver and related options). If you switch drivers or write a custom one, verify it still respects the array-order contract and serialization semantics — otherwise code written against the facade breaks silently.
Extension point. The driver is resolved through the container, so a custom implementation can be registered for observability (timing, error capture) or for environments where forking isn't allowed. Treat process as the default and only widely-tested path.
How can I help you explore Laravel packages today?