skip to content

In Laravel 13, how does Http::pool() send requests concurrently, what does it return when one request times out, and why can't you chain withToken() before it?

level: seniorimportance: should knowfreq 30%

answer

  1. closure receives a Pool
  2. results keyed by position or as()
  3. failures returned, not thrown
  4. concurrency 0 means all at once
  5. configure each pooled request

basics

~20 s

Http::pool() runs the requests added to its Pool concurrently and returns an array keyed by position or as() name. A timed-out entry holds a ConnectionException object instead of throwing, and headers must be set on each pooled request.

solid answer

~50 s

In Laravel 13, `Http::pool()` takes a closure receiving an `Illuminate\Http\Client\Pool`; each `$pool->get(...)` registers an asynchronous request, and `pool()` drives them through Guzzle promises in one PHP process, blocking until all settle — so the wall time is about the slowest request, not the sum. Results come back in an array keyed by registration position (0, 1, 2 ...), or by the name given with `$pool->as('key')`. Nothing is thrown for a single failure: a 4xx or 5xx is a `Response` with `failed()` true, and a timeout or DNS failure is a `ConnectionException` object in its slot, so check `instanceof Throwable` first. The `concurrency` argument defaults to `0`, meaning all requests at once; pass `concurrency: 5` to cap it. `Http::withToken($t)->pool(...)` sends no token because the pool builds fresh requests from the factory — configure each `$pool->...` chain.

code

php · 23 lines
php
<?php

use Illuminate\Http\Client\Pool;
use Illuminate\Support\Facades\Http;

$token = config('services.carrier.token');

$responses = Http::pool(fn (Pool $pool) => array_map(
    fn (string $parcel) => $pool->as($parcel)
        ->withToken($token)
        ->timeout(5)
        ->get("https://carrier.example/api/tracking/{$parcel}"),
    $parcelNumbers,
), concurrency: 5);

$statuses = [];
foreach ($responses as $parcel => $result) {
    if ($result instanceof \Throwable) {
        report($result); // ConnectionException: timeout, DNS, refused
    } elseif ($result->successful()) {
        $statuses[$parcel] = $result->json('status');
    }
}

go deeper

for a junior

Remember that Http::pool() sends several requests at once and returns an array you index by position or by the as() name.

for a middle

Explain the Pool closure, why failures arrive as values, how keys are assigned, and why configuration must go on each pooled request.

for a senior

Show production care: cap concurrency for rate-limited upstreams, set per-request timeouts, and check every slot for Throwable before reading it.

for a principal

Decide between pool(), batch() with defer(), queued jobs and process-based concurrency by weighing latency, durability and load on the upstream.

