Knowledge

How to Use Residential Proxies in PHP with cURL and Guzzle

Proxies in PHP: cURL's CURLOPT_PROXY and CURLOPT_PROXYUSERPWD, Guzzle's proxy option, CURLOPT_HTTPPROXYTUNNEL, and per-request geo rotation.

Chris Collins

Chris Collins

July 26, 2026 · 9 min read

PHP still moves an enormous amount of the web’s server-to-server traffic, and most of it goes through one of two paths: raw cURL, or Guzzle sitting on top of cURL. Wiring a residential proxy into either is a few options, not a rewrite. The friction is in the details each one handles differently: how proxy authentication is passed, one setting that decides whether HTTPS through the proxy works at all, and the connection-reuse behavior that separates a fast scraper from one that pays a full handshake on every request.

This is the PHP entry in the same series as residential proxies with Python, with Playwright, and in Go: the code that works for both cURL and Guzzle, plus the PHP-specific traps.

Everything below uses Shifter’s residential gateway: one endpoint, p.shifter.io:443, with all targeting encoded in the username. Swap the host and credentials for another provider; the shape is the same.

The gateway model in one paragraph

The proxy username carries your authentication and your targeting. You do not switch endpoints to change country or session, you change the username string:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us targets the United States, sid pins a sticky session, ttl holds that IP for N seconds. Omit sid/ttl and every new connection rotates. The password stays constant. That whole string is the username you hand to cURL or Guzzle.

Raw cURL

cURL takes the proxy host in CURLOPT_PROXY and the credentials in CURLOPT_PROXYUSERPWD. You can also inline user:pass@host in the proxy string, but keeping the credentials in their own option is cleaner and avoids URL-encoding headaches with the long username.

<?php
$user = getenv('SHIFTER_USER') . '-country-us';
$pass = getenv('SHIFTER_PASS');
$ch = curl_init('https://api.ipify.org');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => 'p.shifter.io:443',
CURLOPT_PROXYUSERPWD => "$user:$pass",
CURLOPT_HTTPPROXYTUNNEL => true, // CONNECT tunnel for HTTPS through the proxy
CURLOPT_CONNECTTIMEOUT => 10, // connect phase only
CURLOPT_TIMEOUT => 30, // whole transfer
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, 'curl error: ' . curl_error($ch) . "\n");
} else {
echo $body, "\n"; // a US residential IP
}
curl_close($ch);

Two things to internalize. The $user string includes the targeting flags (-country-us), because the geo lives there. And CURLOPT_HTTPPROXYTUNNEL is what makes HTTPS-through-a-proxy work: it tells cURL to open a CONNECT tunnel so TLS is negotiated end-to-end with the target, not with the proxy. Leave it off and HTTPS requests through an HTTP proxy fail or behave strangely. This is the single most common PHP-proxy mistake.

Guzzle

Guzzle takes the proxy through the proxy request (or client) option, credentials inlined in the URL. Because Guzzle is a cURL wrapper, the same tunnel behavior applies under the hood, but Guzzle handles the CONNECT for https:// targets automatically.

<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$user = getenv('SHIFTER_USER') . '-country-us';
$pass = getenv('SHIFTER_PASS');
$proxy = "http://$user:$pass@p.shifter.io:443";
// Build the client ONCE and reuse it (see the connection-reuse note below).
$client = new Client([
'proxy' => $proxy,
'connect_timeout' => 10, // connect phase
'timeout' => 30, // whole request
]);
$res = $client->get('https://api.ipify.org');
echo $res->getBody(), "\n"; // a US residential IP

Note the separate connect_timeout and timeout. That connect-versus-total split is exactly what makes timeouts diagnosable when something stalls: a slow connect points at your side or a missing matching IP, a slow total points at the target.

Guzzle also accepts the proxy option per request and as an array keyed by scheme, which is how you route only https through the proxy or set a no bypass list:

$res = $client->get('https://example.com', [
'proxy' => [
'http' => $proxy,
'https' => $proxy,
'no' => ['localhost', '127.0.0.1'],
],
]);

Trap 1: reuse the Guzzle client (and the cURL handle)

A fresh GuzzleHttp\Client per request, or a fresh curl_init() per request, pays a full TCP + TLS handshake through the proxy every single time, the overhead the latency guide exists to remove. Guzzle keeps a cURL handle pool underneath and reuses connections when you reuse the client. Build one client at startup, inject it, and hold it.

For raw cURL, reuse the handle across requests and change only the URL between calls, so the connection stays warm:

$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => 'p.shifter.io:443',
CURLOPT_PROXYUSERPWD => "$user:$pass",
CURLOPT_HTTPPROXYTUNNEL => true,
]);
foreach ($urls as $url) {
curl_setopt($ch, CURLOPT_URL, $url); // reuse the handle, keep the connection
$body = curl_exec($ch);
// ... handle $body
}
curl_close($ch);

Trap 2: PHP’s default is to fetch one URL at a time

PHP request handlers are synchronous by default. A scraper that loops over curl_exec fetches strictly one URL at a time, which is fine for small jobs and painfully slow at scale. Two ways up:

