Wprowadzenie: dlaczego PHP potrzebuje proxy?
PHP pozostaje jednym z najpopularniejszych języków do scrapingu stron, integracji z zewnętrznymi API i automatyzacji procesów backendowych. Niezależnie od tego, czy budujesz system monitoringu cen, czy pobierasz dane z publicznych rejestrów — prędzej czy później napotkasz limity zapytań, blokady geograficzne lub rate limiting.
Proxy HTTP w PHP rozwiązuje te problemy, maskując adres IP źródłowy i pozwalając na dystrybucję zapytań przez pulę adresów. W tym przewodniku pokażę Ci, jak skonfigurować proxy w najpopularniejszych bibliotekach PHP: od surowego cURL, przez Guzzle i Symfony HTTP Client, aż po gotowe rozwiązanie dla Laravel.
Podstawy: surowy cURL z CURLOPT_PROXY
Rozpocznijmy od fundamentów. Rozszerzenie cURL w PHP oferuje pełną kontrolę nad połączeniem HTTP, w tym konfigurację proxy. Dwie kluczowe opcje to CURLOPT_PROXY (adres serwera proxy) oraz CURLOPT_PROXYUSERPWD (dane uwierzytelniające).
Poniżej kompletny przykład produkcyjny z obsługą błędów, timeoutami i weryfikacją SSL:
<?php
declare(strict_types=1);
/**
* Klasa do wykonywania zapytań HTTP przez proxy z pełną obsługą błędów.
*/
class ProxyHttpClient
{
private string $proxyHost;
private int $proxyPort;
private string $proxyUser;
private string $proxyPass;
private int $timeout;
private bool $verifySsl;
public function __construct(
string $proxyHost = 'gate.proxyhat.com',
int $proxyPort = 8080,
string $proxyUser = 'user-country-US',
string $proxyPass = 'PASSWORD',
int $timeout = 30,
bool $verifySsl = true
) {
$this->proxyHost = $proxyHost;
$this->proxyPort = $proxyPort;
$this->proxyUser = $proxyUser;
$this->proxyPass = $proxyPass;
$this->timeout = $timeout;
$this->verifySsl = $verifySsl;
}
/**
* Wykonuje zapytanie GET przez proxy.
*
* @param string $url URL docelowy
* @param array<string, string> $headers Dodatkowe nagłówki
* @return array{status: int, body: string, headers: array}
* @throws RuntimeException Gdy zapytanie się nie powiedzie
*/
public function get(string $url, array $headers = []): array
{
$ch = curl_init();
if ($ch === false) {
throw new RuntimeException('Nie udało się zainicjalizować cURL');
}
try {
// Podstawowa konfiguracja
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
// Konfiguracja proxy
curl_setopt($ch, CURLOPT_PROXY, $this->proxyHost);
curl_setopt($ch, CURLOPT_PROXYPORT, $this->proxyPort);
curl_setopt($ch, CURLOPT_PROXYUSERPWD, $this->proxyUser . ':' . $this->proxyPass);
curl_setopt($ch, CURLOPT_PROXYAUTH, CURLAUTH_BASIC);
// Konfiguracja SSL/TLS
if ($this->verifySsl) {
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
// Użyj systemowego bundle CA lub określ ścieżkę
$caBundle = $this->getCaBundlePath();
if ($caBundle !== null) {
curl_setopt($ch, CURLOPT_CAINFO, $caBundle);
}
} else {
// TYLKO dla developmentu!
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
}
// Nagłówki
if (!empty($headers)) {
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
}
// Zapisz nagłówki odpowiedzi
$responseHeaders = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($curl, $header) use (&$responseHeaders) {
$len = strlen($header);
$header = explode(':', $header, 2);
if (count($header) < 2) {
return $len;
}
$responseHeaders[strtolower(trim($header[0]))] = trim($header[1]);
return $len;
});
// Wykonaj zapytanie
$body = curl_exec($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if ($errno !== CURLE_OK) {
throw new RuntimeException(
sprintf('Błąd cURL [%d]: %s', $errno, $error)
);
}
return [
'status' => (int) $status,
'body' => (string) $body,
'headers' => $responseHeaders,
];
} finally {
curl_close($ch);
}
}
/**
* Zwraca ścieżkę do bundle CA.
*/
private function getCaBundlePath(): ?string
{
// Sprawdź typowe lokalizacje
$paths = [
'/etc/ssl/certs/ca-certificates.crt', // Debian/Ubuntu
'/etc/pki/tls/certs/ca-bundle.crt', // RHEL/CentOS
'/usr/local/etc/openssl/cert.pem', // macOS Homebrew
__DIR__ . '/cacert.pem', // Lokalny bundle
];
foreach ($paths as $path) {
if (file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Tworzy instancję z geo-targetingiem.
*/
public static function withGeoTargeting(string $country, string $password): self
{
return new self(
proxyUser: 'user-country-' . strtoupper($country),
proxyPass: $password
);
}
}
// Przykład użycia
try {
$client = ProxyHttpClient::withGeoTargeting('DE', 'twoje_haslo');
$response = $client->get('https://httpbin.org/ip');
echo "Status: " . $response['status'] . "\n";
echo "Body: " . $response['body'] . "\n";
} catch (RuntimeException $e) {
echo "Błąd: " . $e->getMessage() . "\n";
}
Kluczowe opcje cURL dla proxy
| Opcja | Opis | Przykład wartości |
|---|---|---|
CURLOPT_PROXY | Adres hosta proxy | gate.proxyhat.com |
CURLOPT_PROXYPORT | Port proxy | 8080 |
CURLOPT_PROXYUSERPWD | Dane logowania (user:pass) | user-country-US:haslo |
CURLOPT_PROXYAUTH | Metoda autoryzacji | CURLAUTH_BASIC |
CURLOPT_PROXYTYPE | Typ proxy (HTTP/SOCKS) | CURLPROXY_HTTP lub CURLPROXY_SOCKS5 |
Guzzle HTTP Client: nowoczesne podejście
Guzzle to najpopularniejszy klient HTTP w ekosystemie PHP. Oferuje czystszy API, obsługę asynchroniczną i łatwą integrację z PSR-7. Konfiguracja proxy jest prosta — wystarczy przekazać opcję proxy w tablicy konfiguracyjnej.
<?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;
/**
* Serwis do scrapingu z rotacją proxy per zapytanie.
*/
class GuzzleProxyScraper
{
private Client $client;
private array $proxyCredentials;
private array $usedSessions = [];
public function __construct(string $password, array $countries = ['US', 'DE', 'GB'])
{
$this->proxyCredentials = [
'host' => 'gate.proxyhat.com',
'port' => 8080,
'password' => $password,
'countries' => $countries,
];
// Handler z retry middleware
$handlerStack = HandlerStack::create();
// Dodaj middleware do retry z exponential backoff
$handlerStack->push(Middleware::retry(
function (int $retries, RequestInterface $request, ?ResponseInterface $response, ?RequestException $exception) {
// Retry przy błędach 5xx lub problemach sieciowych
if ($retries >= 3) {
return false;
}
if ($exception !== null) {
return true;
}
if ($response !== null && $response->getStatusCode() >= 500) {
return true;
}
return false;
},
function (int $retries) {
return 1000 * (2 ** $retries); // Exponential backoff: 1s, 2s, 4s
}
));
$this->client = new Client([
'handler' => $handlerStack,
'timeout' => 30,
'connect_timeout' => 10,
'http_errors' => true,
'verify' => true, // Weryfikacja SSL
]);
}
/**
* Generuje losowe dane proxy z geo-targetingiem.
*/
private function getRandomProxy(): array
{
$country = $this->proxyCredentials['countries'][array_rand($this->proxyCredentials['countries'])];
$sessionId = bin2hex(random_bytes(8));
return [
'url' => sprintf(
'http://%s:%s@%s:%d',
'user-country-' . $country . '-session-' . $sessionId,
$this->proxyCredentials['password'],
$this->proxyCredentials['host'],
$this->proxyCredentials['port']
),
'country' => $country,
'session' => $sessionId,
];
}
/**
* Pobiera stronę przez losowe proxy.
*
* @throws RequestException
*/
public function fetch(string $url, array $options = []): array
{
$proxy = $this->getRandomProxy();
$defaultOptions = [
'proxy' => $proxy['url'],
'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',
'Accept-Language' => 'en-US,en;q=0.9',
],
];
$options = array_merge($defaultOptions, $options);
$startTime = microtime(true);
$response = $this->client->get($url, $options);
$duration = microtime(true) - $startTime;
return [
'status' => $response->getStatusCode(),
'body' => (string) $response->getBody(),
'headers' => $response->getHeaders(),
'proxy_country' => $proxy['country'],
'proxy_session' => $proxy['session'],
'duration' => $duration,
];
}
/**
* Pobiera wiele URL-i z różnymi proxy (równolegle z Promise'ami).
*/
public function fetchMultiple(array $urls, int $concurrency = 5): array
{
$promises = [];
$results = [];
foreach ($urls as $key => $url) {
$proxy = $this->getRandomProxy();
$promises[$key] = $this->client->getAsync($url, [
'proxy' => $proxy['url'],
]);
}
// Czekaj na wszystkie zakończone Promise'y
$responses = \GuzzleHttp\Promise\Utils::settle($promises)->wait();
foreach ($responses as $key => $result) {
if ($result['state'] === 'fulfilled') {
$results[$key] = [
'status' => $result['value']->getStatusCode(),
'body' => (string) $result['value']->getBody(),
];
} else {
$results[$key] = [
'error' => $result['reason']->getMessage(),
];
}
}
return $results;
}
}
// Przykład użycia
$scraper = new GuzzleProxyScraper('twoje_haslo', ['US', 'DE', 'FR', 'PL']);
try {
$result = $scraper->fetch('https://httpbin.org/ip');
echo "IP przez proxy ({$result['proxy_country']}): " . $result['body'] . "\n";
echo "Czas: " . round($result['duration'] * 1000, 2) . "ms\n";
} catch (RequestException $e) {
echo "Błąd: " . $e->getMessage() . "\n";
}
Zalety Guzzle nad surowym cURL
- Middleware — łatwe dodawanie retry, logowania, autoryzacji
- Promise/Async — asynchroniczne zapytania bez blokowania
- PSR-7 — standardowe obiekty Request/Response
- Testowalność — mock handler dla testów jednostkowych
Symfony HTTP Client: wydajność i asynchroniczność
Symfony HTTP Client to nowoczesny komponent z natywną obsługą asynchroniczności, AMPHP i jednoczesnego przetwarzania wielu zapytań. Idealny dla aplikacji wymagających wysokiej wydajności.
<?php
require 'vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\Response\AsyncResponse;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\ResponseInterface;
/**
* Asynchroniczny klient HTTP z proxy dla Symfony.
*/
class SymfonyProxyClient
{
private HttpClientInterface $client;
private string $proxyUrl;
public function __construct(string $password, string $country = 'US')
{
// Buduj URL proxy
$this->proxyUrl = sprintf(
'http://user-country-%s:%s@gate.proxyhat.com:8080',
strtoupper($country),
$password
);
$this->client = HttpClient::create([
'timeout' => 30,
'max_redirects' => 5,
'verify_peer' => true,
'verify_host' => true,
'cafile' => '/etc/ssl/certs/ca-certificates.crt', // Dostosuj do systemu
]);
}
/**
* Tworzy instancję ze sticky session.
*/
public static function withStickySession(string $password, string $sessionId, string $country = 'US'): self
{
$instance = new self($password, $country);
$instance->proxyUrl = sprintf(
'http://user-country-%s-session-%s:%s@gate.proxyhat.com:8080',
strtoupper($country),
$sessionId,
$password
);
return $instance;
}
/**
* Wykonuje pojedyncze zapytanie przez proxy.
*/
public function request(string $method, string $url, array $options = []): ResponseInterface
{
$options['proxy'] = $this->proxyUrl;
return $this->client->request($method, $url, $options);
}
/**
* Pobiera wiele URL-i równolegle z streaming.
*
* @param array<string> $urls Lista URL-i
* @param callable|null $onProgress Callback postępu
* @return array<string, array> Wyniki zmapowane URL => dane
*/
public function fetchConcurrently(array $urls, ?callable $onProgress = null): array
{
$responses = [];
$results = [];
// Inicjalizuj wszystkie zapytania
foreach ($urls as $url) {
$responses[$url] = $this->client->request('GET', $url, [
'proxy' => $this->proxyUrl,
'on_progress' => $onProgress,
]);
}
// Przetwarzaj odpowiedzi w miarę ich nadejścia
foreach ($responses as $url => $response) {
try {
$statusCode = $response->getStatusCode();
$content = $response->getContent();
$headers = $response->getHeaders();
$results[$url] = [
'status' => $statusCode,
'body' => $content,
'headers' => $headers,
];
} catch (\Exception $e) {
$results[$url] = [
'error' => $e->getMessage(),
'status' => 0,
];
}
}
return $results;
}
/**
* Streaming z chunk'ami — idealne dla dużych plików.
*/
public function streamWithCallback(
string $url,
callable $chunkCallback,
int $bufferSize = 8192
): void {
$response = $this->client->request('GET', $url, [
'proxy' => $this->proxyUrl,
'buffer' => false, // Wyłącz buforowanie w pamięci
]);
foreach ($this->client->stream($response) as $chunk) {
if ($chunk->isFirst()) {
// Nagłówki dostępne
$headers = $response->getHeaders();
$chunkCallback(null, $headers, 'headers');
} elseif ($chunk->isLast()) {
$chunkCallback(null, null, 'complete');
} else {
$chunkCallback($chunk->getContent(), null, 'data');
}
}
}
}
// Przykład użycia — pobieranie równoległe
$client = new SymfonyProxyClient('twoje_haslo', 'DE');
$urls = [
'https://httpbin.org/ip',
'https://httpbin.org/headers',
'https://httpbin.org/user-agent',
];
$results = $client->fetchConcurrently($urls);
foreach ($results as $url => $data) {
echo "URL: $url\n";
if (isset($data['error'])) {
echo " Error: {$data['error']}\n";
} else {
echo " Status: {$data['status']}\n";
echo " Body: " . substr($data['body'], 0, 100) . "...\n";
}
}
Laravel: serwis do zarządzania pulą proxy
W aplikacjach Laravel warto wydzielić logikę proxy do osobnego serwisu, który może być wstrzykiwany do Jobów, Commandów i Controllerów. Poniżej kompletna implementacja z pulą residential proxy.
<?php
namespace App\Services\Proxy;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\RequestException;
use RuntimeException;
/**
* Konfiguracja proxy w config/services.php:
*
* 'proxyhat' => [
* 'host' => env('PROXY_HOST', 'gate.proxyhat.com'),
* 'port' => env('PROXY_PORT', 8080),
* 'username' => env('PROXY_USERNAME', 'user'),
* 'password' => env('PROXY_PASSWORD'),
* 'countries' => env('PROXY_COUNTRIES', 'US,DE,GB'),
* ],
*/
class ResidentialProxyPool
{
private Client $client;
private array $config;
private int $maxRetries;
private int $retryDelay;
public function __construct(array $config = null, int $maxRetries = 3, int $retryDelay = 1000)
{
$this->config = $config ?? config('services.proxyhat');
$this->maxRetries = $maxRetries;
$this->retryDelay = $retryDelay;
$this->client = new Client([
'timeout' => 60,
'connect_timeout' => 15,
'verify' => true,
]);
}
/**
* Tworzy URL proxy z określonymi opcjami.
*
* @param array{country?: string, city?: string, session?: string} $options
* @return string
*/
public function buildProxyUrl(array $options = []): string
{
$username = $this->config['username'];
// Dodaj geo-targeting
if (isset($options['country'])) {
$username .= '-country-' . strtoupper($options['country']);
}
if (isset($options['city'])) {
$username .= '-city-' . strtolower($options['city']);
}
// Sticky session
if (isset($options['session'])) {
$username .= '-session-' . $options['session'];
} else {
// Losowa sesja dla rotacji per zapytanie
$username .= '-session-' . bin2hex(random_bytes(8));
}
return sprintf(
'http://%s:%s@%s:%d',
$username,
$this->config['password'],
$this->config['host'],
$this->config['port']
);
}
/**
* Pobiera losowy kraj z puli.
*/
public function getRandomCountry(): string
{
$countries = explode(',', $this->config['countries'] ?? 'US');
return $countries[array_rand($countries)];
}
/**
* Wykonuje zapytanie GET przez proxy z retry.
*
* @param string $url URL docelowy
* @param array{country?: string, session?: string, headers?: array} $options
* @return array{status: int, body: string, ip: string}
* @throws RuntimeException
*/
public function get(string $url, array $options = []): array
{
$lastException = null;
for ($attempt = 0; $attempt < $this->maxRetries; $attempt++) {
$proxyUrl = $this->buildProxyUrl($options);
$country = $options['country'] ?? $this->getRandomCountry();
try {
Log::debug('Proxy request attempt', [
'url' => $url,
'country' => $country,
'attempt' => $attempt + 1,
]);
$response = $this->client->get($url, [
'proxy' => $proxyUrl,
'headers' => $options['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 [
'status' => $response->getStatusCode(),
'body' => (string) $response->getBody(),
'proxy_country' => $country,
];
} catch (RequestException $e) {
$lastException = $e;
Log::warning('Proxy request failed', [
'url' => $url,
'attempt' => $attempt + 1,
'error' => $e->getMessage(),
]);
// Czekaj przed ponowną próbą
if ($attempt < $this->maxRetries - 1) {
usleep($this->retryDelay * 1000 * ($attempt + 1));
}
}
}
throw new RuntimeException(
'Proxy request failed after ' . $this->maxRetries . ' attempts: ' . $lastException?->getMessage(),
0,
$lastException
);
}
/**
* Pobiera zawartość z cache lub wykonuje zapytanie.
*
* @param string $key Klucz cache
* @param string $url URL do pobrania
* @param int $ttl Czas życia cache w sekundach
* @param array $proxyOptions Opcje proxy
*/
public function getOrCache(string $key, string $url, int $ttl = 3600, array $proxyOptions = []): string
{
return Cache::remember($key, $ttl, function () use ($url, $proxyOptions) {
$result = $this->get($url, $proxyOptions);
return $result['body'];
});
}
}
Teraz przykład Job'a Laravel, który wykorzystuje ten serwis:
<?php
namespace App\Jobs;
use App\Services\Proxy\ResidentialProxyPool;
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;
class ScrapeProductPrice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = 60;
protected string $productUrl;
protected string $country;
public function __construct(string $productUrl, string $country = 'US')
{
$this->productUrl = $productUrl;
$this->country = $country;
}
public function handle(ResidentialProxyPool $proxyPool): void
{
Log::info('Starting price scrape', [
'url' => $this->productUrl,
'country' => $this->country,
]);
try {
$result = $proxyPool->get($this->productUrl, [
'country' => $this->country,
'session' => 'price-' . md5($this->productUrl), // Sticky session
]);
// Parsuj HTML i wyekstrahuj cenę
$price = $this->extractPrice($result['body']);
// Zapisz do bazy lub cache
// Price::updateOrCreate(['url' => $this->productUrl], ['price' => $price]);
Log::info('Price scraped successfully', [
'url' => $this->productUrl,
'price' => $price,
'proxy_country' => $result['proxy_country'],
]);
} catch (\Exception $e) {
Log::error('Price scrape failed', [
'url' => $this->productUrl,
'error' => $e->getMessage(),
]);
throw $e; // Retry job
}
}
private function extractPrice(string $html): ?float
{
// Implementacja parsowania HTML
if (preg_match('/"price":\s*([0-9.]+)/', $html, $matches)) {
return (float) $matches[1];
}
return null;
}
}
// W kontrolerze lub Service
use App\Jobs\ScrapeProductPrice;
class PriceMonitorController extends Controller
{
public function queueScrape(Request $request)
{
$urls = $request->input('urls', []);
$country = $request->input('country', 'US');
foreach ($urls as $url) {
ScrapeProductPrice::dispatch($url, $country)
->onQueue('scraping');
}
return response()->json([
'queued' => count($urls),
'message' => 'Jobs dispatched successfully',
]);
}
}
multi_curl: równoległe pobieranie bez frameworków
Dla aplikacji bez Guzzle czy Symfony, natywny curl_multi_* pozwala na równoległe pobieranie wielu stron. To najszybsza metoda w czystym PHP.
<?php
declare(strict_types=1);
/**
* Równoległy klient HTTP z proxy dla wielu zapytań.
*/
class MultiCurlProxyClient
{
private string $proxyHost;
private int $proxyPort;
private string $proxyPassword;
private int $maxConnections;
private int $timeout;
public function __construct(
string $proxyPassword,
int $maxConnections = 10,
int $timeout = 30
) {
$this->proxyHost = 'gate.proxyhat.com';
$this->proxyPort = 8080;
$this->proxyPassword = $proxyPassword;
$this->maxConnections = $maxConnections;
$this->timeout = $timeout;
}
/**
* Pobiera wiele URL-i równolegle z różnymi proxy.
*
* @param array<string> $urls Lista URL-i do pobrania
* @param array<string> $countries Lista krajów do dystrybucji
* @return array<string, array> URL => wynik
*/
public function fetchAll(array $urls, array $countries = ['US', 'DE', 'GB']): array
{
$mh = curl_multi_init();
$handles = [];
$results = [];
if ($mh === false) {
throw new RuntimeException('Nie udało się zainicjalizować curl_multi');
}
try {
// Inicjalizuj wszystkie uchwyty cURL
foreach ($urls as $key => $url) {
$country = $countries[$key % count($countries)];
$sessionId = bin2hex(random_bytes(4));
$ch = curl_init($url);
if ($ch === false) {
continue;
}
// Konfiguracja proxy
$proxyUser = 'user-country-' . $country . '-session-' . $sessionId;
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_PROXY => $this->proxyHost,
CURLOPT_PROXYPORT => $this->proxyPort,
CURLOPT_PROXYUSERPWD => $proxyUser . ':' . $this->proxyPassword,
CURLOPT_PROXYAUTH => CURLAUTH_BASIC,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
]);
curl_multi_add_handle($mh, $ch);
$handles[$key] = [
'handle' => $ch,
'url' => $url,
'country' => $country,
];
}
// Wykonaj wszystkie zapytania
$active = null;
do {
$status = curl_multi_exec($mh, $active);
if ($active) {
// Czekaj na aktywność z timeout
curl_multi_select($mh, 1.0);
}
} while ($status === CURLM_CALL_MULTI_PERFORM || $active);
// Pobierz wyniki
foreach ($handles as $key => $data) {
$ch = $data['handle'];
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
$errno = curl_errno($ch);
$content = curl_multi_getcontent($ch);
if ($errno !== CURLE_OK) {
$results[$key] = [
'success' => false,
'error' => $error,
'errno' => $errno,
'url' => $data['url'],
];
} else {
$results[$key] = [
'success' => true,
'status' => $httpCode,
'body' => $content,
'url' => $data['url'],
'country' => $data['country'],
];
}
curl_multi_remove_handle($mh, $ch);
curl_close($ch);
}
} finally {
curl_multi_close($mh);
}
return $results;
}
/**
* Pobiera z batch'ingiem dla bardzo dużej liczby URL-i.
*
* @param array<string> $urls Lista URL-i
* @param callable $onProgress Callback postępu
*/
public function fetchBatched(
array $urls,
callable $onProgress = null,
array $countries = ['US', 'DE', 'GB', 'FR', 'PL']
): array {
$batches = array_chunk($urls, $this->maxConnections, true);
$allResults = [];
$completed = 0;
$total = count($urls);
foreach ($batches as $batch) {
$results = $this->fetchAll($batch, $countries);
$allResults = array_merge($allResults, $results);
$completed += count($batch);
if ($onProgress !== null) {
$onProgress($completed, $total, $results);
}
// Krótka pauza między batch'ami
usleep(100000); // 100ms
}
return $allResults;
}
}
// Przykład użycia
$client = new MultiCurlProxyClient('twoje_haslo', maxConnections: 20);
$urls = [];
for ($i = 1; $i <= 50; $i++) {
$urls[] = "https://httpbin.org/delay/1?n={$i}";
}
$startTime = microtime(true);
$results = $client->fetchBatched($urls, function ($completed, $total, $batchResults) {
echo "Postęp: {$completed}/{$total}\n";
}, ['US', 'DE', 'GB', 'FR', 'PL']);
$duration = microtime(true) - $startTime;
$successCount = count(array_filter($results, fn($r) => $r['success'] ?? false));
echo "Pobrano {$successCount}/" . count($urls) . " URL-i w " . round($duration, 2) . "s\n";
echo "Średnio " . round($duration / count($urls) * 1000, 0) . "ms na URL\n";
TLS/SSL: bezpieczna konfiguracja i CA bundle
Bezpieczeństwo połączeń przez proxy jest krytyczne — dane przesyłane przez proxy mogą być przechwytywane, jeśli nie użyjesz HTTPS z poprawną weryfikacją certyfikatów.
Typowe problemy z SSL w PHP
- Brak bundle CA — PHP nie zawsze ma dostęp do aktualnych certyfikatów root
- Przestarzałe wersje TLS — niektóre serwery wymagają TLS 1.2+
- SNI (Server Name Indication) — wymagane dla wirtualnych hostów HTTPS
<?php
declare(strict_types=1);
/**
* Konfiguracja TLS/SSL dla bezpiecznych połączeń przez proxy.
*/
class SecureProxyClient
{
private ?string $caBundlePath;
private int $minTlsVersion;
private bool $verifyPeer;
private bool $verifyHost;
public function __construct(
?string $caBundlePath = null,
int $minTlsVersion = CURL_SSLVERSION_TLSv1_2,
bool $verifyPeer = true,
bool $verifyHost = true
) {
$this->caBundlePath = $caBundlePath ?? $this->findCaBundle();
$this->minTlsVersion = $minTlsVersion;
$this->verifyPeer = $verifyPeer;
$this->verifyHost = $verifyHost;
}
/**
* Znajduje bundle CA w systemie.
*/
private function findCaBundle(): ?string
{
// Sprawdź zmienną środowiskową
$envBundle = getenv('CURL_CA_BUNDLE');
if ($envBundle && file_exists($envBundle)) {
return $envBundle;
}
// Sprawdź typowe lokalizacje
$paths = [
// Linux
'/etc/ssl/certs/ca-certificates.crt',
'/etc/pki/tls/certs/ca-bundle.crt',
'/etc/ssl/ca-bundle.pem',
// macOS
'/usr/local/etc/openssl/cert.pem',
'/usr/local/etc/openssl@1.1/cert.pem',
'/opt/homebrew/etc/openssl@3/cert.pem',
// Windows (XAMPP, WAMP)
'C:\xampp\php\extras\openssl\cacert.pem',
// Composer bundle
dirname(__DIR__) . '/vendor/composer/ca-bundle/res/cacert.pem',
];
foreach ($paths as $path) {
if (file_exists($path)) {
return $path;
}
}
return null;
}
/**
* Pobiera bundle CA z mozilli (fallback).
*/
public static function downloadMozBundle(string $targetPath): bool
{
$url = 'https://curl.se/ca/cacert.pem';
$ch = curl_init($url);
if ($ch === false) {
return false;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_SSL_VERIFYPEER => true,
]);
$data = curl_exec($ch);
$error = curl_error($ch);
curl_close($ch);
if ($data === false || !empty($error)) {
return false;
}
return file_put_contents($targetPath, $data) !== false;
}
/**
* Konfiguruje opcje SSL dla cURL.
*/
public function configureSsl($ch): void
{
// Weryfikacja certyfikatu serwera
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, $this->verifyPeer);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, $this->verifyHost ? 2 : 0);
// Minimalna wersja TLS
curl_setopt($ch, CURLOPT_SSLVERSION, $this->minTlsVersion);
// Bundle CA
if ($this->caBundlePath !== null) {
curl_setopt($ch, CURLOPT_CAINFO, $this->caBundlePath);
curl_setopt($ch, CURLOPT_CAPATH, dirname($this->caBundlePath));
}
// SNI (Server Name Indication) — domyślnie włączone w nowym cURL
curl_setopt($ch, CURLOPT_SSL_ENABLE_SNI, true);
// Lista szyfrów (cipher suites)
// curl_setopt($ch, CURLOPT_SSL_CIPHER_LIST, 'TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256');
}
/**
* Bezpieczne zapytanie przez proxy z pełną weryfikacją SSL.
*/
public function secureGet(string $url, string $proxyUser, string $proxyPass): array
{
$ch = curl_init($url);
if ($ch === false) {
throw new RuntimeException('Nie udało się zainicjalizować cURL');
}
try {
// Podstawowa konfiguracja
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_MAXREDIRS => 5,
CURLOPT_TIMEOUT => 30,
CURLOPT_CONNECTTIMEOUT => 10,
// Proxy
CURLOPT_PROXY => 'gate.proxyhat.com',
CURLOPT_PROXYPORT => 8080,
CURLOPT_PROXYUSERPWD => $proxyUser . ':' . $proxyPass,
CURLOPT_PROXYAUTH => CURLAUTH_BASIC,
]);
// Konfiguracja SSL
$this->configureSsl($ch);
// Nagłówki
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'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',
]);
// Wykonaj
$body = curl_exec($ch);
$error = curl_error($ch);
$errno = curl_errno($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$sslVerifyResult = curl_getinfo($ch, CURLINFO_SSL_VERIFYRESULT);
if ($errno !== CURLE_OK) {
throw new RuntimeException("Błąd cURL [{$errno}]: {$error}");
}
if ($this->verifyPeer && $sslVerifyResult !== 0) {
throw new RuntimeException("Weryfikacja SSL nie powiodła się: {$sslVerifyResult}");
}
return [
'status' => (int) $status,
'body' => (string) $body,
'ssl_verified' => $this->verifyPeer,
'ca_bundle' => $this->caBundlePath,
];
} finally {
curl_close($ch);
}
}
/**
* Sprawdza konfigurację SSL.
*/
public function diagnoseSsl(): array
{
$version = curl_version();
return [
'curl_version' => $version['version'],
'ssl_version' => $version['ssl_version'],
'ca_bundle' => $this->caBundlePath,
'ca_exists' => $this->caBundlePath !== null && file_exists($this->caBundlePath),
'supports_tls_1_2' => defined('CURL_SSLVERSION_TLSv1_2'),
'supports_tls_1_3' => defined('CURL_SSLVERSION_TLSv1_3'),
'sni_support' => $version['features'] & CURL_VERSION_SSL,
];
}
}
// Inicjalizacja i test
$client = new SecureProxyClient();
// Diagnoza
$diag = $client->diagnoseSsl();
echo "=== Diagnoza SSL ===\n";
echo "cURL: {$diag['curl_version']}\n";
echo "SSL: {$diag['ssl_version']}\n";
echo "CA Bundle: {$diag['ca_bundle']}\n";
echo "CA Exists: " . ($diag['ca_exists'] ? 'Yes' : 'No') . "\n";
// Pobierz bundle CA jeśli brakuje
if (!$diag['ca_exists']) {
echo "Pobieranie CA bundle...\n";
SecureProxyClient::downloadMozBundle(__DIR__ . '/cacert.pem');
}
Porównanie metod
| Metoda | Zalety | Wady | Najlepsze zastosowanie |
|---|---|---|---|
| cURL | Pełna kontrola, brak zależności, najwyższa wydajność | Verbose API, ręczna obsługa błędów | Proste skrypty, CLI tools |
| Guzzle | Czyste API, middleware, Promise, PSR-7 | Dodatkowa zależność | Aplikacje Symfony/Laravel, API clients |
| Symfony HTTP | Native async, streaming, AMPHP integration | Większa złożoność | Wysoka wydajność, streaming |
| multi_curl | Równoległość bez frameworków | Skomplikowany kod, brak abstrakcji | Batch processing, scraping dużej skali |
| Laravel Service | DI, queue integration, caching | Tylko dla projektów Laravel | Aplikacje Laravel, background jobs |
Wskazówka: Dla nowych projektów polecam Guzzle jako domyślny wybór — oferuje najlepszy stosunek prostoty do możliwości. Używaj multi_curl tylko gdy potrzebujesz maksymalnej wydajności bez zależności frameworkowych.
Kluczowe wnioski
- Zawsze weryfikuj SSL — nigdy nie wyłączaj
verify_peerw produkcji. Pobierz aktualny CA bundle jeśli brakuje w systemie. - Używaj sticky sessions — dla scrapingu wieloetapowego (logowanie, koszyk) używaj parametru
sessionw username, by zachować ten sam IP. - Implementuj retry z backoff — proxy residential może mieć przejściowe problemy; exponential backoff zwiększa sukces bez DDoS'owania.
- Rozdziel zapytania — używaj różnych krajów i sesji dla równoległych zapytań, by uniknąć rate limitów.
- Loguj wszystko — zapisuj kraj proxy, session ID i czas odpowiedzi dla każdego zapytania — to kluczowe dla debugowania.
- Cache'uj agresywnie — używaj cache dla danych, które rzadko się zmieniają (np. HTML kategorii, listy produktów).
Dla profesjonalnego scrapingu w PHP, residential proxy od ProxyHat z geo-targetingiem i sticky sessions to fundament stabilnej infrastruktury. Połącz to z retry logic i monitoringiem, a Twój scraper będzie działać niezawodnie nawet przy dużej skali.






