skip to content

Guzzle Client

Guzzle wraps cURL behind a Client with base_uri, request options, a typed exception tree and middleware. Interviewers ask how you set timeouts, retry safely and fake responses in tests.

part ofPHPoverview, primer and where to startread it →
on this pageshow

explore

questions

5

When you build a Guzzle Client with base_uri and default options, how are relative paths resolved, and what timeout applies if you set none?

level: juniorimportance: must knowfreq 58%

answer

  1. client defaults merge into each request
  2. RFC 3986 reference resolution
  3. trailing slash on the base path
  4. leading slash replaces the path
  5. timeout 0 waits indefinitely

basics

~20 s

Guzzle resolves a request URI against base_uri by RFC 3986: 'rates' after 'https://api.test/v2/' gives /v2/rates, but '/rates' or a base without the trailing slash drops v2. The timeout option defaults to 0, which waits indefinitely.

solid answer

~40 s

`new Client([...])` takes `base_uri` plus any request options, and those options become defaults for every request, which can override them per call. Relative URIs are merged with `base_uri` by RFC 3986 rules, not by string concatenation. With `https://rates.example.test/v2/`, the path `latest` becomes `/v2/latest`. A leading slash (`/latest`) replaces the whole path, and a base without the trailing slash (`.../v2`) treats `v2` as a file and replaces it. An absolute URL ignores the base. Timeouts are not set for you: `timeout` defaults to `0`, meaning wait indefinitely, and `connect_timeout` defaults to `0`, which the docs describe as waiting 300 seconds. So I always set both on the client, for example `'connect_timeout' => 2, 'timeout' => 5`. I also use `query` for parameters and `json` for request bodies.

code

php · 19 lines
php
<?php
declare(strict_types=1);

use GuzzleHttp\Client;

$rates = new Client([
    'base_uri'        => 'https://rates.example.test/v2/', // trailing slash
    'connect_timeout' => 2,
    'timeout'         => 5,
    'headers'         => ['Accept' => 'application/json'],
]);

// GET https://rates.example.test/v2/latest?base=EUR&symbols=USD
$response = $rates->request('GET', 'latest', [
    'query'   => ['base' => 'EUR', 'symbols' => 'USD'],
    'timeout' => 3, // per-request override of the client default
]);

$data = json_decode((string) $response->getBody(), true, flags: JSON_THROW_ON_ERROR);

go deeper

for a junior

Know that constructor options become per-request defaults, that the trailing slash on base_uri matters, and that timeout defaults to 0, meaning no limit.

for a middle

Explain RFC 3986 resolution with the leading-slash and trailing-slash cases, per-request overrides, and how query and json build the request.

for a senior

Build one configured client per upstream with timeouts sized to the caller's budget, and catch the base_uri path bug in review before it sends traffic to the wrong endpoint.

for a principal

Standardise how services construct HTTP clients so timeouts and base paths are never left at library defaults, and make the defaults visible in configuration.

