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. One is Guzzle, which many frameworks and SDKs depend on, and the other is the raw curl_* functions that ship with PHP. Both support proxies. 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 check that proves the proxy carries your traffic.
Search follows PHP's two built-in lanes, curl and the stream wrapper. Keyword Planner puts "php curl" and "php file_get_contents" at 2,400 searches a month each worldwide, and "file_get_contents proxy" at 10.
Every example is copy-paste runnable. Where one needs a live proxy, pull a fresh one from our free proxy API, which returns recently checked entries without a key.
What does each recipe actually send?
Before the recipes, the evidence. On September 16, 2026, we ran PHP 8.3.25 against a stub target and a stub proxy on our own machine. The proxy logs the request line it is given. Ten shapes, one row each:
What each PHP recipe sent, and what came back
| 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 |
HProxy, PHP 8.3.25 against stub HTTP and SOCKS5 proxies and a stub target on our own machine, September 16, 2026; nothing outside the machine was contacted
Three rows there decide whether a PHP proxy works, and two of them are about the lane you chose rather than the proxy.
Why does file_get_contents need request_fulluri?
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. 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 makes PHP send the whole address, and it "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://. The stream wrapper also 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. 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
Can one string cover 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 the 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.
How do I split proxies by scheme and add a bypass list?
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 read proxy variables from the environment. Its quickstart documents HTTPS_PROXY, and HTTP_PROXY on the command line only. With no proxy option, its cURL handler leaves libcurl to honor a lowercase http_proxy, which our ext/curl run showed. So a proxy exported in your shell can route your Guzzle traffic; set the proxy option when you want to be sure where it goes. 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. The moment you pass the proxy request option yourself, that automatic population stops. 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. That silent gap can leak an internal host through the proxy.
How do I 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. Otherwise the parser reads the password as part of the host. A rejected credential comes back as HTTP 407 Proxy Authentication Required. That 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". That SOCKS default catches people who expected 8080.
These are the four options that carry a proxy, with the values each one takes:
The four ext/curl options that carry a proxy
| 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 for the proxy; the target never sees them |
libcurl documentation for CURLOPT_PROXY, CURLOPT_PROXYPORT and CURLOPT_PROXYTYPE, read September 9, 2026, and the PHP manual for curl_setopt
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);
What separates CURLPROXY_SOCKS5 from 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. Your local resolver then 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.
How does the scheme form work in curl and in Guzzle?
cURL's own docs call it "often more convenient to specify the proxy type with the scheme part of the CURLOPT_PROXY string". 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 and no SOCKS. 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 hands you one endpoint and a fresh residential IP per request, which removes list management entirely. Rotating a list by hand first is still 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 the option was set on the wrong client, or the target was in the no bypass list. Look at the headers that arrive at https://httpbin.org/headers too. A transparent proxy can pass your real address along in X-Forwarded-For while origin looks changed. Our proxy checker does the same in one paste and adds the anonymity grade, the location and the latency.
Where should I 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. Then verify every exit before you trust it. For a live pool to test against, the free proxy list shows when each entry was last checked. The free API hands the entries 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. Proxies for web scraping covers choosing the right proxy type and the request hygiene no HTTP client can add for you. When a project goes to production, our paid pools charge $0.50/GB for one GB of residential traffic, falling to $0.44/GB at 2,000 GB+. 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, and embedding credentials as userinfo. It also carries theNO_PROXYcaveat: 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. It also notes that the type can be set through 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 September 9, 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 September 9, 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 September 16, 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.
-
Google Keyword Planner, worldwide 12-month averages for the searches in the chart, read October 22, 2026.
-
Measured by HProxy on September 16, 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.



