Scraping
15 minutes reading time

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

composer require guzzlehttp/guzzle          # latest 7.x
composer require guzzlehttp/guzzle:^7.9     # pin a minor version
composer show guzzlehttp/guzzle             # check what's installed

Guzzle 7 is the current major line and requires PHP 7.2.5+ (it runs fine on PHP 8.x, which is what you should be on — see our PHP web scraping guide for the wider toolchain). 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 upgrade is a version-constraint bump; code that extended Guzzle internals or type-hinted concrete classes needs a closer look.

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/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/126.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 waits forever by default. 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,    // seconds between reads on a streaming body
]);

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

A timeout surfaces as GuzzleHttp\Exception\ConnectException, so your retry and error-handling code can treat "too slow" and "unreachable" the same way.

Error handling and the exception hierarchy

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

  • GuzzleException — interface every Guzzle error implements; catch-all
  • ConnectException — DNS failure, refused connection, timeout; no response available
  • RequestException — base for errors where a request was sent
    • ClientException — 4xx responses
    • ServerException — 5xx responses
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;

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 (ConnectException $e) {             // network layer — no response object
    $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 GuzzleHttp\Exception\ConnectException;
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 ConnectException) return true;                    // network errors
    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:

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 HTTP_PROXY, HTTPS_PROXY, and NO_PROXY environment variables when no proxy option is set. 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.

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) configures the same machinery 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; security fixes target the 7.x line. Upgrading 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.

Get Started Now

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