skip to content

In Laravel 13, how does Concurrency::run() execute three slow API calls at once with the default process driver, and what does that require of the closures?

level: seniorimportance: should knowfreq 30%

answer

  1. hidden invoke-serialized-closure command
  2. closures and results are serialized
  3. each child boots the application
  4. process default, fork CLI-only, sync
  5. timeout: argument, process driver only

basics

~20 s

With the process driver, Concurrency::run() serializes each closure, runs it in its own PHP process through a hidden Artisan command, and unserializes the results by position or key. Closures, captured variables and return values must be serializable.

solid answer

~50 s

In Laravel 13 the `Concurrency` facade's default driver is `process` (`CONCURRENCY_DRIVER`). `Concurrency::run([...])` wraps each closure in a serializable closure, starts one child PHP process per task through the hidden `invoke-serialized-closure` Artisan command using a process pool, waits for all of them, and unserializes each return value; results keep the array's keys, so `[$a, $b, $c] = Concurrency::run([...])` works. Each child boots the whole application and opens its own connections, and only serializable data crosses the boundary: captured variables and results cannot be open resources, and changes a child makes to in-memory state never reach the parent. If a task throws, the parent rethrows an exception of the same class. Since 13.9, `timeout:` caps each task under the process driver. `fork` (CLI only, needs `spatie/fork`) skips the boot cost; `sync` runs tasks in sequence for tests.

code

php · 12 lines
php
<?php

use Illuminate\Support\Facades\Concurrency;

$customerId = $order->customer_id;
$orderData = $order->toArray();

[$contact, $invoices, $quote] = Concurrency::run([
    fn () => app(CrmClient::class)->contact($customerId),       // returns an array
    fn () => app(BillingClient::class)->openInvoices($customerId), // returns an array
    fn () => app(TaxClient::class)->quote($orderData),            // returns an array
], timeout: 15);

go deeper

for a junior

Remember that Concurrency::run() takes an array of closures, runs them at the same time, and returns their results in the same order or keys.

for a middle

Explain the process driver: serialized closures, the hidden Artisan command, a framework boot per task, and how results and exceptions return to the parent.

for a senior

Show judgment on cost and fit: serialization limits, connection counts, the timeout argument, fork for CLI only, and when the HTTP client or a queue is the better tool.

for a principal

Decide where parallelism belongs in the system — inside a request, inside a job, or across workers — and set team rules on task counts and timeouts accordingly.

## How the process driver works The `Concurrency` facade runs several closures in parallel. In Laravel 13 its default driver is **`process`**, set by `config/concurrency.php` as `env('CONCURRENCY_DRIVER', 'process')` (publish that file with `php artisan config:publish concurrency` to change it). A call such as `Concurrency::run([fn () => $crm->syncContact($id), fn () => $billing->fetchInvoices($id), fn () => $tax->quote($order)])` goes through these steps: 1. Each closure is wrapped in a `SerializableClosure` and serialized, together with the variables it captured. 2. Laravel builds a **process pool** (the same `Process` facade machinery) with one child per task. Each child runs the hidden Artisan command **`invoke-serialized-closure`** from the application's base path, receiving the closure and the current `Context` data through environment variables. 3. Every child **boots the full Laravel application**, unserializes its closure, calls it through the container, and prints the serialized return value. 4. The parent waits for all children, then unserializes each result. Results keep the keys of the input array, so both `[$a, $b, $c] = Concurrency::run([...])` and `$results['crm']` work. The wall-clock time is roughly the slowest task plus the cost of starting PHP processes and booting the framework in each. ## What the closures must satisfy Because everything crosses a process boundary as serialized data: - **Captured variables must serialize.** Plain values, arrays and most objects are fine; an open file handle, a stream or a live connection object is not. - **Return values must serialize.** Return data — arrays, scalars, simple objects — rather than resources or handles. - **Side effects stay in the child.** A static property, an in-memory cache or a singleton changed inside a task is not changed in the parent. - **Each child opens its own connections.** Three tasks mean three database connections and three HTTP clients, not a shared one. ## Errors and timeouts - If a task throws, the child reports the exception and returns its class and message; the parent then **rethrows an exception of the same class**. The child's stack trace stays in the child's log entry. - If a child process fails outright (non-zero exit without a result), the parent throws a generic `Exception` naming the exit code and stderr. - Since Laravel 13.9, `Concurrency::run([...], timeout: 30)` sets a per-task limit (seconds or a `CarbonInterval`); **only the process driver honours it**. Without it, each child process keeps the `Process` component's default 60-second timeout. ## Choosing a driver | Driver | How tasks run | Constraints | |---|---|---| | `process` (default) | a child PHP process per task via Artisan | serialization; framework boot per task | | `fork` | forked copies of the current process | CLI only (throws in web requests); requires `spatie/fork` | | `sync` | one after another in the current process | no parallelism; meant for tests | Pick one per call with `Concurrency::driver('fork')->run([...])`. ## Is it the right tool for three API calls? It depends on what "API call" means: - If the calls are plain HTTP requests, Laravel's HTTP client can overlap them inside one process, with no process start-up or framework boot per call. - If each call goes through a vendor SDK that only offers blocking methods, or includes heavy PHP work, `Concurrency::run()` is a clean way to overlap them. - If the work must survive a crash, be retried, or run for minutes, it belongs in queued jobs instead. The fixed cost matters for small tasks. Three lookups that each take 50 milliseconds may finish sooner sequentially than after starting three PHP processes that each boot the framework; three calls that each take two seconds are where the process driver clearly wins. Measure both before adopting it on a hot path. ## Pitfalls - Referencing `$this` inside a task when the object holds a live connection or other state that cannot be serialized. - Expecting a task to update a variable in the parent by reference. - Running dozens of tasks at once: each is a full PHP process with its own memory. - Using `fork` in a web request, which throws a `RuntimeException`.

  • Why does the fork driver refuse to run in a web request?
    Laravel's `ConcurrencyManager` throws a `RuntimeException` when `fork` is requested outside the console, because PHP does not support forking in a web request. It also requires the `spatie/fork` package. Use it in Artisan commands and queue workers, where it avoids the per-task framework boot.
  • What does Concurrency::defer() do differently from Concurrency::run()?
    `defer()` does not return results. It registers the tasks to start after the HTTP response has been sent; with the process driver each closure is launched as a background process whose output and failures are not collected. It fits fire-and-forget work such as reporting metrics.
  • How do you run Concurrency tasks sequentially in a test?
    Switch to the `sync` driver, for example by setting `CONCURRENCY_DRIVER=sync` in the test environment or calling `Concurrency::driver('sync')`. Tasks then run one after another in the current process, so ordinary assertions and database state apply.

saying these in an interview costs you the question

  • Concurrency::run() uses PHP threads that share the parent's memory.
  • A task can update a variable in the parent through a reference.
  • The fork driver is the default and works in web requests.
  • An exception inside a task is swallowed and returned as null.
  • The timeout argument limits tasks under every driver.