Guide

How to Use Proxies With PHP (Guzzle and cURL)

How to route PHP requests through a proxy: the Guzzle proxy request option as a string or a per-scheme array, raw ext/curl with CURLOPT_PROXY and CURLOPT_PROXYTYPE, SOCKS5, proxy auth, rotating a pool, and verifying your exit IP.

HProxy Team · ·Updated July 17, 2026 ·8 min read
HProxy. Guide

Free proxies won't hold up here.

Shared datacenter IPs get flagged and dropped fast. When it has to hold, gaming, streaming, accounts, you need mobile and residential IPs that read as a real device, from $0.65/GB, pay as you go.

See plans & pricing

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 the NO_PROXY caveat that you must parse the variable into the no key yourself when passing the proxy option.
  • libcurl: CURLOPT_PROXYTYPE: the CURLPROXY_HTTP default and the CURLPROXY_SOCKS4/SOCKS4A/SOCKS5/SOCKS5_HOSTNAME constants, 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, and CURLOPT_PROXYTYPE exposed to PHP.

Frequently asked questions

Does Guzzle read the http_proxy environment variable automatically?
No. Unlike the curl command line, Guzzle does not pick up http_proxy or https_proxy on its own, so a proxy set in your shell will not silently route your Guzzle traffic. You have to pass the proxy request option explicitly. There is one catch around NO_PROXY: Guzzle populates its default bypass list from it, but once you pass the proxy option yourself you must parse NO_PROXY into the no key (for example explode(',', getenv('NO_PROXY'))), because Guzzle no longer does it for you. If you want environment-driven proxying, read getenv('http_proxy') yourself and pass it into the proxy option.
How do I use a SOCKS5 proxy in PHP?
With raw ext/curl, set CURLOPT_PROXY to the proxy address and CURLOPT_PROXYTYPE to CURLPROXY_SOCKS5, or to CURLPROXY_SOCKS5_HOSTNAME to have the proxy resolve DNS. With Guzzle you get the same result by putting the scheme in the proxy string, socks5h://host:port, which works as long as Guzzle is using its cURL handler, the default when the curl extension is installed. Plain PHP stream wrappers do not speak SOCKS, so the curl path is the one to use.
What is the difference between CURLPROXY_SOCKS5 and CURLPROXY_SOCKS5_HOSTNAME?
With CURLPROXY_SOCKS5 your machine resolves the target hostname and sends the proxy an IP, so your local resolver sees every host you visit. With CURLPROXY_SOCKS5_HOSTNAME the proxy performs the DNS lookup, which is the socks5h behavior curl exposes through the scheme. For privacy or for names that only resolve on the proxy's network, CURLPROXY_SOCKS5_HOSTNAME (or the socks5h:// scheme) is the one you want.
How do I pass a username and password to a proxy in PHP?
In Guzzle, put the credentials in the proxy URL as userinfo: http://user:pass@host:port. In raw curl, set CURLOPT_PROXYUSERPWD to the string 'user:pass'. A wrong or missing password comes back as HTTP 407 Proxy Authentication Required, which tells you the proxy is alive and only the credentials are wrong.
Why does -x https:// or a https:// proxy scheme not proxy my HTTPS traffic?
Because the https:// proxy scheme means the connection to the proxy itself is TLS, which very few proxies support. It is not related to the scheme of the URL you are fetching. A plain HTTP proxy carries your HTTPS requests fine by opening a CONNECT tunnel, so http://proxy:port used against an https:// target is the normal, correct combination even though the schemes look mismatched.

Proxies that don't die mid-job

Residential, ISP, datacenter and mobile, verified by the same engine that runs tens of millions of checks. They read as a real device and hold up under load. Pay as you go, and your balance never expires.

47M+ proxy checks run · 100+ countries · HTTP / HTTPS / SOCKS · re-checked every few minutes · no signup