Symfony HttpClient for API Consumption
Guzzle isn't the only first-class PHP option. Symfony HttpClient is async-capable, PSR-18-compatible, and ships with Symfony out of the box.
What you’ll learn
- Use `symfony/http-client` for sync and async-style scraping.
- Configure base URI, default headers, retries, and HTTP/2.
- Take advantage of `stream` and concurrent requests.
- Choose between Guzzle and Symfony HttpClient based on context.
If you work in a Symfony project, or want async concurrency without Guzzle's async-promise model, Symfony HttpClient is the better choice. It's tightly integrated with Symfony, PSR-18-compatible, supports HTTP/2 natively, and has a clean concurrent API.
Install
composer require symfony/http-client
# Optional, for HTTPS/2 and faster transport:
composer require symfony/http-client-contracts amphp/http-client
Symfony projects already have it. Standalone PHP projects need the explicit install.
Hello, HttpClient
<?php
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\HttpClient\HttpClient;
$client = HttpClient::create([
'base_uri' => 'https://practice.scrapingcentral.com',
'timeout' => 10,
'headers' => ['Accept' => 'application/json'],
'http_version' => '2.0',
]);
$res = $client->request('GET', '/api/products');
$data = $res->toArray(); // auto-decodes JSON
echo count($data['products']) . " products\n";
->toArray() is the killer feature, it decodes JSON, throws if the body isn't JSON, and throws if the response was an error. No json_decode($res->getBody()->getContents(), true) boilerplate.
Methods and options
The request() signature: request(method, url, options).
Common options:
$res = $client->request('POST', '/api/auth/login', [
'json' => ['email' => '...', 'password' => '...'], // JSON body
'headers' => ['X-Trace-Id' => '...'], // per-request headers
'query' => ['lang' => 'en'], // query string
'auth_basic' => ['user', 'pass'], // basic auth
'auth_bearer' => $token, // bearer auth
'timeout' => 5, // override
'max_redirects' => 0, // don't follow
]);
Response methods:
$res->getStatusCode(), int$res->getHeaders(), array of header-name => array-of-values$res->getContent(), string body (throws on 4xx/5xx unlessfalsepassed)$res->toArray(), decoded JSON$res->cancel(), abort the request$res->getInfo(), timing, redirects, response code, etc.
A clean Symfony-style client class
<?php
namespace App\Catalog108;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class Catalog108Symfony {
private HttpClientInterface $http;
private ?string $token = null;
public function __construct(?HttpClientInterface $http = null) {
$this->http = $http ?? HttpClient::create([
'base_uri' => 'https://practice.scrapingcentral.com',
'timeout' => 10,
'headers' => ['Accept' => 'application/json'],
]);
}
public function login(string $email, string $password): array {
$res = $this->http->request('POST', '/api/auth/login', [
'json' => compact('email', 'password'),
]);
$data = $res->toArray();
$this->token = $data['access_token'];
return $data;
}
public function me(): array {
return $this->call('GET', '/api/auth/me');
}
public function products(int $page = 1, int $perPage = 12): array {
return $this->call('GET', '/api/products', [
'query' => compact('page', 'perPage')
+ ['per_page' => $perPage] - ['perPage' => null],
]);
}
public function product(int $id): array {
return $this->call('GET', "/api/products/{$id}");
}
private function call(string $method, string $path, array $options = []): array {
if ($this->token) {
$options['auth_bearer'] = $this->token;
}
return $this->http->request($method, $path, $options)->toArray();
}
}
Inject HttpClientInterface via the constructor, easy to mock in tests, easy to wrap with the Symfony profiler in dev.
Concurrency: the killer feature
Symfony HttpClient handles concurrency without the promise gymnastics. Fire many requests, iterate over them as they complete:
$client = HttpClient::create(['base_uri' => 'https://practice.scrapingcentral.com']);
// Fire 10 requests, non-blocking
$responses = [];
for ($page = 1; $page <= 10; $page++) {
$responses[$page] = $client->request('GET', '/api/products', [
'query' => ['page' => $page, 'per_page' => 50],
]);
}
// Stream over them as they finish
foreach ($client->stream($responses) as $response => $chunk) {
if ($chunk->isLast()) {
$data = $response->toArray();
// Find which page this was
$page = array_search($response, $responses, true);
echo "Page {$page}: " . count($data['products']) . " products\n";
}
}
Behind the scenes, Symfony uses libcurl's multi-handle (or amphp if you've installed the async transport) to overlap requests. You write straight-line PHP and get true concurrency.
For batch scraping a paginated API, this pattern often gives 5–10x throughput over sequential Guzzle.
Retry middleware
Symfony HttpClient has a built-in RetryableHttpClient decorator:
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\HttpClient\RetryableHttpClient;
use Symfony\Component\HttpClient\Retry\GenericRetryStrategy;
$client = new RetryableHttpClient(
HttpClient::create(['base_uri' => 'https://practice.scrapingcentral.com']),
new GenericRetryStrategy(
statusCodes: [429 => ['GET', 'POST'], 500, 502, 503, 504],
delayMs: 1000, // base
multiplier: 2.0, // exponential
maxDelayMs: 30_000,
jitter: 0.5,
),
maxRetries: 5,
);
Wraps any HttpClientInterface, retains all the original config.
Guzzle vs Symfony HttpClient
| Concern | Guzzle | Symfony HttpClient |
|---|---|---|
| Sync | Excellent | Excellent |
| Concurrency | Promise-based | Stream-based, simpler API |
| HTTP/2 | Yes (with cURL handler) | Yes (default) |
| PSR-18 | Yes | Yes (via adapter) |
| Retries | Middleware | Built-in RetryableHttpClient |
| Best in | Standalone PHP, Laravel | Symfony, anywhere async matters |
For a Symfony project: use HttpClient. For a Laravel/standalone project where Guzzle is already pulled: use Guzzle. Both are first-class.
Production pattern: dependency injection in Symfony
In a Symfony app, register the client in services:
# config/services.yaml
services:
App\Catalog108\Catalog108Symfony:
arguments:
$http: '@Symfony\Contracts\HttpClient\HttpClientInterface'
And inject Catalog108Symfony anywhere, controllers, commands, message handlers. The framework wires the underlying HttpClient automatically. Dev profiler logs every API call in the Symfony debug toolbar.
Hands-on lab
In a fresh composer project, install symfony/http-client. Build the Catalog108Symfony class above. Run it against /api/products and /api/products/1/reviews. Then write the concurrent version: fire 10 page requests, iterate via $client->stream(), and time it against a sequential version. You should see a clear speedup proportional to network latency.
Hands-on lab
Practice this lesson on Catalog108, our first-party scraping sandbox.
Open lab target →/api/productsQuiz, check your understanding
Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.