PHP sigue moviendo una cantidad enorme del tráfico servidor a servidor de la web, y la mayor parte pasa por uno de dos caminos: cURL puro, o Guzzle apoyado sobre cURL. Conectar un proxy residencial a cualquiera de los dos son unas cuantas opciones, no una reescritura. La fricción está en los detalles que cada uno maneja distinto: cómo se pasa la autenticación del proxy, un ajuste que decide si HTTPS a través del proxy funciona siquiera, y el comportamiento de reutilización de conexión que separa a un scraper rápido de otro que paga un handshake completo en cada petición.
Esta es la entrada de PHP de la misma serie que proxies residenciales con Python, con Playwright y con Go: el código que funciona para cURL y para Guzzle, más las trampas propias de PHP.
Todo lo de abajo usa el gateway residencial de Shifter: un endpoint, p.shifter.io:443, con todo el targeting codificado en el nombre de usuario. Cambia el host y las credenciales por otro proveedor; la forma es la misma.
El modelo del gateway en un párrafo
El nombre de usuario del proxy lleva tu autenticación y tu targeting. No cambias de endpoint para cambiar de país o de sesión, cambias la cadena del nombre de usuario:
customer-USERNAME-country-us-sid-abc123-ttl-600country-us apunta a Estados Unidos, sid fija una sesión sticky, ttl mantiene esa IP durante N segundos. Omite sid/ttl y cada conexión nueva rota. La contraseña se mantiene constante. Esa cadena entera es el nombre de usuario que le pasas a cURL o a Guzzle.
cURL puro
cURL toma el host del proxy en CURLOPT_PROXY y las credenciales en CURLOPT_PROXYUSERPWD. También puedes incrustar user:pass@host en la cadena del proxy, pero mantener las credenciales en su propia opción es más limpio y evita dolores de cabeza de codificación URL con el nombre de usuario largo.
<?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, // túnel CONNECT para HTTPS a través del proxy CURLOPT_CONNECTTIMEOUT => 10, // solo fase de conexión CURLOPT_TIMEOUT => 30, // transferencia completa]);
$body = curl_exec($ch);if ($body === false) { fwrite(STDERR, 'curl error: ' . curl_error($ch) . "\n");} else { echo $body, "\n"; // una IP residencial de EE. UU.}curl_close($ch);Dos cosas que interiorizar. La cadena $user incluye los flags de targeting (-country-us), porque ahí es donde vive la geo. Y CURLOPT_HTTPPROXYTUNNEL es lo que hace que HTTPS a través de un proxy funcione: le dice a cURL que abra un túnel CONNECT para que TLS se negocie de extremo a extremo con el destino, no con el proxy. Déjalo apagado y las peticiones HTTPS a través de un proxy HTTP fallan o se comportan de forma extraña. Este es el error de proxy en PHP más común de todos.
Guzzle
Guzzle toma el proxy mediante la opción proxy de la petición (o del cliente), con las credenciales incrustadas en la URL. Como Guzzle es un envoltorio de cURL, el mismo comportamiento de túnel aplica por debajo, pero Guzzle maneja el CONNECT para destinos https:// automáticamente.
<?phprequire 'vendor/autoload.php';
use GuzzleHttp\Client;
$user = getenv('SHIFTER_USER') . '-country-us';$pass = getenv('SHIFTER_PASS');$proxy = "http://$user:$pass@p.shifter.io:443";
// Crea el cliente UNA vez y reutilízalo (ver la nota de reutilización de conexión abajo).$client = new Client([ 'proxy' => $proxy, 'connect_timeout' => 10, // fase de conexión 'timeout' => 30, // petición completa]);
$res = $client->get('https://api.ipify.org');echo $res->getBody(), "\n"; // una IP residencial de EE. UU.Fíjate en los connect_timeout y timeout separados. Esa separación conexión-frente-a-total es exactamente lo que hace diagnosticables los timeouts cuando algo se atasca: una conexión lenta apunta a tu lado o a que no hay IP coincidente, un total lento apunta al destino.
Guzzle también acepta la opción proxy por petición y como un array indexado por esquema, que es como enrutas solo https a través del proxy o defines una lista no de excepciones:
$res = $client->get('https://example.com', [ 'proxy' => [ 'http' => $proxy, 'https' => $proxy, 'no' => ['localhost', '127.0.0.1'], ],]);Trampa 1: reutiliza el cliente de Guzzle (y el handle de cURL)
Un GuzzleHttp\Client nuevo por petición, o un curl_init() nuevo por petición, paga un handshake TCP + TLS completo a través del proxy cada vez, la sobrecarga que la guía de latencia existe para eliminar. Guzzle mantiene un pool de handles de cURL por debajo y reutiliza conexiones cuando reutilizas el cliente. Crea un cliente al arrancar, inyéctalo y consérvalo.
Para cURL puro, reutiliza el handle entre peticiones y cambia solo la URL entre llamadas, para que la conexión se mantenga caliente:
$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); // reutiliza el handle, mantén la conexión $body = curl_exec($ch); // ... manejar $body}curl_close($ch);Trampa 2: por defecto PHP descarga una URL cada vez
Los manejadores de peticiones de PHP son síncronos por defecto. Un scraper que itera sobre curl_exec descarga estrictamente una URL cada vez, lo cual está bien para trabajos pequeños y es dolorosamente lento a escala. Dos formas de subir:
Guzzle asíncrono con un pool acotado, para que haya N peticiones en vuelo pero nunca más:
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, // limita peticiones en vuelo 'fulfilled' => function ($response, $i) { /* manejar */ }, 'rejected' => function ($reason, $i) { /* log + reintento */ },]);$pool->promise()->wait();O curl_multi_* si estás en cURL puro. En cualquier caso, limita la concurrencia por host de destino, no de forma global, para que un sitio frágil no reciba una paliza mientras uno permisivo pasa hambre. Más paralelismo por encima de la tolerancia de un destino compra bloqueos, no throughput (cómo evitar que te bloqueen).
Trampa 3: consume la respuesta, y revisa el error de transporte, no solo el estado
cURL devuelve false ante un fallo de transporte (proxy rechazado, túnel fallido, timeout) y una cadena de cuerpo ante una respuesta HTTP, incluso un 407 o un 502. Comprueba el retorno de curl_exec contra false y lee curl_error/curl_errno antes de fiarte del código de estado. En Guzzle, un fallo de conexión lanza ConnectException mientras que un 4xx/5xx lanza RequestException solo si http_errors está activo (lo está, por defecto). Maneja ambos, y lee siempre el cuerpo para que el handle quede libre para reutilizar:
use GuzzleHttp\Exception\ConnectException;use GuzzleHttp\Exception\RequestException;
try { $res = $client->get($url); $body = (string) $res->getBody(); // drena el cuerpo} catch (ConnectException $e) { // transporte: proxy/túnel/timeout — reintenta con una identidad nueva} catch (RequestException $e) { // estado HTTP — inspecciona $e->getResponse()->getStatusCode()}Rotar geo y sesiones
Como el targeting vive en el nombre de usuario, una identidad distinta es una cadena de credenciales distinta.
cURL puro: pon CURLOPT_PROXYUSERPWD con el nuevo nombre de usuario antes de la llamada. El mismo handle puede llevar identidades distintas entre iteraciones.
Guzzle: pasa la opción proxy por petición para sobrescribir el valor por defecto del cliente, de modo que un cliente sirva muchas identidades sin reconstruirse:
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]);Dale a cada unidad lógica de trabajo su propio sid y rota entre unidades, no a mitad de flujo (sticky vs rotativo cubre la distinción), y mapea el trabajo a identidades como describe el post sobre reparto de carga.
Verifica que de verdad estás en el proxy
Antes de hacer benchmark o depurar cualquier otra cosa, confirma la IP de salida:
$res = $client->get('http://ip-api.com/json');echo $res->getBody(), "\n"; // espera una IP residencial en el país objetivoTu propia IP significa que la opción del proxy no se está aplicando. Un cuelgue significa que la salida local está bloqueada. Ambos se cubren en la guía de diagnóstico de timeouts.
Preguntas frecuentes
¿Por qué fallan mis peticiones HTTPS a través del proxy en cURL puro?
Casi seguro te falta CURLOPT_HTTPPROXYTUNNEL. HTTPS a través de un proxy HTTP necesita un túnel CONNECT para que TLS se negocie con el destino, no con el proxy. Pon CURLOPT_HTTPPROXYTUNNEL => true. Guzzle lo hace automáticamente para destinos https://, por eso la trampa solo muerde en cURL puro.
¿Pongo las credenciales del proxy en la URL o en una opción aparte?
Cualquiera vale. En cURL puro, CURLOPT_PROXYUSERPWD mantiene el largo nombre de usuario del gateway fuera de la URL y evita problemas de codificación URL. En Guzzle, incrustar user:pass@host en la cadena proxy es el camino habitual. Elige uno y sé consistente.
Mi scraper de PHP es lento aunque el proxy sea rápido. ¿Por qué?
Lo más probable es que estés creando un cliente de Guzzle (o curl_init) nuevo por petición, pagando un handshake fresco cada vez, o que estés descargando estrictamente una URL cada vez. Reutiliza un cliente/handle, y usa un Pool acotado de Guzzle o curl_multi para correr varias peticiones a la vez.
¿Cómo roto IPs por petición en PHP?
Varía el nombre de usuario del proxy. En cURL puro, pon CURLOPT_PROXYUSERPWD con el nuevo nombre de usuario antes de cada llamada. En Guzzle, pasa la opción proxy por petición. Ambos dejan que un cliente o handle compartido sirva muchas identidades.
¿cURL o Guzzle para scraping? Guzzle te da pools asíncronos, middleware, reintentos y PSR-7 por una dependencia pequeña; cURL puro no tiene dependencias y es algo más rápido por llamada. Usa Guzzle cuando quieras concurrencia y estructura, cURL puro para scripts ajustados y mínimos. La configuración del proxy y las trampas de arriba aplican a ambos, porque Guzzle corre sobre cURL.
En resumen
PHP más proxies residenciales es sólido en cuanto respetas las reglas de cada cliente: en cURL puro, pon el host del proxy y CURLOPT_PROXYUSERPWD, y nunca olvides CURLOPT_HTTPPROXYTUNNEL para HTTPS; en Guzzle, pasa la opción proxy y deja que él haga el túnel. Reutiliza un cliente o handle para que las conexiones se mantengan calientes, corre peticiones a la vez con un pool acotado en lugar de una cada vez, limita la concurrencia por host de destino, y revisa el error de transporte, no solo el código de estado. Varía el nombre de usuario del proxy para cambiar geo o sesión.
Hazlo bien y ambos caminos rinden como PHP debería a escala. Apúntalos al gateway residencial, y recuerda que la calidad del pool decide con qué frecuencia reintentas siquiera (reputación de IP). La página de precios tiene los planes por GB para probarlo contra tus propios destinos.