This guide walks through every part of a working Guzzle proxy configuration: the proxy request option in its string and array forms, authenticated proxy URLs, HTTPS targets through a CONNECT tunnel, timeouts and certificate verification, and the exceptions Guzzle throws when the proxy says no. By the end you will have a reusable PHP client that routes through a residential proxy, keeps one exit IP across a session, verifies that IP, and retries with a fresh session when a request fails.
All examples target Guzzle 7 and PHP 8.1 or later. Install with composer require guzzlehttp/guzzle:^7.
The proxy option: string or array
Guzzle configures a proxy through the proxy request option, documented at docs.guzzlephp.org. You can pass it per request or in the client constructor as a default. The simplest form is a string, which applies to every protocol.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client([
'proxy' => 'http://USERNAME:PASSWORD@premium-residential.geonode.com:9000',
'timeout' => 30,
]);
$response = $client->get('https://api.ipify.org?format=json');
echo $response->getBody(), PHP_EOL;
The array form lets you choose a different proxy per URI scheme and exclude hosts. Three keys are recognised: http, https and no.
<?php
$proxyUrl = 'http://USERNAME:PASSWORD@premium-residential.geonode.com:9000';
$client = new GuzzleHttp\Client([
'proxy' => [
'http' => $proxyUrl, // used for http:// targets
'https' => $proxyUrl, // used for https:// targets (CONNECT tunnel)
'no' => ['localhost', '.internal.example'], // never proxied
],
]);
Use the string form when everything should go through one proxy. Use the array form when you need the no list or want plain HTTP requests to go through a datacenter pool while HTTPS goes residential.
The no list and the NO_PROXY caveat
Guzzle reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment when you do not set a proxy option. The moment you do set one, that automation stops, including the exclusion list. The official docs are explicit that it is then up to you to pass no yourself. If your container sets NO_PROXY for internal services, forgetting this means those calls suddenly route through the residential proxy.
$no = getenv('NO_PROXY') ? explode(',', getenv('NO_PROXY')) : [];
$client = new GuzzleHttp\Client([
'proxy' => [
'http' => $proxyUrl,
'https' => $proxyUrl,
'no' => $no,
],
]);
Guzzle proxy authentication: credentials in the URL
Guzzle has no separate proxy_auth option. Credentials go into the proxy URL as userinfo (http://username:password@host:port), and the cURL handler sends them as Proxy-Authorization: Basic. Two practical rules follow from that.
First, percent-encode anything in the password that is not URL-safe. A @, : or / in a raw password breaks URL parsing and produces a confusing 407 or a connection to the wrong host. Build the URL with rawurlencode() rather than string concatenation.
function proxyUrl(string $user, string $pass, int $port = 9000): string
{
return sprintf(
'http://%s:%s@premium-residential.geonode.com:%d',
rawurlencode($user),
rawurlencode($pass),
$port
);
}
Second, keep credentials out of the code. Read them from environment variables or a secrets manager and never log the full proxy URL; Guzzle's debug output includes the Proxy-Authorization header, so redact it before shipping logs anywhere.
HTTPS targets through an HTTP CONNECT proxy
A common point of confusion: the proxy URL scheme describes how you talk to the proxy, not what the target uses. For an https:// target, Guzzle (via cURL) opens a plain HTTP connection to the proxy, sends CONNECT host:443, then performs the TLS handshake with the target inside that tunnel. So both the http and https keys in the array form normally point to an http:// proxy URL. Writing https://premium-residential.geonode.com:9000 would tell cURL to wrap the proxy hop itself in TLS, which the endpoint does not expect.
Because TLS runs end to end inside the tunnel, the proxy never sees or alters the response body, and your normal certificate verification applies to the target site exactly as it would without a proxy.
verify and certificate problems
The verify option controls certificate checks for the target. Leave it at the default true, which uses the system CA bundle. If PHP cannot find a bundle (common on minimal Docker images), point verify at a PEM file rather than disabling it.
$client = new GuzzleHttp\Client([
'proxy' => $proxyUrl,
'verify' => '/etc/ssl/certs/ca-certificates.crt', // or true for the system default
]);
Setting verify => false to silence an error that appeared only after adding a proxy is a mistake; a correctly configured CONNECT tunnel does not cause certificate errors, so the real problem is elsewhere, usually the https:// scheme mix-up above.
Timeouts: timeout and connect_timeout
Guzzle's default timeout is 0, meaning wait forever. Through a residential proxy that is a bad default, because a slow exit device can stall a worker indefinitely. Set two values: connect_timeout bounds the TCP connection to the proxy and is supported by the cURL handler only; timeout bounds the whole transfer in seconds.
$client = new GuzzleHttp\Client([
'proxy' => $proxyUrl,
'connect_timeout' => 10, // seconds to reach the proxy
'timeout' => 45, // total seconds for the request
]);
Residential exits are slower than datacenter ones, so give the total timeout more room than you would for a direct request. A connect timeout that trips consistently points to a network path problem rather than the target.
Handling exceptions from a Guzzle proxy
Guzzle throws typed exceptions, all extending GuzzleHttp\Exception\RequestException, and knowing which one you got tells you where the failure happened.
| Exception | When it is thrown | Typical proxy cause |
|---|
ConnectException | No HTTP response at all | Proxy host unreachable, wrong port, connect timeout, DNS failure |
ClientException | 4xx response | 407 wrong credentials; 403/429 from the target |
ServerException | 5xx response | Target error, or proxy could not reach the target |
RequestException | Everything else | Total timeout after headers, TLS failure |
A 407 is worth isolating. It is the proxy, not the target, refusing the request, so retrying will not help until the credentials change.
<?php
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\RequestException;
try {
$response = $client->get('https://example.com/');
} catch (ConnectException $e) {
// Could not reach the proxy or the connect_timeout expired.
error_log('connect: ' . $e->getMessage());
} catch (ClientException $e) {
$status = $e->getResponse()->getStatusCode();
if ($status === 407) {
// Proxy rejected credentials: check username grammar and password.
throw new RuntimeException('proxy auth failed', 407, $e);
}
// 403, 404, 429 etc. come from the target site.
} catch (ServerException $e) {
// 5xx from the target (or the proxy failing to reach it).
} catch (RequestException $e) {
// Timeouts after connect, TLS errors and other transfer failures.
}
Turning off exceptions with http_errors
If you would rather branch on the status code than catch exceptions, set http_errors => false. Guzzle then returns the response for any status, including 407, and you check getStatusCode() yourself. ConnectException is still thrown because there is no response to return.
$response = $client->get('https://example.com/', ['http_errors' => false]);
if ($response->getStatusCode() === 407) {
// handle auth failure without an exception
}
Diagnostics: on_stats and debug
When a Guzzle proxy request is slow or fails intermittently, two options tell you what happened on the wire. on_stats receives a GuzzleHttp\TransferStats object after each transfer, with the total time and the cURL handler statistics such as name lookup, connect and TLS timings.
use GuzzleHttp\TransferStats;
$client->get('https://example.com/', [
'on_stats' => function (TransferStats $stats) {
printf("%s took %.3fs\n", $stats->getEffectiveUri(), $stats->getTransferTime());
if (!$stats->hasResponse()) {
var_dump($stats->getHandlerErrorData());
}
},
]);
debug => true enables CURLOPT_VERBOSE and prints the CONNECT exchange, including the proxy's response line. Use it once to confirm the tunnel is established, then remove it: it writes credentials to STDOUT. Passing an fopen() stream instead of true directs the output to a file.
Sticky sessions on port 10000
Port 9000 rotates the exit IP on every request. For multi-step flows (log in, then fetch pages, then paginate) you need the same IP, which is what port 10000 provides. The session is selected through the username, not a header: append -session-XXXXXXXX with an 8-character alphanumeric id, and optionally -lifetime-SECONDS from 180 to 86400. Targeting suffixes such as -country-us go in the same string.
<?php
function stickyProxyUrl(string $baseUser, string $pass, string $country = 'us', int $lifetime = 600): string
{
$session = substr(bin2hex(random_bytes(4)), 0, 8); // 8 alphanumeric chars
$user = sprintf('%s-country-%s-session-%s-lifetime-%d', $baseUser, $country, $session, $lifetime);
return sprintf(
'http://%s:%s@premium-residential.geonode.com:10000',
rawurlencode($user),
rawurlencode($pass)
);
}
$client = new GuzzleHttp\Client([
'proxy' => stickyProxyUrl(getenv('GEONODE_USER'), getenv('GEONODE_PASS')),
'timeout' => 45,
]);
Every request from $client now shares one exit IP for ten minutes. Create a new client (or pass a new proxy option) when you want a different IP.
Verifying the exit IP
Before trusting a session, confirm what the target will see. Hit an IP echo service through the same client twice: with a sticky session both calls return the same address, with port 9000 they differ.
$first = json_decode((string) $client->get('https://api.ipify.org?format=json')->getBody(), true);
$second = json_decode((string) $client->get('https://api.ipify.org?format=json')->getBody(), true);
printf("exit IP: %s (stable: %s)\n", $first['ip'], $first['ip'] === $second['ip'] ? 'yes' : 'no');
A small retry helper that rotates the session
Residential exits can disappear mid-session, and targets sometimes block an individual IP. A pragmatic approach is to retry a failed request with a new session id, which both changes the exit IP and avoids reusing a connection that is already in a bad state. The helper below rebuilds the client per attempt and only retries on connection failures, timeouts and status codes that indicate blocking.
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
use Psr\Http\Message\ResponseInterface;
function fetchWithNewSession(string $url, int $attempts = 3): ResponseInterface
{
$lastError = null;
for ($i = 1; $i <= $attempts; $i++) {
$client = new Client([
'proxy' => stickyProxyUrl(getenv('GEONODE_USER'), getenv('GEONODE_PASS')),
'connect_timeout' => 10,
'timeout' => 45,
'http_errors' => false,
]);
try {
$response = $client->get($url);
$status = $response->getStatusCode();
if ($status === 407) {
throw new RuntimeException('proxy credentials rejected'); // do not retry
}
if (!in_array($status, [403, 429, 502, 503, 504], true)) {
return $response;
}
$lastError = new RuntimeException("status $status");
} catch (ConnectException | RequestException $e) {
$lastError = $e;
}
usleep(500_000 * $i); // simple linear backoff
}
throw new RuntimeException("gave up after $attempts attempts", 0, $lastError);
}
The same code works with port 9000 if you drop the session suffix, since every attempt gets a new IP anyway. Keep the attempt count low: a target that answers 403 three times in a row is telling you something about request fingerprinting, not about the proxy.
Common mistakes
Using an https:// scheme in the proxy URL
The proxy hop is plain HTTP; the target's HTTPS runs inside the CONNECT tunnel. Both array keys should carry an http:// proxy URL. The usual symptom is a ConnectException mentioning an SSL handshake with the proxy host.
Forgetting the no list after setting proxy
Once you pass proxy, NO_PROXY is ignored. Internal health checks, metadata endpoints and databases reached over HTTP will go through the residential proxy and fail, or worse, leak. Always pass no explicitly.
Leaving timeout at zero
Guzzle waits forever by default. A single stalled residential connection can hold a queue worker until a supervisor kills it. Set both connect_timeout and timeout on every proxied client.
Retrying a 407
A 407 means the proxy rejected the credentials. Retrying with the same username and password only burns time. Fail fast, log the username (never the password) and check the grammar suffixes.
Next step
The configuration above works unchanged with Geonode residential proxies: 2.5M IPs online across 195+ countries, HTTP, HTTPS and SOCKS5 on the same host, and sticky sessions up to 24 hours selected entirely through the username. Country, state, city and ASN targeting use the same grammar shown here, so no code changes are needed when you narrow the exit location. Plans are pay-as-you-go with no contract, starting at 10 GB for $7.92 per month, and a 3-day trial costs $5. See residential proxies for the full details, or compare tiers on the pricing page. For scraping patterns beyond proxy setup, the web scraping use case covers the rest of the pipeline.
FAQ
How do I set a Guzzle proxy for every request from one client?
Pass the proxy option in the client constructor instead of per request. new Client(['proxy' => 'http://user:pass@host:port']) applies the proxy to every request that client sends, and any request-level proxy option overrides it. This is the normal pattern when all traffic from a worker should leave through the same provider.
Why does my Guzzle proxy work for http:// URLs but fail for https:// targets?
Usually the https key in the array form is missing or points to an https:// scheme. Guzzle tunnels HTTPS targets through an HTTP proxy with CONNECT, so the proxy URL for the https key should still start with http://. Check the array form, and confirm the proxy port accepts CONNECT, which the Geonode ports do.
What does a 407 Proxy Authentication Required mean in Guzzle?
The proxy rejected your credentials before forwarding anything. Guzzle raises it as a ClientException with status 407 because it is a 4xx response. Check the username grammar, the password, and that special characters in the password are percent-encoded in the URL. With http_errors set to false you get the 407 response object instead of an exception.
Does Guzzle honour HTTP_PROXY and NO_PROXY environment variables?
Yes, when you do not pass a proxy option Guzzle reads HTTP_PROXY, HTTPS_PROXY and NO_PROXY from the environment. The moment you pass a proxy option yourself the automatic NO_PROXY handling stops, so you need to pass the no key explicitly, typically explode(',', getenv('NO_PROXY')).
Can I keep the same exit IP across several Guzzle requests?
Yes, use a sticky session. Point the Guzzle proxy at port 10000 and add -session-XXXXXXXX (8 alphanumeric characters) plus an optional -lifetime-SECONDS suffix to the username. Every request that reuses that username shares one exit IP for the lifetime you set, between 180 seconds and 24 hours.
Are there limits I should plan for when scraping through a Guzzle proxy?
Residential bandwidth is metered per gigabyte, so large HTML pages and images add up. Sticky sessions last at most 24 hours and can end earlier if the upstream device disconnects, so code must tolerate a changed exit IP. Also respect the target site's terms, robots rules and rate limits; a proxy does not change your legal obligations.