## How pool() works `Http::pool()` sends several requests **concurrently from one PHP process**, using Guzzle's asynchronous promises rather than threads or child processes. In Laravel 13: 1. You pass a closure that receives an `Illuminate\Http\Client\Pool`. 2. Each call on the pool — `$pool->get(...)`, or `$pool->as('key')->withToken(...)->get(...)` — creates a new **asynchronous** `PendingRequest` and registers it. The closure's return value is not used; registration is the side effect that matters, and returning an array is only a readable convention. 3. `pool()` starts the promises, keeps up to `concurrency` of them in flight, and **blocks until every one has settled**. 4. It returns a plain PHP array of results, one per request. Because the requests overlap, the wall-clock time is roughly that of the **slowest** request rather than the sum. No single request gets faster; their waits are spent side by side. ## Reading the results | What happened to the request | What its slot holds | |---|---| | 2xx response | a `Response` whose `successful()` is true | | 4xx or 5xx response | a `Response` whose `failed()` is true | | timeout, DNS failure, refused connection | a `ConnectionException` **object** | | failed status with `throw()`, or exhausted `retry()` | a `RequestException` **object** | Nothing is thrown out of `pool()` for an individual failure, so one dead request does not abort the others — but an unchecked slot can hold an exception where you expected a response. Check `$result instanceof Throwable` before calling `status()` or `json()`. Keys follow registration order (0, 1, 2 ...) unless you name requests with `$pool->as('parcel-17')`, in which case the array is keyed by those names. Each result always sits under its own request's key, whichever finished first; a `foreach` over the array may visit them in completion order, so read results by key rather than by position in the loop. ## Concurrency The second argument caps how many requests are in flight at once: `Http::pool($callback, concurrency: 5)`. In Laravel 13 it **defaults to `0`**, which the client treats as "all of them": twenty tracking lookups mean twenty simultaneous requests to the carrier. Against a rate-limited API, set a cap explicitly. ## Configuring pooled requests The `Pool` builds each request from the factory, not from the `PendingRequest` that `pool()` was called on. So `Http::withToken($t)->pool(...)` sends **no** token: the token sits on a request the pool never uses. The documentation says it directly — `pool()` cannot be chained after methods such as `withHeaders()`. Configure each pooled request instead: - chain `withToken()`, `withHeaders()`, `timeout()` or `retry()` after `$pool->as(...)`, or directly on `$pool` before the verb; - factor the shared part into a small closure that takes the `Pool` and returns a configured request; - give each request its own `timeout()`: because `pool()` waits for everyone, one hung request holds the whole pool until its own timeout fires. `retry()` works on pooled requests; the delay is handed to Guzzle as its `delay` option, and a request that exhausts its attempts lands in its slot as an exception object. ## Pool versus batch `Http::batch()` is the callback-driven sibling: - its closure receives an `Illuminate\Http\Client\Batch`; - `before()`, `progress()`, `then()`, `catch()` and `finally()` register hooks; - `concurrency(5)` caps the parallelism; - `send()` runs it now, while `defer()` runs it after the HTTP response has been sent to the user. Use `pool()` when you need every answer before continuing; use `batch()` to react per request or to push the work past the response. ## When pooling is the right tool Pooling pays off when a page or job needs several **independent** HTTP answers and spends most of its time waiting on the network: tracking statuses for every parcel in an order, rates from several services of one carrier, or lookups against several endpoints of the same API. It does not help when: - each request needs the previous response (a token call, then the call that uses the token) — those stay sequential; - the work is CPU-bound PHP rather than network waiting — the pool runs in one PHP process and does not add CPU; - the upstream enforces a strict rate limit that concurrency would simply hit sooner. For running arbitrary closures in parallel rather than HTTP requests, Laravel has separate process-based tools; `pool()` is specifically for HTTP. ## Pitfalls - Treating every slot as a `Response` and calling `->json()` on a `ConnectionException`. - Forgetting a concurrency cap and tripping the carrier's rate limit. - Leaving the 30-second default timeout on pooled requests, so one hung call stretches the whole pool. - Expecting headers set before `pool()` to reach the pooled requests.

  • How is Http::batch() different from Http::pool() in Laravel?
    `batch()` builds an `Illuminate\Http\Client\Batch` with `before()`, `progress()`, `then()`, `catch()` and `finally()` callbacks, a `concurrency()` method, and either `send()` to run now or `defer()` to run after the response is sent. `pool()` simply returns every result once all requests settle.
  • What happens to a pool's total time if one of ten pooled requests hangs?
    `pool()` waits for every request to settle, so the whole call lasts until that request's own timeout fires — 30 seconds by default. Setting a short `timeout()` on each pooled request bounds the pool.
  • Does retry() work on a request inside Http::pool()?
    Yes. Pooled requests retry through the promise chain, with the delay passed to Guzzle as its `delay` option. A request that runs out of attempts ends up in its result slot as a `RequestException` or `ConnectionException` object rather than being thrown.

Sending five couriers out at once instead of one courier doing five rounds: no courier drives faster, but you are done when the slowest one returns, and a courier who gets lost is written down as a failure on his line of the list rather than stopping the others.

saying these in an interview costs you the question

  • Http::withToken($t)->pool(...) applies the token to every pooled request.
  • A timeout inside the pool throws and aborts the other requests.
  • pool() renumbers results by completion order, so $responses[0] is the fastest reply.
  • pool() keeps only a couple of requests in flight unless told otherwise.
  • Pooling runs each request on its own thread or child process.