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/guzzleand create oneClientper service — Guzzle reuses cURL handles per client, which is where connection pooling comes from - Guzzle sets no request timeout by default — always configure
timeoutandconnect_timeout - 4xx/5xx responses throw
ClientException/ServerExceptionunless 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:
Poolruns hundreds of requests over cURL multi with aconcurrencycap - The
proxyoption accepts per-protocol proxies,noexclusion lists, credentials in the URL, andsocks5://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:
| 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 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-allConnectException— DNS failure, refused connection, timeout; no response availableRequestException— base for errors where a request was sentClientException— 4xx responsesServerException— 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
| 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 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.