## A client is a bundle of defaults **Guzzle** is a PHP HTTP client library built on top of cURL and PHP streams. Its `GuzzleHttp\Client` is configured once, in the constructor, and is **immutable** afterwards: the docs say you cannot change its defaults once it exists. The constructor array holds three kinds of entries: - `base_uri`: the URI that relative request URIs are resolved against; - `handler`: the callable that actually sends requests (by default a `HandlerStack` wrapping a cURL handler); - **everything else**: default request options, such as `timeout`, `headers` or `http_errors`, merged into every request. An option passed to `request()` for one call overrides the client default. ## How base_uri is resolved Guzzle does not glue strings together. It applies the reference-resolution rules of RFC 3986, section 5.2, the same rules a browser uses for a relative link. The effect depends on two slashes: | base_uri | request URI | result | |---|---|---| | `https://rates.example.test/v2/` | `latest` | `https://rates.example.test/v2/latest` | | `https://rates.example.test/v2` | `latest` | `https://rates.example.test/latest` | | `https://rates.example.test/v2/` | `/latest` | `https://rates.example.test/latest` | | `https://rates.example.test/v2/` | `https://other.example.test/x` | `https://other.example.test/x` | The rules behind the table: 1. A request URI starting with `/` is an absolute path: it replaces the base path entirely. 2. A relative path is merged with the base path **up to its last slash**. Without a trailing slash, the last segment (`v2`) is treated like a file name and dropped. 3. A full URL with a scheme ignores `base_uri` completely. The classic bug is a client with `'base_uri' => 'https://api.test/v2'` and calls to `'/rates'`: every request silently goes to `/rates`, not `/v2/rates`. Put the trailing slash on the base, and no leading slash on the paths. ## Timeouts are off unless you set them From Guzzle's request-options documentation: | Option | Meaning | Default | |---|---|---| | `timeout` | total time for the request, in seconds (float allowed) | `0`, wait indefinitely | | `connect_timeout` | time to establish the connection | `0`, which the docs describe as waiting 300 seconds | | `read_timeout` | per-read limit on a streamed body (`stream => true`) | the `default_socket_timeout` ini value | So a fresh `new Client()` will wait indefinitely for an upstream that accepts the connection and then hangs. For an exchange-rate provider behind a checkout, that means a stuck PHP worker. Set both limits as client defaults, and tighten them per request where a call has a smaller budget. When a limit expires with the default cURL handler, Guzzle throws `GuzzleHttp\Exception\ConnectException`. ## Options you will use on nearly every call - `query`: an array (or string) that becomes the query string. The docs warn that it **overwrites** any query string already in the URI; it does not merge with it. - `json`: any value `json_encode()` accepts. Guzzle encodes it as the body and adds `Content-Type: application/json` if no content type is set. It cannot be combined with `body`, `form_params` or `multipart`, and it does not take `json_encode()` flags; for custom flags, encode yourself and pass `body`. - `headers`: default headers such as `Accept` or an API key. A client-level `User-Agent` is added automatically if you set none. - `http_errors`: `true` by default, so 4xx and 5xx responses throw exceptions. ## How per-request options merge with client defaults The merge is **shallow**: an option given to `request()` replaces the client default of the same name, and an option set to `null` is removed. Headers are the exception. Client-level `headers` are applied as conditional defaults: each default header is added only if the request does not already set a header of that name. So a per-request `headers` array adds to the defaults rather than wiping them, and passing `'headers' => null` drops the defaults for that call. This is why the usual pattern works: API keys and `Accept` live on the client, and a single call adds an `Idempotency-Key` or overrides `timeout` without repeating the rest. ## One client per provider, reused Create the client once and reuse it, for example as a service in your container, instead of calling `new Client()` inside every method. The client owns its handler stack, and Guzzle's cURL handlers keep released handles for reuse, so a long-lived client can reuse connections within the PHP process. A fresh client per call starts from nothing each time and scatters configuration across call sites. ## Putting it together A client built for one provider usually sets `base_uri`, both timeouts, and the headers every call needs. Callers then pass only what varies: a relative path, `query`, or `json`. That keeps the per-call code short, and the risky defaults are handled in one place.

  • With base_uri 'https://api.test/v2/', where does a request to '/latest' go, and why?
    To `https://api.test/latest`. Guzzle resolves request URIs by RFC 3986, and a path that starts with `/` is an absolute path that replaces the base path. To stay under `/v2/`, pass `latest` without the leading slash.
  • What happens if you pass both a query string in the URI and the query option?
    The `query` option wins: Guzzle's docs say values in `query` overwrite all query string values supplied in the URI. A request to `latest?base=EUR` with `'query' => ['symbols' => 'USD']` is sent as `latest?symbols=USD`, so put all parameters in one place.
  • Can you change a Guzzle client's timeout after it is created?
    Not on the client itself: Guzzle clients are immutable, and their defaults are fixed at construction. Pass `timeout` in the options of a single request to override it for that call, or build a second client with different defaults.

saying these in an interview costs you the question

  • Guzzle concatenates base_uri and the path, so slashes do not matter.
  • A new Guzzle client times out after 30 seconds by default.
  • The query option merges with a query string already in the URI.
  • You can call a setter to change a Guzzle client's default timeout later.
  • The json option accepts json_encode flags such as JSON_PRETTY_PRINT.
open as a page

In Guzzle 7, what does the http_errors option control, and which exceptions do a 404, a 503 and a timed-out request raise?

level: middleimportance: must knowfreq 55%

basics

~20 s

With http_errors true (the default), a 404 throws ClientException and a 503 ServerException, both BadResponseExceptions carrying the response. A timeout or refused connection throws ConnectException, which since Guzzle 7 extends TransferException, not RequestException, and has no response.

open as a page

How do you unit-test code that uses a Guzzle client with MockHandler, and why wrap it in HandlerStack::create()?

level: middleimportance: should knowfreq 40%

basics

~20 s

Inject a Client whose handler is HandlerStack::create(new MockHandler([...])); queued responses and exceptions are returned in order without network access. The stack matters because http_errors and other options are middleware; a bare MockHandler never throws on 4xx or 5xx.

open as a page

How do you send several requests concurrently with Guzzle using requestAsync() promises or a Pool, and how do you collect partial failures?

level: seniorimportance: should knowfreq 36%

basics

~20 s

getAsync() or requestAsync() return promises; the transfers run concurrently while you wait. Promise\Utils::settle() collects fulfilled and rejected results without throwing, unlike unwrap(). For many or unbounded requests, Pool limits in-flight requests (concurrency, default 25) and reports each via fulfilled/rejected callbacks.

open as a page

How do you add retries to a Guzzle client with Middleware::retry, and what does the decider see when pushed onto HandlerStack::create()?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Push Middleware::retry($decider, $delay) onto a HandlerStack. The decider receives the retry count (starting at 0), the request, and a response or exception, and returns true to retry. Pushed after HandlerStack::create(), it sits inside http_errors, so a 5xx arrives as a response.

open as a page