Scraping Central is reader-supported. When you buy through links on our site, we may earn an affiliate commission.

3.13intermediate5 min read

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 unless false passed)
  • $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/products

Quiz, check your understanding

Pass mark is 70%. Pick the best answer; you’ll see the explanation right after.

Symfony HttpClient for API Consumption1 / 8

Which response method on Symfony HttpClient auto-decodes JSON and throws on errors?

Score so far: 0 / 0