PHP Guzzle Guide: Requests, Async Concurrency, Retries, and Proxies
Scraping
15 minutes reading time
Updated

PHP Guzzle Guide: Requests, Async Concurrency, Retries, and Proxies

Table of contents

Guzzle 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)

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 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:

ChangeGuzzle 6Guzzle 7
PHP requirement5.5+7.2.5+
PSR-18 (ClientInterface)NoYes — drop-in for PSR-18 consumers
ExceptionsGuzzleException interfaceSame interface, now extends Throwable
TypesDocBlock-onlyNative parameter/return types
guzzlehttp/psr7v1v1 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 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:

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:

$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 covers patterns and pitfalls.

POST requests: JSON, forms, and multipart uploads

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

// 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:

$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.

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:

$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:

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 async requests: one PHP process driving many concurrent HTTP connections in parallel

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:

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:

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:

$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:

$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:

// 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 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:

// 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:

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

ToolWhat it isWhen to pick it
Raw cURL extensionThe C library PHP binds directlyMaximum control, no dependencies; verbose and easy to get subtly wrong
GuzzlePSR-7/PSR-18 client over cURLThe default for standalone PHP: middleware, promises, pools, huge ecosystem
Laravel Http facadeLaravel's wrapper around GuzzleInside Laravel — terser syntax, testing fakes; it's still Guzzle underneath
Symfony HttpClientSymfony's independent clientSymfony 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 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 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 — one Guzzle request, rendered in a real browser with rotating proxies:

$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 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 from breaking on every redesign. Every parameter (proxy, country, wait_for, device, timeout) is listed in the API reference; 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.

Get Started Now

WebScraping.AI provides rotating proxies, Chromium rendering and built-in HTML parser for web scraping
Icon