- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
There's a file in your codebase called StripeApiClient or MailchimpService, and it takes a raw HttpClientInterface in the constructor. Inside, every method repeats the base URL, re-adds the auth header, sets the same timeout, and copy-pastes a try/catch that hopes the vendor is up. Twelve methods, twelve chances to get the header wrong.
Then the vendor has a bad afternoon. Requests hang for 30 seconds because nobody set a timeout. A transient 503 that would've cleared on the second try takes down a checkout instead. And your test suite hits the real API because there was never a clean seam to fake.
Symfony's HttpClient already solves most of this. Scoped clients kill the repeated config. RetryableHttpClient handles the transient failures. The mock client gives you the test seam. The last piece, the adapter boundary, is on you: it's what keeps the vendor from bleeding into your domain.
Scoped clients: config once, not per call
A scoped client is a pre-configured HttpClientInterface that applies a base URI, headers, and timeouts to every request whose URL matches a host pattern. You define it in YAML and inject it by name.
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
github.client:
base_uri: 'https://api.github.com'
headers:
Accept: 'application/vnd.github+json'
X-GitHub-Api-Version: '2022-11-28'
auth_bearer: '%env(GITHUB_TOKEN)%'
timeout: 5
max_duration: 10
Now you don't pass a bearer token or a base URL anywhere in PHP. Symfony generates a service you can autowire by the camelCased name of the scope:
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class GitHubGateway
{
public function __construct(
private HttpClientInterface $githubClient,
) {
}
public function repo(string $owner, string $name): array
{
return $this->githubClient
->request('GET', "/repos/{$owner}/{$name}")
->toArray();
}
}
The argument name $githubClient is the wiring. Symfony matches it to the github.client scope. No #[Autowire] attribute needed, though you can add one if you prefer to be explicit.
Two timeouts are doing different jobs here. timeout is the idle timeout — how long a connection can sit with no data flowing before it's dropped. max_duration is the hard ceiling on the whole request, retries included. Set both. A vendor that trickles one byte every four seconds will never trip timeout on its own, and max_duration is what saves you.
RetryableHttpClient: stop hand-rolling the retry loop
Most hand-written retry loops share one bug: they retry a POST that already succeeded server-side, so the customer gets charged twice. RetryableHttpClient gives you exponential backoff with jitter and lets you scope retries by status code. But read its defaults before you trust them. Only connection errors, 500, and 504 are limited to idempotent methods (GET, HEAD, PUT, DELETE, OPTIONS, TRACE). The rest of the default list (423, 425, 429, 502, 503, 507, 510) retries every method, POST included. So the checkout 503 from the opening gets retried by default, and on a non-idempotent write that's the exact double-charge you were trying to avoid.
The simplest way to turn it on is per scope:
# config/packages/framework.yaml
framework:
http_client:
scoped_clients:
github.client:
base_uri: 'https://api.github.com'
auth_bearer: '%env(GITHUB_TOKEN)%'
timeout: 5
max_duration: 15
retry_failed:
max_retries: 3
delay: 1000
multiplier: 2
max_delay: 5000
delay is milliseconds before the first retry. multiplier doubles it each attempt. max_delay caps the wait so a 503 storm doesn't push a single request past a minute. With these numbers the waits are roughly 1s, 2s, 4s, plus the jitter Symfony adds so you don't stampede the vendor when it recovers.
If this scope ever sends a POST or any other write, override http_codes so the risky statuses only retry safe methods:
retry_failed:
max_retries: 3
http_codes:
429: ['GET', 'HEAD']
502: ['GET', 'HEAD']
503: ['GET', 'HEAD']
Now a 503 on a write is surfaced to your code instead of retried behind your back. The alternative is to make the write idempotent with an idempotency key so a second attempt is a no-op. Do that when the vendor supports it, and keep the default retry list for reads.
If you need to decide what counts as retryable (say a vendor that signals "try again" with a 409 and a specific body), implement RetryStrategyInterface and wire it in. But reach for that only when the defaults genuinely don't fit. They fit more often than people expect.
One thing worth internalizing: retries multiply your latency budget. Three retries against a 5s timeout is potentially 15s of wall time, which is why max_duration exists. If a request is on the path of a synchronous web response, keep the retry count low or push the call into a message handler where 15s is fine.
The adapter boundary: keep the vendor at the edge
Scoped clients and retries are configuration. The design decision is refusing to let HttpClientInterface leak past one class.
The problem with injecting the HTTP client into a service is that the service now speaks HTTP. Its callers learn about status codes, JSON shapes, and ResponseInterface. When the vendor changes a field name, the change radiates through everything that touched the client.
An adapter stops that. You define an interface in terms your domain cares about, and the HTTP mess lives behind it.
namespace App\Domain\Repository;
use App\Domain\Model\Repository as RepoModel;
interface SourceRepositoryProvider
{
public function fetch(string $owner, string $name): RepoModel;
}
The domain names the operation. It says nothing about HTTP, GitHub, or JSON. The implementation lives in the infrastructure layer, and it's the only place that imports Symfony's contracts:
namespace App\Infrastructure\GitHub;
use App\Domain\Model\Repository as RepoModel;
use App\Domain\Repository\SourceRepositoryProvider;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\HttpClient\Exception\
ExceptionInterface as HttpException;
final class GitHubRepositoryProvider implements
SourceRepositoryProvider
{
public function __construct(
private HttpClientInterface $githubClient,
) {
}
public function fetch(string $owner, string $name): RepoModel
{
try {
$data = $this->githubClient
->request('GET', "/repos/{$owner}/{$name}")
->toArray();
} catch (HttpException $e) {
throw new RepositoryUnavailable(
"GitHub lookup failed for {$owner}/{$name}",
previous: $e,
);
}
return new RepoModel(
fullName: $data['full_name'],
stars: $data['stargazers_count'],
openIssues: $data['open_issues_count'],
);
}
}
Notice the exception translation. Symfony throws TransportException, ClientException, ServerException — all of them extend the transport ExceptionInterface. Your domain shouldn't catch those; it should catch RepositoryUnavailable, a name from your own vocabulary. The adapter is where the vendor's failure language gets translated into yours.
The mapping in the return statement is the other half. stargazers_count is GitHub's word. stars is yours. If GitHub renames the field, exactly one file changes.
Mocking in tests: the seam you designed for
Because the adapter takes an HttpClientInterface, you can hand it a MockHttpClient and never touch the network. No HTTP server, no fixtures directory of recorded responses unless you want them.
use App\Infrastructure\GitHub\GitHubRepositoryProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class GitHubRepositoryProviderTest extends TestCase
{
public function testMapsResponseToDomainModel(): void
{
$client = new MockHttpClient(new MockResponse(
json_encode([
'full_name' => 'symfony/symfony',
'stargazers_count' => 30000,
'open_issues_count' => 700,
]),
['http_code' => 200],
));
$provider = new GitHubRepositoryProvider($client);
$repo = $provider->fetch('symfony', 'symfony');
self::assertSame('symfony/symfony', $repo->fullName);
self::assertSame(30000, $repo->stars);
}
}
MockHttpClient returns queued responses in order, so you can script a whole conversation — a 429 followed by a 200 to prove your retry path works, or a malformed body to prove your error translation fires.
You can also assert on what was sent. Pass a callback instead of a static response and inspect the method, URL, and headers:
$client = new MockHttpClient(function ($method, $url, $options) {
self::assertSame('GET', $method);
self::assertStringContainsString(
'/repos/symfony/symfony',
$url,
);
return new MockResponse('{"full_name":"symfony/symfony",'
. '"stargazers_count":1,"open_issues_count":0}');
});
That callback runs when the request is made, which lets you verify the adapter built the request correctly without a live endpoint. It's the test that catches the day someone fat-fingers the path template.
Putting it together in the container
The last piece is wiring the interface to the implementation so your domain depends on SourceRepositoryProvider, never on the GitHub class:
# config/services.yaml
services:
App\Domain\Repository\SourceRepositoryProvider:
class: App\Infrastructure\GitHub\GitHubRepositoryProvider
arguments:
$githubClient: '@github.client'
Now the whole stack reads cleanly. The scope holds the base URL, auth, and timeouts. retry_failed handles the transient failures without a hand-written loop. The adapter translates HTTP into domain language and vendor exceptions into your own. And every one of those layers is fake-able in a unit test because the seam was there from the start.
Swap GitHub for GitLab later and you write a second implementation of the same interface. The domain doesn't notice. That's the entire point of drawing the line where you drew it.
The short version
- Move base URL, auth, and timeouts into a scoped client. Stop repeating them.
- Set both
timeoutandmax_duration. The idle timeout alone won't save you. - Turn on
retry_failedinstead of hand-rolling a retry loop, but scope itshttp_codesso a non-idempotentPOSTnever gets retried into a double charge. - Wrap the client in an adapter that speaks your domain's language and throws your domain's exceptions.
- Test the adapter with
MockHttpClient— assert on the response mapping and on the request you sent.
None of this is exotic. It's the difference between an integration you can reason about in a year and one you're afraid to touch.
Keeping HTTP at the edge is a small version of a larger idea: the third-party API is infrastructure, and infrastructure belongs behind an interface your domain owns. The adapter is where a vendor's shape stops and yours begins, which is exactly the boundary that lets you swap providers, fake them in tests, and outlive whichever SDK was fashionable when you started. That boundary — where the framework ends and your application begins — is what Decoupled PHP is about.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)