---
title: "PHP Guzzle Guide: Requests, Async Concurrency, Retries, and Proxies"
description: "Practical Guzzle tutorial for PHP: installing, JSON and multipart requests, timeouts, error handling, retry middleware, async pools, proxies, and SSL."
url: https://webscraping.ai/blog/php-guzzle-guide
markdown_index: https://webscraping.ai/llms.txt
---
# PHP Guzzle Guide: Requests, Async Concurrency, Retries, and Proxies

[Guzzle](https://docs.guzzlephp.org/) is the de facto standard HTTP client for PHP: it powers Laravel's `Http` facade, ships with countless SDKs, and implements the PSR-7 and PSR-18 interfaces the rest of the ecosystem builds against. This guide covers the parts you actually need in production — installing and version pinning, sending JSON and multipart requests, timeouts, the exception hierarchy, retry middleware, async concurrency with pools, proxies, and SSL verification.

## Key Takeaways

- Install with `composer require guzzlehttp/guzzle` and create **one `Client` per service** — Guzzle reuses cURL handles per client, which is where connection pooling comes from
- Guzzle sets no overall request timeout by default — always configure `timeout` and `connect_timeout`
- 4xx/5xx responses throw `ClientException`/`ServerException` unless you pass `'http_errors' => false`
- There is no built-in retry option; the idiomatic solution is `Middleware::retry()` with an exponential-backoff decider
- Concurrency comes from promises, not threads: `Pool` runs hundreds of requests over cURL multi with a `concurrency` cap
- The `proxy` option accepts per-protocol proxies, `no` exclusion lists, credentials in the URL, and `socks5://` schemes

## Installing Guzzle (and Guzzle 6, 7, and 8)

```bash
composer require guzzlehttp/guzzle # latest 8.x
composer require guzzlehttp/guzzle:^8.2 # pin a minor version
composer show guzzlehttp/guzzle # check what's installed
```

Guzzle 8 (first released July 2026, 8.2 as of September 2026) is the current major line and requires PHP 7.4+; Guzzle 7 (PHP 7.2.5+) is in maintenance mode and still gets fixes. Either runs fine on PHP 8.x, which is what you should be on — see our [PHP web scraping guide](https://webscraping.ai/blog/web-scraping-with-php) for the wider toolchain. The code in this guide works on both unless noted. If you're maintaining code stuck on Guzzle 6, the practical differences when upgrading:

| Change | Guzzle 6 | Guzzle 7 |
| --- | --- | --- |
| PHP requirement | 5.5+ | 7.2.5+ |
| PSR-18 (`ClientInterface`) | No | Yes — drop-in for PSR-18 consumers |
| Exceptions | `GuzzleException` interface | Same interface, now extends `Throwable` |
| Types | DocBlock-only | Native parameter/return types |
| `guzzlehttp/psr7` | v1 | v1 or v2 |

For most applications the 6 → 7 upgrade is a version-constraint bump; code that extended Guzzle internals or type-hinted concrete classes needs a closer look. Going from 7 to 8 is more involved: it moves to `guzzlehttp/psr7` 3.x and `guzzlehttp/promises` 3.x, header values must be strings (`'Api-Version' => '1'`, not `1`), the exception hierarchy is split by transfer phase (see below), NTLM is no longer a built-in `auth` type, and the deprecated `GuzzleHttp\` namespace functions are gone. Read the official [upgrade guide](https://github.com/guzzle/guzzle/blob/8.2/UPGRADING.md) before bumping.

## Quick start: clients and requests

Create a client once and reuse it — every `new Client()` starts with cold connections, and per-request clients silently throw away the cURL handle reuse that makes Guzzle fast:

```php
use GuzzleHttp\Client;

$client = new Client([
    'base_uri' => 'https://api.example.com',
    'timeout' => 10.0,
]);

$response = $client->request('GET', '/products', [
    'query' => ['page' => 2, 'per_page' => 50],
]);

echo $response->getStatusCode(); // 200
echo $response->getHeaderLine('Content-Type');
$body = (string) $response->getBody(); // the body is a PSR-7 stream — cast it
```

`getBody()` returns a stream, not a string. Cast with `(string)` or call `->getContents()` once; a second `getContents()` call returns an empty string because the stream pointer is at the end (rewind with `$response->getBody()->rewind()` if needed).

The `query` option builds and encodes the query string for you — don't concatenate parameters into the URL by hand.

## Headers, user agents, and referers

Guzzle identifies itself as `GuzzleHttp/8` (or `GuzzleHttp/7`) by default, which many sites treat as bot traffic. Set headers per client (defaults) or per request:

```php
$client = new Client([
    'headers' => [
        'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36',
        'Accept' => 'text/html,application/xhtml+xml',
    ],
]);

$client->request('GET', '/page', [
    'headers' => ['Referer' => 'https://www.google.com/'], // merged with defaults
]);
```

Per-request headers merge with client defaults; a per-request header with the same name replaces the default. For scraping, rotate realistic user agents — our [user-agent rotation guide](https://webscraping.ai/blog/user-agent-rotation-for-web-scraping) covers patterns and pitfalls.

## POST requests: JSON, forms, and multipart uploads

Three body options cover almost everything; each sets the right `Content-Type` automatically:

```php
// JSON API call
$response = $client->post('/api/items', [
    'json' => ['name' => 'Widget', 'price' => 9.99],
]);
$data = json_decode((string) $response->getBody(), true);

// Classic form submit (application/x-www-form-urlencoded)
$client->post('/login', [
    'form_params' => ['email' => $email, 'password' => $password],
]);

// Multipart upload (files + fields)
use GuzzleHttp\Psr7\Utils;

$client->post('/upload', [
    'multipart' => [
        ['name' => 'file', 'contents' => Utils::tryFopen('/path/report.pdf', 'r'), 'filename' => 'report.pdf'],
        ['name' => 'title', 'contents' => 'Q3 Report'],
    ],
]);
```

Pass file handles (not `file_get_contents()` strings) to multipart parts — Guzzle streams them, so a 2 GB upload doesn't need 2 GB of memory. `json`, `form_params`, and `multipart` are mutually exclusive; pick one per request.

## Timeouts

Guzzle sets no overall deadline by default (`timeout` is `0`; Guzzle 8 caps only the connect phase, at 60 seconds). Production code should always set:

```php
$client = new Client([
    'connect_timeout' => 5, // seconds to establish the TCP/TLS connection
    'timeout' => 15, // total seconds for the whole request
    'read_timeout' => 10, // idle seconds between reads (stream handler only; cURL ignores it)
]);

// Override per request when one endpoint is known-slow
$client->get('/export', ['timeout' => 120]);
```

In Guzzle 7 a timeout surfaces as `GuzzleHttp\Exception\ConnectException`. Guzzle 8 splits it by phase: `ConnectTimeoutException` (a `ConnectException`) while connecting, `NetworkTimeoutException` before response headers arrive, and `ResponseTimeoutException` after. Catch `Psr\Http\Client\NetworkExceptionInterface` if you want "too slow" and "unreachable" handled the same way on both versions.

## Error handling and the exception hierarchy

By default Guzzle throws on HTTP error statuses. The Guzzle 8 hierarchy is worth memorizing:

- `GuzzleException` — interface every Guzzle error implements; catch-all
- `NetworkException` — no response available: `ConnectException` (DNS failure, refused connection, TLS setup, connect timeout) and `NetworkTimeoutException`
- `RequestException` — base for request failures
  - `ResponseException` — a response object is available (`getResponse()` lives here in Guzzle 8)
  - `ClientException` — 4xx responses
  - `ServerException` — 5xx responses

Guzzle 7 has no `NetworkException` or `ResponseException`: `ConnectException` sits directly under `TransferException`, and `ClientException`/`ServerException` extend `BadResponseException` under `RequestException`.

```php
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use Psr\Http\Client\NetworkExceptionInterface;

try {
    $response = $client->get('/flaky-endpoint');
} catch (ClientException $e) { // 4xx — our fault, don't retry blindly
    $status = $e->getResponse()->getStatusCode();
    $body = (string) $e->getResponse()->getBody();
} catch (ServerException $e) { // 5xx — their fault, retrying may help
    $status = $e->getResponse()->getStatusCode();
} catch (NetworkExceptionInterface $e) { // network layer — no response object (Guzzle 7 and 8)
    $reason = $e->getMessage();
}
```

If you'd rather branch on status codes than exceptions (common in scraping, where a 404 is data, not an error), disable throwing:

```php
$response = $client->get('/maybe-missing', ['http_errors' => false]);
if ($response->getStatusCode() === 404) { /* handle it as a normal case */ }
```

## Retrying failed requests

Guzzle ships no `retries` option — the built-in answer is retry middleware on the handler stack:

```php
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Client\NetworkExceptionInterface;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;

$decider = function (int $retries, RequestInterface $req, ?ResponseInterface $res, $error) {
    if ($retries >= 3) return false;
    if ($error instanceof NetworkExceptionInterface) return true; // network errors, incl. timeouts
    return $res && in_array($res->getStatusCode(), [429, 500, 502, 503, 504]);
};

$delay = fn (int $retries) => 1000 * (2 ** ($retries - 1)); // 1s, 2s, 4s (ms)

$stack = HandlerStack::create();
$stack->push(Middleware::retry($decider, $delay));

$client = new Client(['handler' => $stack, 'timeout' => 15]);
```

Retry idempotent requests (GET, HEAD) freely; be careful with POSTs unless the endpoint deduplicates. For 429 responses, read `Retry-After` in the decider and honor it — hammering a rate-limited host with fixed backoff just extends the ban.

## Async requests and concurrency

Guzzle is single-threaded, but cURL's multi interface lets one process drive many sockets at once. Every request method has an async twin returning a promise:

```php
use GuzzleHttp\Promise\Utils;

$promises = [
    'products' => $client->getAsync('/products'),
    'reviews' => $client->getAsync('/reviews'),
    'prices' => $client->getAsync('/prices'),
];

$results = Utils::settle($promises)->wait(); // never throws; inspect each outcome

foreach ($results as $key => $result) {
    if ($result['state'] === 'fulfilled') {
        echo $key, ': ', $result['value']->getStatusCode(), PHP_EOL;
    } else {
        echo $key, ' failed: ', $result['reason']->getMessage(), PHP_EOL;
    }
}
```

For large URL lists, `Pool` caps how many requests are in flight simultaneously:

```php
use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;

$requests = function (array $urls) {
    foreach ($urls as $url) {
        yield new Request('GET', $url);
    }
};

$pool = new Pool($client, $requests($urls), [
    'concurrency' => 10,
    'fulfilled' => fn ($response, $index) => save($urls[$index], (string) $response->getBody()),
    'rejected' => fn ($reason, $index) => log_failure($urls[$index], $reason),
]);

$pool->promise()->wait();
```

A `concurrency` of 5–20 is a sane range for scraping one host; going higher mostly earns you 429s. Because the pool reuses the client's cURL handles, keep-alive connections carry across requests — this, plus reusing one `Client`, **is** Guzzle's connection pooling; there's no separate pool object to configure.

## Downloading files

Stream downloads to disk with `sink` — the body never has to fit in memory:

```php
$client->get('https://example.com/dataset.zip', [
    'sink' => '/tmp/dataset.zip',
    'timeout' => 300,
    'progress' => function ($totalBytes, $downloadedBytes) {
        // update a progress bar; $totalBytes is 0 if the server omits Content-Length
    },
]);
```

Or process a response as a stream without saving it:

```php
$response = $client->get('/big-export.csv', ['stream' => true]);
$body = $response->getBody();
while (!$body->eof()) {
    process_chunk($body->read(8192));
}
```

## Proxies

The `proxy` request option covers everything from a single gateway to per-protocol routing:

```php
// One proxy for everything, credentials inline
$client->get('/page', [
    'proxy' => 'http://user:pass@proxy.example.com:8080',
]);

// Per-protocol, with exclusions
$client = new Client([
    'proxy' => [
        'http' => 'http://proxy.example.com:8080',
        'https' => 'http://secure-proxy.example.com:8443',
        'no' => ['.internal.example.com', 'localhost'],
    ],
]);

// SOCKS5 (cURL handles the scheme; use socks5h to resolve DNS through the proxy)
$client->get('/page', ['proxy' => 'socks5h://127.0.0.1:9050']);
```

Guzzle also honors the standard proxy environment variables when no `proxy` option is set — `https_proxy`/`HTTPS_PROXY`, `NO_PROXY`, and lowercase `http_proxy` (uppercase `HTTP_PROXY` is deliberately ignored as an httpoxy defense). For scraping through rotating residential or datacenter proxies, put the gateway URL in the option and rotate at the provider level — our [proxy provider comparison](https://webscraping.ai/blog/best-proxy-providers-for-web-scraping) covers the trade-offs. And note what a proxy alone can't fix: JavaScript-rendered pages and fingerprinting-based blocks need a browser, not just a different IP.

## SSL certificate verification

Guzzle verifies TLS certificates by default using the system CA bundle. When PHP can't find one (common on Windows and in minimal containers), you'll see `cURL error 60: SSL certificate problem`. Fix it by pointing at a bundle, not by turning verification off:

```php
// Best: fix the environment (php.ini)
// curl.cainfo = /path/to/cacert.pem (download from https://curl.se/docs/caextract.html)

// Or per client:
$client = new Client(['verify' => '/path/to/cacert.pem']);

// Client certificates for mutual TLS
$client->get('/secure', [
    'cert' => ['/path/client.pem', 'cert-password'],
    'ssl_key' => '/path/client.key',
]);

// Last resort only — disables MITM protection; never ship to production
$client->get('/dev-server', ['verify' => false]);
```

## Logging and debugging

For a quick look at what's on the wire, `'debug' => true` dumps cURL's verbose output to STDOUT. For structured logging, push `Middleware::log` onto the handler stack:

```php
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\MessageFormatter;

$stack = HandlerStack::create();
$stack->push(Middleware::log(
    $psrLogger, // any PSR-3 logger (Monolog etc.)
    new MessageFormatter('{method} {uri} -> {code} ({res_header_Content-Length} bytes)')
));

$client = new Client(['handler' => $stack]);
```

`MessageFormatter` placeholders can include request/response headers and bodies (`{req_body}`, `{res_body}`) — handy in development, dangerous in production logs if requests carry credentials.

## Guzzle vs cURL, Laravel HTTP, and Symfony HttpClient

| Tool | What it is | When to pick it |
| --- | --- | --- |
| Raw cURL extension | The C library PHP binds directly | Maximum control, no dependencies; verbose and easy to get subtly wrong |
| **Guzzle** | PSR-7/PSR-18 client over cURL | The default for standalone PHP: middleware, promises, pools, huge ecosystem |
| Laravel `Http` facade | Laravel's wrapper **around Guzzle** | Inside Laravel — terser syntax, testing fakes; it's still Guzzle underneath |
| Symfony HttpClient | Symfony's independent client | Symfony apps; native HTTP/2 push, lazy responses |

Guzzle is itself built on cURL (via `CurlHandler`/`CurlMultiHandler`), so "Guzzle vs cURL" isn't performance — it's ergonomics, testability (`MockHandler`), and middleware. If you're comparing the raw approaches, our [cURL commands guide](https://webscraping.ai/blog/curl-commands-for-web-scraping) shows the equivalent one-liners.

## Using Guzzle for web scraping

Guzzle fetches static HTML quickly, and pairs with `symfony/dom-crawler` or `voku/simple_html_dom` for parsing (see the [PHP scraping guide](https://webscraping.ai/blog/web-scraping-with-php) for the full stack). What it can't do: execute JavaScript, pass browser fingerprinting checks, or maintain a clean IP reputation. When a target returns empty markup or 403s regardless of headers, delegate the fetch to [WebScraping.AI](https://webscraping.ai/) — one Guzzle request, rendered in a real browser with rotating proxies:

```php
$client = new Client(['base_uri' => 'https://api.webscraping.ai']);

$response = $client->get('/html', [
    'query' => [
        'api_key' => $apiKey,
        'url' => 'https://example.com/spa-products',
        'js' => 'true', // real browser rendering, proxies included
    ],
]);
$html = (string) $response->getBody(); // parse with your usual tools
```

The [/ai/fields endpoint](https://webscraping.ai/ai-web-scraping) goes a step further and returns structured JSON from fields you describe in plain English — no selectors to maintain, which is what keeps long-running jobs like [price monitoring](https://webscraping.ai/use-cases/price-monitoring) from breaking on every redesign. Every parameter (`proxy`, `country`, `wait_for`, `device`, `timeout`) is listed in the [API reference](https://webscraping.ai/docs); failed requests aren't billed, so a `Pool` that hits a wall of 403s doesn't cost you anything.

## Frequently asked questions

**What is Guzzle used for in PHP?** Sending HTTP requests: consuming REST APIs, webhooks, file transfers, and web scraping. It wraps cURL in a PSR-7/PSR-18 interface with middleware, async promises, and connection reuse, and it's the client underneath Laravel's `Http` facade and most PHP SDKs.

**Should I use Guzzle or cURL directly?** Guzzle, in almost all application code. It uses cURL internally, so there's no meaningful performance gap — what you gain is readable request options, a sane exception hierarchy, retry/log middleware, async pools, and `MockHandler` for tests. Drop to raw cURL only for exotic options Guzzle doesn't expose.

**Does Laravel use Guzzle?** Yes. Laravel's `Http` facade is a thin, expressive wrapper around Guzzle — `Http::retry(3, 100)->get($url)` gives you retries without writing the middleware described in this guide. Anything Guzzle can do, you can reach from Laravel by passing options through `withOptions()`.

**Does Guzzle have built-in retries?** There's no `retries` request option, but the framework ships `Middleware::retry()` — you supply a decider (when to retry) and a delay function (how long to wait), and push it onto the client's handler stack. See the retry section above for a production-ready example.

**How does Guzzle send concurrent requests without threads?** Through cURL's multi interface: one PHP process registers many transfers and cURL multiplexes the sockets. `getAsync()` returns promises; `Pool` manages large batches with a concurrency limit. It's cooperative I/O concurrency — CPU-bound work still blocks everything.

**Is Guzzle 6 still supported?** No — Guzzle 6 reached end of life. Guzzle 8 is the current line and 7.x is in maintenance. Going from 6 to 7 is usually painless (see the version table above): bump the constraint to `^7.0`, run your tests, and check any code that extends Guzzle internals or pins `guzzlehttp/psr7` v1. Moving on to `^8.0` takes more care, mainly around exception classes and header value types.