Guzzle async with a bounded pool, so N requests are in flight but never more:

use GuzzleHttp\Pool;
use GuzzleHttp\Psr7\Request;
$requests = function ($urls) {
foreach ($urls as $u) { yield new Request('GET', $u); }
};
$pool = new Pool($client, $requests($urls), [
'concurrency' => 8, // cap in-flight requests
'fulfilled' => function ($response, $i) { /* handle */ },
'rejected' => function ($reason, $i) { /* log + retry */ },
]);
$pool->promise()->wait();

Or curl_multi_* if you are on raw cURL. Either way, cap concurrency per target host, not globally, so one fragile site is not hammered while a permissive one is starved. More parallelism past a target’s tolerance buys blocks, not throughput (how to avoid getting blocked).

Trap 3: consume the response, and check the transport error, not just the status

cURL returns false on a transport failure (proxy refused, tunnel failed, timeout) and a body string on an HTTP response, even a 407 or 502. Check curl_exec’s return against false and read curl_error/curl_errno before you trust the status code. In Guzzle, a connection failure throws ConnectException while a 4xx/5xx throws RequestException only if http_errors is on (it is, by default). Handle both, and always read the body so the handle is free to reuse:

use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\RequestException;
try {
$res = $client->get($url);
$body = (string) $res->getBody(); // drain the body
} catch (ConnectException $e) {
// transport: proxy/tunnel/timeout — retry with a fresh identity
} catch (RequestException $e) {
// HTTP status — inspect $e->getResponse()->getStatusCode()
}

Rotating geo and sessions

Because the targeting lives in the username, a different identity is a different credential string.

Raw cURL: set CURLOPT_PROXYUSERPWD to the new username before the call. The same handle can carry different identities across iterations.

Guzzle: pass the proxy option per request to override the client default, so one client serves many identities without rebuilding:

function userFor(string $country, ?string $sid = null): string {
$u = getenv('SHIFTER_USER') . '-country-' . $country;
if ($sid !== null) { $u .= '-sid-' . $sid . '-ttl-600'; }
return $u;
}
$pass = getenv('SHIFTER_PASS');
$proxy = 'http://' . userFor('de', 'job-42') . ":$pass@p.shifter.io:443";
$res = $client->get('https://example.com', ['proxy' => $proxy]);

Give each logical unit of work its own sid and rotate between units, not mid-stream (sticky vs rotating covers the distinction), and map work to identities the way the load-balancing post describes.

Verify you are actually on the proxy

Before you benchmark or debug anything else, confirm the exit IP:

$res = $client->get('http://ip-api.com/json');
echo $res->getBody(), "\n"; // expect a residential IP in the targeted country

Your own IP means the proxy option is not being applied. A hang means local egress is blocked. Both are covered in the timeout diagnosis guide.

FAQ

Why do my HTTPS requests fail through the proxy in raw cURL? You are almost certainly missing CURLOPT_HTTPPROXYTUNNEL. HTTPS through an HTTP proxy needs a CONNECT tunnel so TLS is negotiated with the target, not the proxy. Set CURLOPT_HTTPPROXYTUNNEL => true. Guzzle does this automatically for https:// targets, which is why the trap only bites raw cURL.

Should I put the proxy credentials in the URL or in a separate option? Either works. In raw cURL, CURLOPT_PROXYUSERPWD keeps the long gateway username out of the URL and avoids URL-encoding issues. In Guzzle, inlining user:pass@host in the proxy string is the normal path. Pick one and be consistent.

My PHP scraper is slow even though the proxy is fast. Why? Most likely you are creating a new Guzzle client (or curl_init) per request, paying a fresh handshake each time, or you are fetching strictly one URL at a time. Reuse one client/handle, and use a bounded Guzzle Pool or curl_multi to run several requests concurrently.

How do I rotate IPs per request in PHP? Vary the proxy username. In raw cURL, set CURLOPT_PROXYUSERPWD to the new username before each call. In Guzzle, pass the proxy option per request. Both let one shared client or handle serve many identities.

cURL or Guzzle for scraping? Guzzle gives you async pools, middleware, retries, and PSR-7 for a small dependency; raw cURL is dependency-free and slightly faster per call. Use Guzzle when you want concurrency and structure, raw cURL for tight, minimal scripts. The proxy setup and traps above apply to both, since Guzzle runs on cURL.

The bottom line

PHP plus residential proxies is solid once you respect each client’s rules: in raw cURL, set the proxy host and CURLOPT_PROXYUSERPWD, and never forget CURLOPT_HTTPPROXYTUNNEL for HTTPS; in Guzzle, pass the proxy option and let it tunnel for you. Reuse one client or handle so connections stay warm, run requests concurrently with a bounded pool instead of one at a time, cap concurrency per target host, and check the transport error, not just the status code. Vary the proxy username to change geo or session.

Get that right and both paths perform the way PHP should at scale. Point them at the residential gateway, and remember that pool quality decides how often you retry at all (IP reputation). The pricing page has the per-GB plans to test it against your own targets.

Ready to get started?

Try Shifter's residential proxies, 205M+ IPs, 195+ countries, from $0.75/GB.

Get Started