Если вы занимаетесь скрейпингом, интеграцией с внешними API или автоматизацией в PHP — рано или поздно столкнётесь с блокировками по IP. Провайдеры ограничивают количество запросов, CDN вроде Cloudflare возвращают 403, а популярные сайты требуют обхода rate limit. PHP proxy — это не просто настройка CURLOPT_PROXY, а целый стек решений: ротация IP, обработка ошибок, управление сессиями и TLS-конфигурация.
В этом руководстве мы пройдём путь от базового cURL до production-ready решения на Laravel: сервис-класс с пулом residential-прокси, очередями и повторными попытками. Все примеры используют ProxyHat — провайдера residential, mobile и datacenter прокси.
Базовая настройка: нативный cURL с CURLOPT_PROXY
cURL — стандарт де-факто для HTTP-запросов в PHP. Для работы через прокси используются опции CURLOPT_PROXY (адрес прокси-сервера) и CURLOPT_PROXYUSERPWD (авторизация). Рассмотрим полный пример с обработкой ошибок и таймаутами.
<?php
class CurlProxyClient
{
private string $proxyHost = 'gate.proxyhat.com';
private int $proxyPort = 8080;
private string $username;
private string $password;
public function __construct(string $username, string $password)
{
$this->username = $username;
$this->password = $password;
}
/**
* Выполняет HTTP-запрос через прокси с ротацией IP
*
* @param string $url Целевой URL
* @param string $country Код страны для геотаргетинга (опционально)
* @param string $session Идентификатор сессии для sticky IP (опционально)
* @return array ['status' => int, 'body' => string, 'headers' => array]
* @throws RuntimeException
*/
public function get(
string $url,
?string $country = null,
?string $session = null
): array {
$ch = curl_init();
// Формируем имя пользователя с флагами геотаргетинга и сессии
$proxyUser = $this->buildProxyUsername($country, $session);
// Базовые опции cURL
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
// Настройка прокси
CURLOPT_PROXY => $this->proxyHost,
CURLOPT_PROXYPORT => $this->proxyPort,
CURLOPT_PROXYUSERPWD => "{$proxyUser}:{$this->password}",
CURLOPT_PROXYTYPE => CURLPROXY_HTTP,
// SSL/TLS конфигурация
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_CAINFO => $this->getCaBundlePath(),
// Заголовки
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
CURLOPT_HTTPHEADER => [
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language: en-US,en;q=0.9',
],
// Захват заголовков ответа
CURLOPT_HEADER => true,
]);
$response = curl_exec($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
$info = curl_getinfo($ch);
curl_close($ch);
if ($errno !== CURLE_OK) {
throw new RuntimeException(
"cURL error [{$errno}]: {$error}",
$errno
);
}
// Разделяем заголовки и тело
$headerSize = $info['header_size'];
$headers = substr($response, 0, $headerSize);
$body = substr($response, $headerSize);
return [
'status' => $info['http_code'],
'body' => $body,
'headers' => $this->parseHeaders($headers),
'effective_url' => $info['url'],
'total_time' => $info['total_time'],
];
}
/**
* Строит имя пользователя с флагами ProxyHat
*/
private function buildProxyUsername(?string $country, ?string $session): string
{
$parts = [$this->username];
if ($country !== null) {
$parts[] = "country-{$country}";
}
if ($session !== null) {
$parts[] = "session-{$session}";
}
return implode('-', $parts);
}
/**
* Возвращает путь к CA-бандлу
*/
private function getCaBundlePath(): string
{
// Приоритет: системный бандл > bundled с cURL > локальный файл
$candidates = [
'/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
'/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS
'/usr/local/share/certs/ca-root-nss.crt', // FreeBSD
__DIR__ . '/cacert.pem', // Локальный fallback
];
foreach ($candidates as $path) {
if (file_exists($path)) {
return $path;
}
}
// Если ничего не найдено, отключаем верификацию (только для dev!)
return '';
}
/**
* Парсит заголовки ответа в массив
*/
private function parseHeaders(string $headerText): array
{
$headers = [];
$lines = explode("\r\n", trim($headerText));
foreach ($lines as $line) {
if (strpos($line, ':') !== false) {
[$key, $value] = explode(':', $line, 2);
$headers[trim($key)] = trim($value);
}
}
return $headers;
}
}
// Использование:
try {
$client = new CurlProxyClient('user', 'your_password');
// Запрос с ротацией IP (новый IP для каждого запроса)
$result = $client->get('https://httpbin.org/ip');
echo "IP: " . json_decode($result['body'], true)['origin'] . "\n";
// Запрос с геотаргетингом (США)
$result = $client->get('https://httpbin.org/ip', 'US');
// Sticky-сессия (тот же IP на протяжении сессии)
$sessionId = bin2hex(random_bytes(8));
$result = $client->get('https://httpbin.org/ip', null, $sessionId);
} catch (RuntimeException $e) {
echo "Ошибка: " . $e->getMessage() . "\n";
}
Ключевые моменты в этом примере:
- CURLOPT_PROXYUSERPWD — авторизация в формате
username:password - Геотаргетинг — добавляем
country-USк имени пользователя - Sticky-сессии — идентификатор
session-abc123гарантирует тот же IP - TLS-верификация — указываем путь к CA-бандлу
Guzzle HTTP Client: современный подход к Guzzle proxy
Guzzle — самая популярная HTTP-библиотека для PHP с PSR-7 совместимостью. Настройка прокси в Guzzle делается через опцию proxy в массиве конфигурации клиента или отдельного запроса.
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class GuzzleProxyClient
{
private Client $client;
private string $username;
private string $password;
private string $proxyHost = 'gate.proxyhat.com';
private int $proxyPort = 8080;
// Пул стран для ротации
private array $countries = ['US', 'DE', 'GB', 'FR', 'JP'];
public function __construct(string $username, string $password, array $config = [])
{
$this->username = $username;
$this->password = $password;
$this->client = $this->createClient($config);
}
/**
* Создаёт Guzzle-клиент с middleware для retry и логирования
*/
private function createClient(array $config = []): Client
{
$handlerStack = HandlerStack::create();
// Middleware для повторных попыток
$handlerStack->push(Middleware::retry(
function ($retries, RequestInterface $request, ResponseInterface $response = null, $exception = null) {
// Повторяем до 3 раз
if ($retries >= 3) {
return false;
}
// Повторяем при 429, 500, 502, 503, 504
if ($response && in_array($response->getStatusCode(), [429, 500, 502, 503, 504])) {
return true;
}
// Повторяем при сетевых ошибках
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
return true;
}
return false;
},
function ($retries) {
// Экспоненциальная задержка: 1s, 2s, 4s
return 1000 * (2 ** ($retries - 1));
}
));
// Middleware для логирования
$handlerStack->push(Middleware::tap(
function (RequestInterface $request, array $options) {
echo sprintf(
"[%s] %s %s\n",
date('Y-m-d H:i:s'),
$request->getMethod(),
$request->getUri()
);
}
));
$defaultConfig = [
'handler' => $handlerStack,
'timeout' => 30,
'connect_timeout' => 10,
'verify' => true,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
],
];
return new Client(array_merge($defaultConfig, $config));
}
/**
* Выполняет запрос с автоматической ротацией IP
*
* @param string $method HTTP-метод
* @param string $url Целевой URL
* @param array $options Дополнительные опции Guzzle
* @param array $proxyOptions Опции прокси: country, session, rotate
* @return ResponseInterface
*/
public function request(
string $method,
string $url,
array $options = [],
array $proxyOptions = []
): ResponseInterface {
// Формируем конфигурацию прокси
$proxyConfig = $this->buildProxyConfig($proxyOptions);
// Объединяем с опциями запроса
$options['proxy'] = $proxyConfig['url'];
// Добавляем кастомные заголовки для отладки
$options['headers']['X-Proxy-Country'] = $proxyConfig['country'] ?? 'rotating';
$options['headers']['X-Proxy-Session'] = $proxyConfig['session'] ?? 'none';
try {
return $this->client->request($method, $url, $options);
} catch (RequestException $e) {
// Логируем ошибку с контекстом
$context = [
'url' => $url,
'proxy_country' => $proxyConfig['country'] ?? 'rotating',
'status_code' => $e->hasResponse() ? $e->getResponse()->getStatusCode() : null,
];
throw new ProxyRequestException(
"Proxy request failed: " . $e->getMessage(),
$e->getCode(),
$e,
$context
);
}
}
/**
* Строит конфигурацию прокси для Guzzle
*/
private function buildProxyConfig(array $options): array
{
$username = $this->username;
$session = $options['session'] ?? null;
$country = $options['country'] ?? null;
// Автоматическая ротация стран
if ($options['rotate_country'] ?? false) {
$country = $this->countries[array_rand($this->countries)];
}
// Формируем имя пользователя с флагами
if ($country !== null) {
$username .= "-country-{$country}";
}
if ($session !== null) {
$username .= "-session-{$session}";
}
// URL для Guzzle: 'protocol://user:pass@host:port'
$proxyUrl = sprintf(
'http://%s:%s@%s:%d',
$username,
$this->password,
$this->proxyHost,
$this->proxyPort
);
return [
'url' => $proxyUrl,
'country' => $country,
'session' => $session,
];
}
/**
* Выполняет несколько запросов параллельно с разными прокси
*/
public function batchRequest(array $requests): array
{
$promises = [];
foreach ($requests as $key => $request) {
$proxyOptions = $request['proxy_options'] ?? [];
$proxyConfig = $this->buildProxyConfig($proxyOptions);
$promises[$key] = $this->client->requestAsync(
$request['method'] ?? 'GET',
$request['url'],
array_merge(
$request['options'] ?? [],
['proxy' => $proxyConfig['url']]
)
);
}
// Ждём завершения всех запросов
$results = \GuzzleHttp\Promise\Utils::settle($promises)->wait();
$responses = [];
foreach ($results as $key => $result) {
if ($result['state'] === 'fulfilled') {
$responses[$key] = [
'success' => true,
'response' => $result['value'],
'body' => $result['value']->getBody()->getContents(),
];
} else {
$responses[$key] = [
'success' => false,
'error' => $result['reason']->getMessage(),
];
}
}
return $responses;
}
}
// Кастомное исключение с контекстом
class ProxyRequestException extends \RuntimeException
{
public array $context;
public function __construct(string $message, int $code, \Throwable $previous, array $context)
{
parent::__construct($message, $code, $previous);
$this->context = $context;
}
}
// Использование:
$client = new GuzzleProxyClient('user', 'your_password');
// Простой запрос с ротацией IP
$response = $client->request('GET', 'https://httpbin.org/ip');
echo $response->getBody()->getContents();
// Запрос с конкретной страной
$response = $client->request('GET', 'https://httpbin.org/ip', [], [
'country' => 'DE',
]);
// Sticky-сессия для многошагового скрейпинга
$session = uniqid('session-', true);
$client->request('GET', 'https://example.com/login', [], ['session' => $session]);
$client->request('POST', 'https://example.com/login', [
'form_params' => ['username' => 'user', 'password' => 'pass']
], ['session' => $session]);
$client->request('GET', 'https://example.com/dashboard', [], ['session' => $session]);
// Пакетные запросы с автоматической ротацией стран
$results = $client->batchRequest([
'us' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'US']],
'de' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'DE']],
'jp' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'JP']],
]);
foreach ($results as $region => $result) {
if ($result['success']) {
echo "{$region}: " . $result['body'] . "\n";
}
}
Per-request ротация в Guzzle
Для скрейпинга часто нужен новый IP на каждый запрос. Guzzle позволяет переопределять прокси на уровне отдельного запроса:
<?php
class RotatingProxyPool
{
private GuzzleProxyClient $client;
private array $sessions = [];
public function __construct(GuzzleProxyClient $client)
{
$this->client = $client;
}
/**
* Получает новый IP для каждого запроса
*/
public function fetchWithRotation(string $url): string
{
// Без session-параметра ProxyHat выдаёт новый IP
return $this->client->request('GET', $url)->getBody()->getContents();
}
/**
* Создаёт sticky-сессию для последовательности запросов
*/
public function createSession(string $country = null): string
{
$sessionId = bin2hex(random_bytes(8));
$this->sessions[$sessionId] = [
'country' => $country,
'created_at' => time(),
];
return $sessionId;
}
/**
* Выполняет запрос в рамках сессии
*/
public function fetchWithSession(string $url, string $sessionId): string
{
if (!isset($this->sessions[$sessionId])) {
throw new InvalidArgumentException("Unknown session: {$sessionId}");
}
$session = $this->sessions[$sessionId];
return $this->client->request('GET', $url, [], [
'session' => $sessionId,
'country' => $session['country'],
])->getBody()->getContents();
}
/**
* Очищает старые сессии (TTL 30 минут)
*/
public function cleanupSessions(int $ttlSeconds = 1800): int
{
$now = time();
$count = 0;
foreach ($this->sessions as $id => $session) {
if ($now - $session['created_at'] > $ttlSeconds) {
unset($this->sessions[$id]);
$count++;
}
}
return $count;
}
}
Symfony HTTP Client: асинхронность и AsyncResponse
Symfony HTTP Client — современная библиотека с поддержкой асинхронных запросов, AMPHP и реактивного программирования. В отличие от Guzzle, Symfony HttpClient использует curl_multi под капотом и предоставляет нативную поддержку асинхронности без promises.
<?php
require 'vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
class SymfonyProxyClient
{
private HttpClient $client;
private string $username;
private string $password;
private string $proxyHost = 'gate.proxyhat.com';
private int $proxyPort = 8080;
public function __construct(string $username, string $password)
{
$this->username = $username;
$this->password = $password;
$this->client = HttpClient::create([
'timeout' => 30,
'max_redirects' => 5,
'verify_peer' => true,
'verify_host' => true,
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
],
]);
}
/**
* Выполняет синхронный запрос через прокси
*/
public function request(string $method, string $url, array $options = [], array $proxyOptions = []): array
{
$proxyUrl = $this->buildProxyUrl($proxyOptions);
$mergedOptions = array_merge($options, [
'proxy' => $proxyUrl,
]);
try {
$response = $this->client->request($method, $url, $mergedOptions);
return [
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(false),
'body' => $response->getContent(),
'info' => $response->getInfo(),
];
} catch (TransportExceptionInterface $e) {
throw new ProxyTransportException(
"Request failed: " . $e->getMessage(),
$e->getCode(),
$e
);
}
}
/**
* Асинхронный запрос с колбэками
*/
public function requestAsync(
string $method,
string $url,
array $options = [],
array $proxyOptions = [],
?callable $onSuccess = null,
?callable $onError = null
): AsyncResponse {
$proxyUrl = $this->buildProxyUrl($proxyOptions);
$mergedOptions = array_merge($options, [
'proxy' => $proxyUrl,
]);
return new AsyncResponse(
$this->client,
$method,
$url,
$mergedOptions,
function ($chunk, $response) use ($onSuccess, $onError) {
static $content = '';
try {
// Проверяем статус при первом чанке
if ($response->getInfo('response_headers')) {
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
if ($onError) {
$onError(new \Exception("HTTP {$statusCode}"), $response);
}
return;
}
}
// Накапливаем контент
if (is_string($chunk)) {
$content .= $chunk;
}
} catch (\Exception $e) {
if ($onError) {
$onError($e, $response);
}
}
},
function ($response) use ($onSuccess) {
// Вызывается при завершении
if ($onSuccess) {
$onSuccess($response);
}
}
);
}
/**
* Пакетные асинхронные запросы с ограничением конкурентности
*/
public function batchAsync(array $requests, int $concurrency = 5): array
{
$responses = [];
$activeRequests = [];
$queue = $requests;
$results = [];
// Инициализируем первые N запросов
for ($i = 0; $i < min($concurrency, count($queue)); $i++) {
$key = key($queue);
$request = array_shift($queue);
$proxyOptions = $request['proxy_options'] ?? [];
$proxyUrl = $this->buildProxyUrl($proxyOptions);
$responses[$key] = $this->client->request(
$request['method'] ?? 'GET',
$request['url'],
array_merge($request['options'] ?? [], ['proxy' => $proxyUrl])
);
$activeRequests[] = $key;
}
// Обрабатываем ответы по мере поступления
$completed = [];
foreach (HttpClient::stream($responses) as $key => $chunk) {
try {
if ($chunk->isLast()) {
$response = $responses[$key];
$results[$key] = [
'success' => true,
'status' => $response->getStatusCode(),
'body' => $response->getContent(),
];
$completed[] = $key;
// Запускаем следующий запрос из очереди
if (!empty($queue)) {
$nextKey = key($queue);
$nextRequest = array_shift($queue);
$proxyUrl = $this->buildProxyUrl($nextRequest['proxy_options'] ?? []);
$responses[$nextKey] = $this->client->request(
$nextRequest['method'] ?? 'GET',
$nextRequest['url'],
array_merge($nextRequest['options'] ?? [], ['proxy' => $proxyUrl])
);
}
}
} catch (TransportExceptionInterface $e) {
$results[$key] = [
'success' => false,
'error' => $e->getMessage(),
];
$completed[] = $key;
}
}
return $results;
}
/**
* Стриминг больших файлов через прокси
*/
public function downloadFile(
string $url,
string $destination,
array $proxyOptions = [],
?callable $progressCallback = null
): int {
$proxyUrl = $this->buildProxyUrl($proxyOptions);
$response = $this->client->request('GET', $url, [
'proxy' => $proxyUrl,
]);
$handle = fopen($destination, 'w');
$bytesWritten = 0;
$contentLength = (int) ($response->getHeaders()['content-length'][0] ?? 0);
foreach ($this->client->stream($response) as $chunk) {
$bytesWritten += fwrite($handle, $chunk->getContent());
if ($progressCallback) {
$progressCallback($bytesWritten, $contentLength);
}
}
fclose($handle);
return $bytesWritten;
}
private function buildProxyUrl(array $options): string
{
$username = $this->username;
if (isset($options['country'])) {
$username .= "-country-{$options['country']}";
}
if (isset($options['session'])) {
$username .= "-session-{$options['session']}";
}
return "http://{$username}:{$this->password}@{$this->proxyHost}:{$this->proxyPort}";
}
}
// Использование:
$client = new SymfonyProxyClient('user', 'your_password');
// Синхронный запрос
$result = $client->request('GET', 'https://httpbin.org/ip');
echo $result['body'];
// Асинхронные запросы с конкурентностью
$results = $client->batchAsync([
'us' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'US']],
'de' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'DE']],
'gb' => ['url' => 'https://httpbin.org/ip', 'proxy_options' => ['country' => 'GB']],
], concurrency: 3);
// Скачивание файла с прогрессом
$client->downloadFile(
'https://example.com/large-file.zip',
'/tmp/file.zip',
['country' => 'US'],
fn($bytes, $total) => printf("Downloaded: %.1f%%\r", ($bytes / $total) * 100)
);
Laravel интеграция: сервис-класс для Laravel proxy scraping
В Laravel-приложении прокси-клиент должен быть сервисом с dependency injection, конфигурацией через .env и интеграцией с очередями. Создадим production-ready решение.
<?php
// config/proxy.php
return [
'default' => env('PROXY_DRIVER', 'proxyhat'),
'drivers' => [
'proxyhat' => [
'host' => env('PROXY_HOST', 'gate.proxyhat.com'),
'http_port' => env('PROXY_HTTP_PORT', 8080),
'socks_port' => env('PROXY_SOCKS_PORT', 1080),
'username' => env('PROXY_USERNAME'),
'password' => env('PROXY_PASSWORD'),
],
],
'defaults' => [
'timeout' => env('PROXY_TIMEOUT', 30),
'connect_timeout' => env('PROXY_CONNECT_TIMEOUT', 10),
'max_retries' => env('PROXY_MAX_RETRIES', 3),
'retry_delay' => env('PROXY_RETRY_DELAY', 1000), // ms
],
'pool' => [
'countries' => env('PROXY_COUNTRIES', 'US,DE,GB,FR,JP'),
'sticky_ttl' => env('PROXY_STICKY_TTL', 1800), // 30 минут
],
];
<?php
// app/Services/ProxyService.php
namespace App\Services;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Cache;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
class ProxyService
{
private Client $client;
private array $config;
private array $sessions = [];
public function __construct(array $config = null)
{
$this->config = $config ?? config('proxy');
$this->client = $this->createClient();
}
/**
* Создаёт Guzzle-клиент с настройками прокси
*/
private function createClient(): Client
{
$handlerStack = HandlerStack::create();
// Middleware для retry с экспоненциальной задержкой
$handlerStack->push($this->createRetryMiddleware());
// Middleware для логирования
if (config('app.debug') || config('proxy.logging.enabled', false)) {
$handlerStack->push($this->createLoggingMiddleware());
}
return new Client([
'handler' => $handlerStack,
'timeout' => $this->config['defaults']['timeout'],
'connect_timeout' => $this->config['defaults']['connect_timeout'],
'verify' => true,
'headers' => $this->getDefaultHeaders(),
]);
}
/**
* Middleware для повторных попыток
*/
private function createRetryMiddleware(): callable
{
return Middleware::retry(
function ($retries, RequestInterface $request, ResponseInterface $response = null, $exception = null) {
$maxRetries = $this->config['defaults']['max_retries'];
if ($retries >= $maxRetries) {
return false;
}
// Retry на 429, 5xx
if ($response) {
$status = $response->getStatusCode();
if (in_array($status, [429, 500, 502, 503, 504])) {
Log::warning("Proxy request retrying", [
'status' => $status,
'attempt' => $retries + 1,
'url' => (string) $request->getUri(),
]);
return true;
}
}
// Retry на сетевых ошибках
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
Log::warning("Proxy connection error, retrying", [
'attempt' => $retries + 1,
'error' => $exception->getMessage(),
]);
return true;
}
return false;
},
function ($retries) {
$baseDelay = $this->config['defaults']['retry_delay'];
return $baseDelay * (2 ** $retries);
}
);
}
/**
* Middleware для логирования запросов
*/
private function createLoggingMiddleware(): callable
{
return Middleware::tap(
function (RequestInterface $request, array $options) {
Log::debug('Proxy request started', [
'method' => $request->getMethod(),
'url' => (string) $request->getUri(),
'proxy' => $options['proxy'] ?? 'none',
]);
},
function (RequestInterface $request, array $options, ResponseInterface $response) {
Log::debug('Proxy request completed', [
'status' => $response->getStatusCode(),
'url' => (string) $request->getUri(),
]);
}
);
}
/**
* Выполняет HTTP-запрос через residential-прокси
*/
public function request(
string $method,
string $url,
array $options = [],
?string $country = null,
?string $sessionId = null
): array {
$proxyUrl = $this->buildProxyUrl($country, $sessionId);
$mergedOptions = array_merge($options, [
'proxy' => $proxyUrl,
]);
$startTime = microtime(true);
try {
$response = $this->client->request($method, $url, $mergedOptions);
$this->recordMetrics(true, microtime(true) - $startTime, $country);
return [
'success' => true,
'status' => $response->getStatusCode(),
'headers' => $response->getHeaders(),
'body' => (string) $response->getBody(),
];
} catch (RequestException $e) {
$this->recordMetrics(false, microtime(true) - $startTime, $country);
$response = $e->getResponse();
return [
'success' => false,
'status' => $response?->getStatusCode() ?? 0,
'error' => $e->getMessage(),
'body' => $response ? (string) $response->getBody() : null,
];
}
}
/**
* Создаёт sticky-сессию (тот же IP для всех запросов)
*/
public function createSession(?string $country = null): string
{
$sessionId = uniqid('sess_', true);
$cacheKey = "proxy_session:{$sessionId}";
Cache::put($cacheKey, [
'country' => $country,
'created_at' => now()->timestamp,
'request_count' => 0,
], now()->addSeconds($this->config['pool']['sticky_ttl']));
return $sessionId;
}
/**
* Выполняет запрос в рамках существующей сессии
*/
public function requestWithSession(
string $method,
string $url,
string $sessionId,
array $options = []
): array {
$cacheKey = "proxy_session:{$sessionId}";
$session = Cache::get($cacheKey);
if (!$session) {
throw new \InvalidArgumentException("Session expired or not found: {$sessionId}");
}
// Обновляем счётчик запросов
$session['request_count']++;
Cache::put($cacheKey, $session, now()->addSeconds($this->config['pool']['sticky_ttl']));
return $this->request($method, $url, $options, $session['country'], $sessionId);
}
/**
* Возвращает случайную страну из пула
*/
public function getRandomCountry(): string
{
$countries = explode(',', $this->config['pool']['countries']);
return $countries[array_rand($countries)];
}
/**
* Пакетные запросы с конкурентностью
*/
public function batch(array $requests, int $concurrency = 5): array
{
$results = [];
$promises = [];
foreach ($requests as $key => $request) {
$country = $request['country'] ?? $this->getRandomCountry();
$proxyUrl = $this->buildProxyUrl($country, $request['session'] ?? null);
$promises[$key] = $this->client->requestAsync(
$request['method'] ?? 'GET',
$request['url'],
array_merge($request['options'] ?? [], ['proxy' => $proxyUrl])
);
}
// Ждём завершения всех запросов
$responses = \GuzzleHttp\Promise\Utils::settle($promises)->wait();
foreach ($responses as $key => $result) {
if ($result['state'] === 'fulfilled') {
$response = $result['value'];
$results[$key] = [
'success' => true,
'status' => $response->getStatusCode(),
'body' => (string) $response->getBody(),
];
} else {
$results[$key] = [
'success' => false,
'error' => $result['reason']->getMessage(),
];
}
}
return $results;
}
/**
* Строит URL для прокси
*/
private function buildProxyUrl(?string $country, ?string $sessionId): string
{
$driverConfig = $this->config['drivers'][$this->config['default']];
$username = $driverConfig['username'];
if ($country) {
$username .= "-country-{$country}";
}
if ($sessionId) {
$username .= "-session-{$sessionId}";
}
$host = $driverConfig['host'];
$port = $driverConfig['http_port'];
$password = $driverConfig['password'];
return "http://{$username}:{$password}@{$host}:{$port}";
}
/**
* Возвращает заголовки по умолчанию
*/
private function getDefaultHeaders(): array
{
return [
'User-Agent' => config('proxy.user_agent', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'),
'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language' => 'en-US,en;q=0.9',
];
}
/**
* Записывает метрики для мониторинга
*/
private function recordMetrics(bool $success, float $duration, ?string $country): void
{
// Интеграция с Laravel Pulse/metrics
if (function_exists('metrics')) {
metrics()->increment('proxy.requests', 1, [
'success' => $success ? 'true' : 'false',
'country' => $country ?? 'rotating',
]);
metrics()->timing('proxy.duration', $duration * 1000, [
'country' => $country ?? 'rotating',
]);
}
}
}
<?php
// app/Jobs/ScrapeUrlJob.php
namespace App\Jobs;
use App\Services\ProxyService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Exception;
class ScrapeUrlJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 10; // секунд между попытками
private string $url;
private ?string $country;
private ?string $sessionId;
private array $callbacks;
public function __construct(
string $url,
?string $country = null,
?string $sessionId = null,
array $callbacks = []
) {
$this->url = $url;
$this->country = $country;
$this->sessionId = $sessionId;
$this->callbacks = $callbacks;
}
public function handle(ProxyService $proxyService): void
{
Log::info("Starting scrape job", ['url' => $this->url]);
$result = $proxyService->request(
'GET',
$this->url,
[],
$this->country,
$this->sessionId
);
if (!$result['success']) {
Log::error("Scrape job failed", [
'url' => $this->url,
'error' => $result['error'],
]);
throw new Exception($result['error']);
}
// Обрабатываем результат
$this->processResult($result);
}
private function processResult(array $result): void
{
// Парсинг HTML, извлечение данных, сохранение в БД
// ...
Log::info("Scrape job completed", [
'url' => $this->url,
'status' => $result['status'],
'size' => strlen($result['body']),
]);
}
/**
* Обработка неудачной задачи
*/
public function failed(Exception $exception): void
{
Log::error("Scrape job permanently failed", [
'url' => $this->url,
'exception' => $exception->getMessage(),
]);
}
}
// Использование в контроллере или сервисе:
// Простой запрос
$proxyService = app(ProxyService::class);
$result = $proxyService->request('GET', 'https://example.com/data');
// С геотаргетингом
$result = $proxyService->request('GET', 'https://example.com/data', [], 'US');
// Sticky-сессия для многошагового скрейпинга
$sessionId = $proxyService->createSession('DE');
$proxyService->requestWithSession('GET', 'https://example.com/login', $sessionId);
$proxyService->requestWithSession('POST', 'https://example.com/login', $sessionId, [
'form_params' => ['username' => 'user', 'password' => 'pass']
]);
// Диспетчеризация в очередь
ScrapeUrlJob::dispatch('https://example.com/data', 'US');
// Пакетная обработка
$results = $proxyService->batch([
'us' => ['url' => 'https://httpbin.org/ip', 'country' => 'US'],
'de' => ['url' => 'https://httpbin.org/ip', 'country' => 'DE'],
'gb' => ['url' => 'https://httpbin.org/ip', 'country' => 'GB'],
]);
Multi-curl для конкурентного скрейпинга
Когда нужно выполнить сотни запросов параллельно, multi_curl — самый эффективный способ. Он использует один процесс и не требует внешних зависимостей.
<?php
class MultiCurlProxyClient
{
private string $proxyHost = 'gate.proxyhat.com';
private int $proxyPort = 8080;
private string $username;
private string $password;
private int $maxConnections = 10;
private int $timeout = 30;
public function __construct(string $username, string $password)
{
$this->username = $username;
$this->password = $password;
}
/**
* Выполняет массив запросов параллельно
*
* @param array $requests Массив ['key' => ['url' => '...', 'options' => [...]]]
* @param callable $progressCallback Колбэк прогресса (optional)
* @return array Результаты по ключам
*/
public function fetchAll(array $requests, ?callable $progressCallback = null): array
{
$results = [];
$handles = [];
// Создаём multi-handle
$mh = curl_multi_init();
// Настройка multi-handle
curl_multi_setopt($mh, CURLMOPT_MAX_TOTAL_CONNECTIONS, $this->maxConnections);
// Создаём индивидуальные handles
foreach ($requests as $key => $request) {
$ch = $this->createHandle($request['url'], $request['options'] ?? []);
$handles[$key] = [
'handle' => $ch,
'url' => $request['url'],
];
curl_multi_add_handle($mh, $ch);
}
// Выполняем запросы
$active = null;
$completed = 0;
$total = count($requests);
do {
$status = curl_multi_exec($mh, $active);
if ($status === CURLM_OK) {
// Ждём активности
curl_multi_select($mh, 1.0);
}
// Проверяем завершённые запросы
while (($info = curl_multi_info_read($mh)) !== false) {
if ($info['msg'] === CURLMSG_DONE) {
$ch = $info['handle'];
// Находим ключ по handle
$key = $this->findKeyByHandle($handles, $ch);
if ($key !== null) {
$results[$key] = $this->processResult($ch, $info['result']);
$completed++;
if ($progressCallback) {
$progressCallback($completed, $total, $key, $results[$key]);
}
}
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
unset($handles[$key]);
}
}
} while ($status === CURLM_CALL_MULTI_PERFORM || $active > 0);
curl_multi_close($mh);
return $results;
}
/**
* Выполняет запросы с ограничением скорости (rate limiting)
*/
public function fetchWithRateLimit(
array $requests,
int $requestsPerSecond = 5,
?callable $progressCallback = null
): array {
$results = [];
$batchSize = $this->maxConnections;
$delayBetweenBatches = (1000 * $batchSize) / $requestsPerSecond;
$batches = array_chunk($requests, $batchSize, true);
foreach ($batches as $batchIndex => $batch) {
if ($batchIndex > 0) {
usleep((int) $delayBetweenBatches * 1000);
}
$batchResults = $this->fetchAll($batch, $progressCallback);
$results = array_merge($results, $batchResults);
}
return $results;
}
/**
* Создаёт cURL handle для запроса
*/
private function createHandle(string $url, array $options = []): \CurlHandle
{
$ch = curl_init();
// Формируем URL прокси с параметрами
$proxyUser = $this->username;
if (isset($options['country'])) {
$proxyUser .= "-country-{$options['country']}";
}
if (isset($options['session'])) {
$proxyUser .= "-session-{$options['session']}";
}
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $options['timeout'] ?? $this->timeout,
CURLOPT_CONNECTTIMEOUT => $options['connect_timeout'] ?? 10,
// Прокси
CURLOPT_PROXY => $this->proxyHost,
CURLOPT_PROXYPORT => $this->proxyPort,
CURLOPT_PROXYUSERPWD => "{$proxyUser}:{$this->password}",
CURLOPT_PROXYTYPE => CURLPROXY_HTTP,
// SSL
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
// Заголовки
CURLOPT_USERAGENT => $options['user_agent'] ?? 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
CURLOPT_HTTPHEADER => $options['headers'] ?? [],
// Для захвата заголовков
CURLOPT_HEADER => true,
]);
// POST-данные если есть
if (isset($options['post_data'])) {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $options['post_data']);
}
// Кастомный метод
if (isset($options['method'])) {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $options['method']);
}
return $ch;
}
/**
* Обрабатывает результат запроса
*/
private function processResult(\CurlHandle $ch, int $curlResult): array
{
$response = curl_multi_getcontent($ch);
$info = curl_getinfo($ch);
$error = curl_error($ch);
if ($curlResult !== CURLE_OK) {
return [
'success' => false,
'error' => $error,
'error_code' => $curlResult,
];
}
$headerSize = $info['header_size'];
return [
'success' => true,
'status' => $info['http_code'],
'headers' => substr($response, 0, $headerSize),
'body' => substr($response, $headerSize),
'info' => [
'total_time' => $info['total_time'],
'connect_time' => $info['connect_time'],
'size_download' => $info['size_download'],
'effective_url' => $info['url'],
],
];
}
/**
* Находит ключ по handle в массиве
*/
private function findKeyByHandle(array $handles, \CurlHandle $ch): ?string
{
foreach ($handles as $key => $data) {
if ($data['handle'] === $ch) {
return $key;
}
}
return null;
}
}
// Использование:
$client = new MultiCurlProxyClient('user', 'your_password');
$client->setMaxConnections(20);
// Генерируем список URL для скрейпинга
$urls = [];
for ($i = 1; $i <= 100; $i++) {
$urls["page_{$i}"] = [
'url' => "https://example.com/page/{$i}",
'options' => ['country' => 'US'],
];
}
// Выполняем с прогрессом
$results = $client->fetchAll($urls, function ($completed, $total, $key, $result) {
echo sprintf("[%d/%d] %s: %s\n", $completed, $total, $key,
$result['success'] ? "OK ({$result['status']})" : "ERROR: {$result['error']}"
);
});
// С rate limiting (10 запросов в секунду)
$results = $client->fetchWithRateLimit($urls, 10);
// Анализ результатов
$successCount = count(array_filter($results, fn($r) => $r['success']));
echo "Успешных запросов: {$successCount} из " . count($results) . "\n";
TLS/SSL конфигурация и CA-бандлы
При работе через прокси критически важна правильная настройка TLS. Ошибки SSL могут маскироваться под проблемы с прокси, хотя на самом деле это отсутствие актуальных корневых сертификатов.
<?php
class TlsProxyClient
{
private ?string $caBundlePath = null;
private array $sslOptions = [];
public function __construct()
{
$this->caBundlePath = $this->detectCaBundle();
$this->configureSsl();
}
/**
* Определяет путь к CA-бандлу
*/
private function detectCaBundle(): string
{
// Приоритет поиска CA-бандла
$candidates = [
// Переменная окружения
getenv('SSL_CERT_FILE') ?: null,
// Системные пути (Linux)
'/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu/Gentoo
'/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS/Fedora
'/etc/ssl/ca-bundle.pem', // OpenSUSE
'/etc/pki/tls/cacert.pem', // Alpine
'/etc/ssl/cert.pem', // Alpine (альтернативный)
'/usr/local/share/certs/ca-root-nss.crt', // FreeBSD
'/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem', // RHEL (новые версии)
// macOS
'/usr/local/etc/openssl/cert.pem',
'/usr/local/etc/openssl@1.1/cert.pem',
'/usr/local/etc/openssl@3/cert.pem',
// Windows (при использовании WAMP/XAMPP)
'C:\\wamp64\\bin\\php\\php7.4\\extras\\ssl\\cacert.pem',
'C:\\xampp\\php\\extras\\ssl\\cacert.pem',
// Composer CA-бандл (если установлен)
dirname(__DIR__) . '/vendor/composer/ca-bundle/res/cacert.pem',
// Локальный fallback
__DIR__ . '/cacert.pem',
];
foreach ($candidates as $path) {
if ($path && file_exists($path) && is_readable($path)) {
return $path;
}
}
// Скачиваем Mozilla CA bundle если ничего не найдено
return $this->downloadCaBundle();
}
/**
* Скачивает актуальный CA-бандл от Mozilla
*/
private function downloadCaBundle(): string
{
$targetPath = sys_get_temp_dir() . '/cacert-' . date('Y-m') . '.pem';
if (file_exists($targetPath)) {
return $targetPath;
}
$source = 'https://curl.se/ca/cacert.pem';
$tempFile = tempnam(sys_get_temp_dir(), 'cacert');
// Скачиваем без верификации (bootstrapping)
$ch = curl_init($source);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => false, // Только для начального скачивания!
CURLOPT_FOLLOWLOCATION => true,
]);
$data = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($code === 200 && $data) {
file_put_contents($tempFile, $data);
rename($tempFile, $targetPath);
return $targetPath;
}
throw new RuntimeException('Failed to download CA bundle');
}
/**
* Настраивает SSL-опции
*/
private function configureSsl(): void
{
$this->sslOptions = [
'verify_peer' => true,
'verify_peer_name' => true,
'verify_host' => 2,
'ca_info' => $this->caBundlePath,
// Минимальная версия TLS
'ssl_version' => CURL_SSLVERSION_TLSv1_2,
// Шифры (приоритет безопасных)
'ssl_cipher_list' => 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_128_GCM_SHA256',
];
}
/**
* Возвращает опции cURL для SSL
*/
public function getCurlSslOptions(): array
{
return [
CURLOPT_SSL_VERIFYPEER => $this->sslOptions['verify_peer'],
CURLOPT_SSL_VERIFYHOST => $this->sslOptions['verify_host'],
CURLOPT_CAINFO => $this->sslOptions['ca_info'],
CURLOPT_SSLVERSION => $this->sslOptions['ssl_version'],
CURLOPT_SSL_CIPHER_LIST => $this->sslOptions['ssl_cipher_list'],
];
}
/**
* Возвращает опции для Guzzle
*/
public function getGuzzleSslOptions(): array
{
return [
'verify' => $this->sslOptions['ca_info'],
];
}
/**
* Возвращает опции для Symfony HttpClient
*/
public function getSymfonySslOptions(): array
{
return [
'verify_peer' => $this->sslOptions['verify_peer'],
'verify_host' => $this->sslOptions['verify_host'],
'cafile' => $this->sslOptions['ca_info'],
];
}
/**
* Проверяет SSL-соединение через прокси
*/
public function testSslConnection(string $proxyUrl, string $testUrl = 'https://www.google.com'): array
{
$ch = curl_init($testUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_PROXY => $proxyUrl,
CURLOPT_TIMEOUT => 10,
] + $this->getCurlSslOptions());
$response = curl_exec($ch);
$info = curl_getinfo($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
curl_close($ch);
return [
'success' => $errno === CURLE_OK && $info['http_code'] === 200,
'http_code' => $info['http_code'],
'ssl_verify_result' => $info['ssl_verify_result'],
'error' => $error ?: null,
'ca_path' => $this->caBundlePath,
'cert_info' => $info['certinfo'] ?? [],
];
}
/**
* Отключает верификацию (только для разработки!)
*/
public function disableVerification(): void
{
if (getenv('APP_ENV') === 'production') {
throw new RuntimeException('Cannot disable SSL verification in production');
}
$this->sslOptions['verify_peer'] = false;
$this->sslOptions['verify_peer_name'] = false;
$this->sslOptions['verify_host'] = 0;
}
}
// Использование:
$sslClient = new TlsProxyClient();
// Проверяем путь к CA
echo "CA Bundle: " . $sslClient->caBundlePath . "\n";
// Тестируем SSL через прокси
$proxyUrl = 'http://user-country-US:password@gate.proxyhat.com:8080';
$result = $sslClient->testSslConnection($proxyUrl);
if ($result['success']) {
echo "SSL-соединение успешно\n";
} else {
echo "SSL-ошибка: " . $result['error'] . "\n";
}
// Интеграция с Guzzle
$guzzleOptions = $sslClient->getGuzzleSslOptions();
$client = new GuzzleHttp\Client($guzzleOptions);
// Интеграция с Symfony HttpClient
$symfonyOptions = $sslClient->getSymfonySslOptions();
$client = Symfony\Component\HttpClient\HttpClient::create($symfonyOptions);
Сравнение подходов
Выбор библиотеки зависит от требований проекта:
| Критерий | cURL | Guzzle | Symfony HttpClient | Multi-curl |
|---|---|---|---|---|
| Сложность | Низкая | Средняя | Средняя | Высокая |
| Асинхронность | Нет | Через promises | Нативная | Нативная |
| Производительность | Базовая | Хорошая | Отличная | Максимальная |
| Интеграция с Laravel | Ручная | Нативная | Через HTTP-клиент | Ручная |
| Retry-логика | Ручная | Middleware | Callback | Ручная |
| Подходит для | Простые запросы | API-интеграции | Реактивные приложения | Массовый скрейпинг |
Рекомендация: Для Laravel proxy scraping используйте Guzzle с middleware для retry и логирования. Для высоконагруженного массового скрейпинга — multi_curl с пулом соединений.
Лучшие практики для production
- Всегда включайте верификацию SSL — никогда не отключайте CURLOPT_SSL_VERIFYPEER в production. Используйте актуальный CA-бандл.
- Настраивайте таймауты — connect_timeout 5-10 секунд, общий timeout 30 секунд для обычных запросов, до 120 для больших файлов.
- Реализуйте circuit breaker — при серии неудач временно отключайте прокси и переключайтесь на резервный.
- Логируйте метрики —成功率, latency, ошибки по странам. Это поможет выявить проблемные регионы.
- Уважайте robots.txt и rate limits — даже с rotating proxies агрессивный скрейпинг приведёт к блокировкам.
- Используйте sticky-сессии для multi-step — логин, навигация, получение данных должны идти через один IP.
- Кэшируйте ответы — для SERP и ценовых данных кэш на 1-24 часа существенно снизит нагрузку.
Key Takeaways
- cURL с CURLOPT_PROXY — базовый вариант для простых скриптов. Настройте CURLOPT_PROXYUSERPWD для авторизации в формате
username-country-US-session-abc:password@host:port. - Guzzle — лучший выбор для Laravel. Используйте middleware для retry, логирования и метрик. Per-request прокси через опцию
proxyв массиве запроса. - Symfony HttpClient — нативная асинхронность без promises. Метод
stream()для обработки больших ответов и конкурентных запросов. - Multi-curl — максимальная производительность для массового скрейпинга. Контролируйте конкурентность через CURLMOPT_MAX_TOTAL_CONNECTIONS.
- TLS/SSL — критически важна актуальность CA-бандла. Скачивайте Mozilla CA bundle или используйте системный путь.
- ProxyHat — residential и mobile прокси с геотаргетингом и sticky-сессиями. Формат:
http://user-country-XX-session-ID:pass@gate.proxyhat.com:8080.
Готовы начать? Изучите тарифы ProxyHat и получите доступ к пулу residential и mobile прокси для ваших PHP-проектов. Для массового скрейпинга ознакомьтесь с кейсом по сбору данных.






