Symfony HttpClient: The Right Way to Call External APIs
Most Symfony HttpClient tutorials stop at $client->request('GET', $url). That works fine until the external API you depend on has a bad day. Then a checkout request waits 30 seconds for a payment provider that will never answer, PHP-FPM workers pile up behind it, and a service you do not operate takes down one you do.
The component itself is solid. What separates a demo from a production integration is everything around the request: how you scope clients, how you retry, how long you are willing to wait, and how you test and observe the whole thing. This post walks through the setup we use for client SaaS projects, ending with a worked example of a typed payment API client.
Scoped clients: one service per external API
The first mistake shows up early: injecting the generic HttpClientInterface everywhere and repeating the base URL and headers at every call site. Six months later the API key rotates and you are grepping the codebase for header names.
Symfony has a built-in answer, the scoped client. You configure base URI, default headers, and retry behavior once:
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
payment.client:
base_uri: 'https://api.payment-provider.com/v1/'
headers:
Authorization: 'Bearer %env(PAYMENT_API_KEY)%'
timeout: 5
max_duration: 10
Then wrap the scoped client in a typed service class so the rest of your application never sees HTTP at all:
final class PaymentApiClient
{
public function __construct(
private HttpClientInterface $paymentClient,
) {}
public function createCharge(ChargeRequest $request): Charge
{
$response = $this->paymentClient->request('POST', 'charges', [
'json' => $request->toArray(),
]);
return Charge::fromArray($response->toArray());
}
}
Controllers and handlers depend on PaymentApiClient and get back a Charge object. The provider's JSON shape, error codes, and auth scheme stay inside one class. When the provider changes their API, you have exactly one place to update, and one class to cover with tests.
Retries and rate limits without hammering the API
Networks fail, and most transient failures resolve within seconds. A retry with exponential backoff turns a blip into a non-event. Symfony ships this as configuration:
framework:
http_client:
scoped_clients:
payment.client:
retry_failed:
max_retries: 3
delay: 500
multiplier: 2
jitter: 0.1
http_codes: [423, 425, 429, 500, 502, 503, 504]
Three details matter more than the config syntax.
First, retry only what is safe. A GET can be retried blindly. A POST that creates a charge cannot, unless the API supports idempotency keys (more on that below). Symfony's default GenericRetryStrategy retries POST only for a narrow set of status codes for exactly this reason.
Second, respect Retry-After. When an API returns 429, it usually tells you when to come back. The built-in strategy honors that header, so a rate-limited response waits the requested time instead of your configured delay. If you write a custom RetryStrategyInterface, keep that behavior. Ignoring it gets your API key throttled harder or banned.
Third, add jitter. If 50 workers all fail at the same moment and all retry after exactly 500 ms, the API receives a synchronized wave and falls over again. Jitter spreads the herd.
For an API with a hard failure mode you can go one step further and wrap calls in a circuit breaker: after N consecutive failures, stop calling for a cooldown window and fail fast instead. You can build a small one on top of Symfony's cache or use the RateLimiter component to cap outbound request rates per provider. The point is that after the third timeout in a row, the fourth request should not wait five seconds to learn what you already know.
Treat timeouts as a latency budget
The default timeout in Symfony covers idle time between chunks, and max_duration caps the entire request. You want both, and you want them low.
Work backwards from your own SLA. If your endpoint must answer in two seconds and the external call is one step of several, that call gets a budget of a few hundred milliseconds, not the 30-second default your HTTP layer might allow. An external API slower than your budget is effectively down, and treating it that way early keeps your worker pool alive.
Async responses help here too. HttpClient requests are lazy: the request starts when you call request(), but blocks only when you read the response. If you need three independent APIs, fire all three first, then read the results. Total wall time becomes the slowest call instead of the sum of all of them.
Streaming responses instead of buffering them
$response->toArray() buffers the whole body in memory. For a 200 KB JSON payload nobody cares. For a 2 GB export or a server-sent event stream, buffering is either an out-of-memory error or plain wrong.
The stream() method gives you chunks as they arrive:
$response = $client->request('GET', 'exports/12345');
foreach ($client->stream($response) as $chunk) {
fwrite($localFile, $chunk->getContent());
}
For SSE endpoints, which most LLM APIs use, Symfony provides EventSourceHttpClient. It handles the text/event-stream framing and reconnection with Last-Event-ID, so you consume typed ServerSentEvent objects instead of parsing data: lines yourself. If you are integrating OpenAI or Anthropic streaming into a Symfony backend, this is the piece most homegrown implementations get wrong.
Testing Symfony HttpClient without touching the network
Integration tests that hit real external APIs are slow and flaky, and with paid providers they can cost real money. MockHttpClient replaces the transport while keeping your real client code, serializer, and error handling in the loop:
$mock = new MockHttpClient([
new MockResponse('{"id":"ch_123","status":"succeeded"}', [
'http_code' => 200,
]),
new MockResponse('', ['http_code' => 429, 'response_headers' => ['retry-after' => '2']]),
]);
$apiClient = new PaymentApiClient($mock);
Queue responses in order and you can test the happy path, the rate-limit path, and the malformed-JSON path deterministically. A callable factory instead of a fixed array lets you assert on the outgoing request: correct URL, correct headers, correct body. That catches the class of bug where a refactor silently drops the idempotency header, which no happy-path test would notice.
One habit worth adopting: record a handful of real responses from the provider's sandbox once, store them as fixtures, and feed those to MockHttpClient. Your tests then verify behavior against payloads the provider actually sends.
Tracing outbound calls with OpenTelemetry
When a request is slow, the first question is where the time went. If external calls are invisible in your traces, the answer becomes guesswork.
The OpenTelemetry PHP instrumentation for Symfony's HttpClient decorates the client and emits a span per outbound request, with URL, method, status code, and duration, and it propagates the traceparent header so the trace continues into any downstream service that participates. Once it is wired in, your trace for a slow checkout shows a 1.8-second span named POST api.payment-provider.com and the debate is over.
If you do not run OpenTelemetry, at minimum log outbound calls with duration and status through a decorator around HttpClientInterface. Ten lines of code, and it will pay for itself during the first incident.
A worked example: a typed payment client
Here is how the pieces combine for a Stripe-style API, where retries are only safe because every write carries an idempotency key:
final class PaymentApiClient
{
public function __construct(
private HttpClientInterface $paymentClient,
private LoggerInterface $logger,
) {}
public function createCharge(ChargeRequest $request): Charge
{
$start = microtime(true);
$response = $this->paymentClient->request('POST', 'charges', [
'json' => $request->toArray(),
'headers' => [
'Idempotency-Key' => $request->idempotencyKey,
],
]);
try {
$data = $response->toArray();
} catch (ClientExceptionInterface $e) {
throw PaymentDeclined::fromResponse($e->getResponse());
} finally {
$this->logger->info('payment_api.create_charge', [
'status' => $response->getStatusCode(),
'duration_ms' => (int) ((microtime(true) - $start) * 1000),
]);
}
return Charge::fromArray($data);
}
}
The idempotency key is generated once per business operation, for example from the order ID, and stored with the order. If the request times out and the retry layer resends it, the provider recognizes the key and returns the original result instead of charging the customer twice. That single header is what makes retry_failed safe to enable for POST requests.
The scoped client config from earlier supplies the base URI, auth header, timeouts, and retry strategy. The class above stays small because the framework carries that weight.
Where this usually goes wrong in real codebases
In code audits we keep finding the same three issues: no explicit timeouts, so the app inherits whatever the transport defaults to; retries on non-idempotent writes, which cause duplicate side effects under load; and zero visibility into outbound latency, so external APIs get blamed or exonerated on gut feeling. None of these are hard to fix. They are just easy to skip while the integration works in staging.
If you are building a product where external APIs sit on the critical path, payments, KYC providers, LLM backends, it is worth getting this layer right before the first incident instead of after. That is the kind of groundwork we do in custom software development projects, and retrofitting it into an existing codebase is a common part of legacy modernization work.
If you want a second pair of eyes on your own integration layer, write to hello@wolf-tech.io or visit wolf-tech.io. We read every message.

