Guzzle takes a proxy request option. Raw ext/curl takes CURLOPT_PROXY, whose value is a bare host:port or a full URL with a scheme. Always write the port. libcurl defaults to 1080 when it is missing, and that is a SOCKS port rather than the 8080 most people assume.
The two do not share a single option name. A Guzzle example pasted into a curl_setopt_array call proxies nothing, and the reverse is true as well.
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.
What each recipe actually sends
Before the recipes, the evidence. On 16 September 2026 we ran PHP 8.3.25 against a stub proxy on our own machine that logs the request line it is given, and a stub target. Ten shapes, one row each:
| What the PHP code did | What the proxy received | What PHP got back |
|---|---|---|
CURLOPT_PROXY | GET http://target/ | HTTP 200 |
CURLOPT_PROXYUSERPWD | the same, plus Proxy-Authorization: Basic ... | HTTP 200 |
CURLOPT_HTTPPROXYTUNNEL | CONNECT target:port | HTTP 200 |
CURLPROXY_SOCKS5 | an address, because PHP resolved the name | curl_errno 97 |
CURLPROXY_SOCKS5_HOSTNAME | the name | HTTP 200 |
| a dead proxy address | nothing | curl_errno 7 |
file_get_contents with a proxy | GET /, a path with no host | 400 Bad Request |
the same plus request_fulluri | GET http://target/ | the page |
only http_proxy in the environment, ext/curl | GET http://target/ | HTTP 200 |
only http_proxy in the environment, file_get_contents | nothing | the page, fetched direct |
Three rows there decide whether a PHP proxy works, and two of them are about the lane you chose rather than the proxy.
file_get_contents and the option nobody copies
The stream wrapper takes a proxy in a context, and the obvious spelling is wrong:
<?php
// This looks right and the proxy answers 400.
$context = stream_context_create(['http' => ['proxy' => 'tcp://203.0.113.7:8080']]);
echo file_get_contents('http://example.com/', false, $context);
Our stub proxy recorded what it was handed: GET /. A proxy cannot route a bare path, because it has no idea which host you meant, so it answers 400 and PHP reports failed to open stream. RFC 9112 puts the rule plainly: a client making a request to a proxy must send the target URI in absolute form.
One option fixes it, and the PHP manual describes it exactly: request_fulluri, which makes PHP send the whole address, and which "defaults to false".
<?php
$context = stream_context_create([
'http' => [
'proxy' => 'tcp://203.0.113.7:8080',
'request_fulluri' => true, // without this the proxy gets a path, not a URL
'timeout' => 8,
],
]);
echo file_get_contents('http://example.com/', false, $context);
With that line the proxy received GET http://target/ and the page came back. Two more things about this lane: the proxy address takes a tcp:// prefix rather than http://, and the stream wrapper does not speak SOCKS at all, so a SOCKS proxy needs the curl lane.
Is something proxying my PHP without asking?
Possibly, and only on one lane. We set http_proxy in the environment and wrote no proxy into the code. The curl lane used it: the stub proxy logged the request. file_get_contents ignored it entirely and fetched the page direct.
That is worth knowing in both directions. A script that looks unproxied can be proxied on a server where the variable is set, and a script that looks proxied by the environment is not, if it uses the stream wrapper. When it matters, set the proxy in the code and read it back from the response rather than trusting the shell.
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.
Your PHP app
Guzzle or curl_*
proxy option
CURLOPT_PROXY
Proxy
adds auth, exits
Target
sees the exit IP
How do I set a proxy in Guzzle?
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
A single string for every protocol
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.
An array keyed by scheme, with a bypass list
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
],
]);
Does Guzzle read http_proxy from the environment?
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.
Set the proxy as a client default
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,
]);
How do I add a username and password in Guzzle?
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:pass@203.0.113.7: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: what value does CURLOPT_PROXY take?
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. libcurl's own documentation puts it plainly: "If not specified, libcurl defaults to using port 1080 for proxies", a SOCKS default that catches people who expected 8080.
These are the four options that carry a proxy, with the values each one takes:
| Option | Value it takes | What the documentation says |
|---|---|---|
CURLOPT_PROXY | host:port, or scheme://host:port | "The proxy string may be prefixed with [scheme]:// to specify which kind of proxy is used", and "To specify port number in this string, append :[port] to the end of the host name" |
CURLOPT_PROXYPORT | the port as an integer | Sets the port "unless it is specified in the proxy string". Its default is 0, which leaves the choice to libcurl |
CURLOPT_PROXYTYPE | CURLPROXY_HTTP (default), CURLPROXY_SOCKS4, CURLPROXY_SOCKS4A, CURLPROXY_SOCKS5, CURLPROXY_SOCKS5_HOSTNAME | Only needed when the string carries no scheme: "Without a scheme prefix, CURLOPT_PROXYTYPE can be used to specify which kind of proxy the string identifies" |
CURLOPT_PROXYUSERPWD | the string "user:pass" | The credentials sent to the proxy, not to the target |
The schemes libcurl accepts in that string are http, https, socks4, socks4a, socks5 and socks5h. Setting the scheme and setting CURLOPT_PROXYTYPE do the same job, so pick one and stay with it.
How do I use a SOCKS5 proxy in PHP?
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);
CURLPROXY_SOCKS5 versus 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.
The scheme form, in curl and in Guzzle
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:pass@198.51.100.14: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.
How do I rotate proxies 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.
How do I check the proxy is really being used?
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. Ordering and regenerating those plans is an HTTP call too, written up in the developer documentation alongside authentication and the wallet endpoints.
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. -
libcurl: CURLOPT_PROXY, read 9 September 2026: "The proxy string may be prefixed with [scheme]:// to specify which kind of proxy is used", "To specify port number in this string, append :[port] to the end of the host name", "Without a scheme prefix, CURLOPT_PROXYTYPE can be used to specify which kind of proxy the string identifies", and "If not specified, libcurl defaults to using port 1080 for proxies".
-
libcurl: CURLOPT_PROXYPORT, read 9 September 2026: the port is used "unless it is specified in the proxy string CURLOPT_PROXY", with a default of
0. -
PHP manual, HTTP context options, read 16 September 2026:
proxytakes a "URI specifying address of proxy server", andrequest_fulluri"defaults to false". -
RFC 9112 section 3.2.2, June 2022: a client making a request to a proxy must send the target URI in absolute form.
-
Measured by HProxy on 16 September 2026 with PHP 8.3.25: ten shapes against stub HTTP and SOCKS5 proxies on this machine that record the request line, plus a stub target. Nothing outside the machine was contacted. The Guzzle parts of this page come from its documentation rather than from a run, because we do not install other people's packages on that machine.


