Most PHP code that talks to the network does it through one of two things: Guzzle, which almost every framework and SDK depends on, or the raw curl_* functions that ship with PHP. Both support proxies, and they wire them up differently enough that copying a Guzzle example into a curl script (or the reverse) gets you nowhere. This guide covers both, plus SOCKS5, authentication, rotating a pool, and the verification step that proves the proxy is actually carrying your traffic.
We run a proxy network and write a fair amount of PHP against it, so the mistakes below are ones we see every week: a Guzzle client that quietly went direct because someone expected it to read http_proxy like the curl CLI does, a SOCKS proxy handed to the stream wrapper that silently does nothing. Every example is copy-paste runnable. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns real, recently checked endpoints without a key.
How do you use a proxy with PHP?
In Guzzle, pass the proxy request option: a single string like http://user:pass@host:port, or an array keyed by scheme (http, https, no). In raw ext/curl, set CURLOPT_PROXY to the proxy address and, for SOCKS, CURLOPT_PROXYTYPE. Guzzle uses cURL under the hood by default, so the two share the same engine, but the option names are completely different.
Guzzle: the proxy request option
Guzzle is where most PHP developers meet proxies, and its proxy request option is refreshingly simple. Install it if you have not already:
composer require guzzlehttp/guzzle
The simplest form is a single string, applied to every protocol:
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
$client = new Client();
$res = $client->get('https://httpbin.org/ip', [
'proxy' => 'http://203.0.113.7:8080',
'timeout' => 15,
]);
echo $res->getBody(), PHP_EOL;
For an https:// target, Guzzle (through cURL) opens a CONNECT tunnel and performs TLS end to end with the origin, so a plain HTTP proxy carries your encrypted traffic without seeing inside it. That is why http://proxy:port against an https:// URL is the standard, correct pairing even though the schemes look mismatched.
If you want different proxies for HTTP and HTTPS, or a list of hosts that should bypass the proxy, Guzzle takes an array keyed by scheme. The no key is a bypass list, and its keys are exactly http, https, and no:
$res = $client->get('https://httpbin.org/ip', [
'proxy' => [
'http' => 'http://203.0.113.7:8080', // used for http:// targets
'https' => 'http://203.0.113.24:3128', // used for https:// targets
'no' => ['localhost', '127.0.0.1'], // these bypass the proxy
],
]);
One behavior worth knowing so it does not surprise you: Guzzle does not read http_proxy or https_proxy from the environment the way the curl command line does. A proxy exported in your shell will not silently route your Guzzle traffic, which is usually what you want. The one proxy-related variable in play is NO_PROXY, and it carries a catch worth reading twice. Guzzle populates its default bypass list from NO_PROXY, but the moment you pass the proxy request option yourself, that automatic population no longer applies. Per its documentation, it is then on you to supply the no value parsed from the variable, for example explode(',', getenv('NO_PROXY')). So a proxy option set without a no key means NO_PROXY is ignored for that request, which is exactly the kind of silent gap that leaks an internal host through the proxy.
To proxy every request from a client without repeating the option, set it as a client default:
$client = new Client([
'proxy' => 'http://203.0.113.7:8080',
'timeout' => 15,
]);
Guzzle proxy authentication
Paid proxies authenticate with a username and password. In Guzzle you embed them in the proxy URL as userinfo:
$res = $client->get('https://httpbin.org/ip', [
'proxy' => 'http://user:[email protected]:8080',
]);
If the password contains characters like @, :, or #, percent-encode them (rawurlencode) before splicing them into the URL, or the parser will read the password as part of the host. A rejected credential comes back as HTTP 407 Proxy Authentication Required, which is your signal that the proxy is reachable and only the login is wrong. We cover that specific status in how to fix 407 Proxy Authentication Required.
Raw ext/curl
When you are not using Guzzle, or you want control Guzzle does not expose, the curl_* functions map straight onto libcurl's options. The proxy lives in CURLOPT_PROXY, and credentials in CURLOPT_PROXYUSERPWD:
<?php
$ch = curl_init('https://httpbin.org/ip');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => '203.0.113.7:8080',
CURLOPT_PROXYUSERPWD => 'user:pass', // "username:password"
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_TIMEOUT => 15,
]);
$body = curl_exec($ch);
if ($body === false) {
fwrite(STDERR, 'curl error: ' . curl_error($ch) . PHP_EOL);
}
curl_close($ch);
echo $body, PHP_EOL;
CURLOPT_PROXY accepts either a bare host:port (cURL assumes an HTTP proxy) or a full URL with a scheme. Always write the port explicitly: if you omit it, cURL falls back to 1080, a SOCKS default that catches people who expected 8080.
SOCKS5 proxies
Neither Guzzle's stream wrapper nor a bare CURLOPT_PROXY string assumes SOCKS, so you tell cURL the proxy type. The CURLOPT_PROXYTYPE option accepts CURLPROXY_HTTP (the default), CURLPROXY_SOCKS4, CURLPROXY_SOCKS4A, CURLPROXY_SOCKS5, and CURLPROXY_SOCKS5_HOSTNAME:
curl_setopt($ch, CURLOPT_PROXY, '198.51.100.14:1080');
curl_setopt($ch, CURLOPT_PROXYTYPE, CURLPROXY_SOCKS5_HOSTNAME);
The distinction that matters is CURLPROXY_SOCKS5 versus CURLPROXY_SOCKS5_HOSTNAME. With plain CURLPROXY_SOCKS5, your machine resolves the target hostname and hands the proxy an IP, so your local resolver sees every host you visit and names that only resolve on the proxy's network fail. With CURLPROXY_SOCKS5_HOSTNAME the proxy does the DNS lookup, which is the remote-resolution behavior curl calls socks5h. For scraping or privacy, the _HOSTNAME variant is almost always what you meant. We go deeper on the split in what is a SOCKS5 proxy.
cURL's own docs note that it is "often more convenient to specify the proxy type with the scheme part of the CURLOPT_PROXY string" instead of the separate option, so this is equivalent:
curl_setopt($ch, CURLOPT_PROXY, 'socks5h://198.51.100.14:1080');
That scheme form is also how you get SOCKS out of Guzzle, since Guzzle passes the proxy string down to cURL:
$res = $client->get('https://httpbin.org/ip', [
'proxy' => 'socks5h://user:[email protected]:1080',
]);
This works when Guzzle is using its cURL handler, which it selects automatically whenever the curl extension is installed. On a build without ext-curl, Guzzle falls back to the PHP stream wrapper, which handles HTTP proxies but not SOCKS, so for SOCKS work make sure ext-curl is present.
Rotating across a pool
One IP sending every request is the exact shape a rate limiter watches for. Spread the load across a list and retry on a different proxy when one fails, because on any pool some exits are always down:
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
function getRotating(Client $client, string $url, array $pool, int $tries = 4)
{
for ($i = 0; $i < $tries; $i++) {
$proxy = $pool[array_rand($pool)];
try {
return $client->get($url, ['proxy' => $proxy, 'timeout' => 15]);
} catch (GuzzleException $e) {
continue; // dead or blocked exit, try the next one
}
}
throw new RuntimeException("all $tries proxies failed for $url");
}
$pool = [
'http://203.0.113.7:8080',
'http://203.0.113.24:3128',
'http://198.51.100.14:8080',
];
$res = getRotating(new Client(), 'https://httpbin.org/ip', $pool);
echo $res->getBody(), PHP_EOL;
You do not have to maintain the list by hand. Our free proxy API returns a fresh pool as plain text you can split straight into that array:
$raw = (string) (new Client())->get('https://hproxy.com/api/proxy-list', [
'query' => ['format' => 'txt', 'protocol' => 'http', 'recent' => 'true', 'limit' => 50],
])->getBody();
$pool = array_map(
fn ($p) => "http://$p",
array_filter(array_map('trim', explode("\n", $raw)))
);
For production, a rotating gateway that hands you one endpoint and a fresh residential IP per request removes list management entirely, but rotating a list by hand is the best way to understand what that gateway does for you.
Verify the exit IP
Never assume a proxy is working. Confirm the target sees the proxy's address and not yours, and do it before you trust an exit with real traffic:
<?php
use GuzzleHttp\Client;
$client = new Client(['timeout' => 15]);
$proxy = 'http://203.0.113.7:8080';
$real = json_decode($client->get('https://httpbin.org/ip')->getBody(), true)['origin'];
$viaProxy = json_decode($client->get('https://httpbin.org/ip', ['proxy' => $proxy])->getBody(), true)['origin'];
echo "real: $real", PHP_EOL;
echo "proxy: $viaProxy", PHP_EOL;
if ($real === $viaProxy) {
throw new RuntimeException('proxy is not changing your IP');
}
If the two IPs match, the request never went through the proxy, usually because the option was set on the wrong client or the target was in the no bypass list. It is also worth checking what headers arrive at https://httpbin.org/headers, because a transparent proxy can forward your real address in X-Forwarded-For even while origin looks changed. Our proxy checker runs that whole battery, exit IP, anonymity grade, real geolocation, and latency, in one paste if you would rather not script it.
Where to go from here
Two habits keep PHP proxy code alive unattended: pass an explicit timeout (and CURLOPT_CONNECTTIMEOUT on raw curl) so a silent proxy cannot hang the request, and verify every exit before you trust it. For a live pool to test against, the free proxy list re-verifies its entries every few minutes, and the free API hands them to you with no key.
From here, the cURL guide is the shell-side reference for the same libcurl options you just set from PHP, the Python requests guide is the closest sibling if your stack spans both languages, and proxies for web scraping covers choosing the right proxy type and the request hygiene no HTTP client can add for you. When a project graduates from experiments to production, get IPs nobody else is burning on our paid pools.
Sources
- Guzzle documentation: proxy request option: the string form, the array keyed by
http/https/no, embedding credentials as userinfo, and theNO_PROXYcaveat that you must parse the variable into thenokey yourself when passing the proxy option. - libcurl: CURLOPT_PROXYTYPE: the
CURLPROXY_HTTPdefault and theCURLPROXY_SOCKS4/SOCKS4A/SOCKS5/SOCKS5_HOSTNAMEconstants, plus the note that the type can be set via the scheme in the proxy string. - PHP manual: curl_setopt: the ext/curl option names
CURLOPT_PROXY,CURLOPT_PROXYUSERPWD, andCURLOPT_PROXYTYPEexposed to PHP.