Scoped and Typed Clients
Configure one HTTP client per API with its base URI and credentials, then inject it by name so no call site holds a secret.
public function __construct(private HttpClientInterface $articlesClient) {}A scoped client is a second HTTP client service, configured in advance for one API. It holds that API's base URI, its default headers and its credentials. Code that makes a call then passes a path and nothing else.
Defining one
Each key under framework.http_client.scoped_clients creates a service named after that key. base_uri is the URI that relative URLs resolve against. scope is a regular expression naming the URLs the options apply to, and is derived from base_uri when you leave it out. Options you pass to request() override the configured ones for that one call.
Injecting one
Every scoped client also gets a named autowiring alias: an alias the container matches on the argument's name as well as its type. A client configured as articles.client is injected into an argument declared HttpClientInterface $articlesClient. The longhand is an attribute on the argument, either #[Autowire(service: 'articles.client')] or #[Target('articlesClient')], and then the argument may be called anything. All three wire the same service. A plain HttpClientInterface $client still gets the unscoped http_client service.
Keeping credentials out of the call site
Set auth_bearer to an environment variable placeholder such as %env(ARTICLES_API_TOKEN)%. The client turns it into an Authorization: Bearer header on every request in scope. The token lives in configuration, not in a controller or a service method, and a request-level Authorization header wins over it when you need a different one. Setting both auth_bearer and auth_basic throws.
Example
<?php
// src/Service/ArticleApi.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ArticleApi
{
// Shorthand: the ARGUMENT NAME picks the client. The scoped client
// configured as "articles.client" is injected for $articlesClient.
public function __construct(
private HttpClientInterface $articlesClient,
) {
}
// Longhand for the same injection, with any argument name:
//
// use Symfony\Component\DependencyInjection\Attribute\Autowire;
//
// public function __construct(
// #[Autowire(service: 'articles.client')]
// private HttpClientInterface $client,
// ) {
// }
public function title(int $id): string
{
// Relative URL. The host and the bearer token come from
// configuration, so no credential appears in this file.
$response = $this->articlesClient->request('GET', 'articles/'.$id);
return $response->toArray()['title'];
}
}
// With base_uri https://api.example.com/v1/ this requests
// https://api.example.com/v1/articles/7
// Given the body {"id":7,"title":"Scoped clients"}
// Output: 'Scoped clients'When to use it
- Talk to a third-party API whose token must stay in configuration rather than in code.
- Call two separate APIs from one application without repeating their hosts and headers.
- Give one slow internal service a tighter timeout than the rest of your outbound calls.
- Point an API client at a staging host in one environment without editing any PHP.
More examples
The scoped client configuration
Two APIs, two services, each injected by argument name as $articlesClient and $searchClient. Scoped clients inherit default_options and override what they name, which is why search.client gets a 2 second timeout and articles.client keeps 5. The env placeholders are resolved at runtime, so the same configuration file works in every environment with different values in .env or in real environment variables.
# config/packages/framework.yaml
framework:
http_client:
# Applied to every client, scoped ones included.
default_options:
timeout: 5
scoped_clients:
# Creates a service named "articles.client" and a named
# autowiring alias for the argument $articlesClient.
articles.client:
# The trailing slash matters for relative URLs.
base_uri: 'https://api.example.com/v1/'
auth_bearer: '%env(ARTICLES_API_TOKEN)%'
headers:
Accept: 'application/json'
# A second API, with its own credentials and its own timeout.
search.client:
base_uri: 'https://search.internal.example.com/'
auth_basic: '%env(SEARCH_USER)%:%env(SEARCH_PASSWORD)%'
timeout: 2Wiring the client explicitly in services.yaml
The leading @ means a service reference, and the $client key names the constructor argument it is passed to. This is the same injection the argument-name alias and the #[Autowire] attribute perform. Reach for it when one class is wired differently per environment, since you can override the arguments key in a config/services_test.yaml without touching the PHP.
# config/services.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\:
resource: '../src/'
# The longhand of the named autowiring alias. Needed when the
# constructor argument is not called $articlesClient, and useful when
# you want the wiring visible in configuration rather than in the class.
App\Service\ArticleApi:
arguments:
$client: '@articles.client'Using a scoped client from a controller
GET /articles/7 returns 200 with the JSON body the upstream API gave for https://api.example.com/v1/articles/7. Injecting the scoped client in the constructor keeps the credential and the host out of the controller, which now only knows a path. In practice you would put the request in a service and let the controller call that, so the same API access can be reused by a console command.
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
final class ArticleController extends AbstractController
{
public function __construct(
private HttpClientInterface $articlesClient,
) {
}
#[Route('/articles/{id}', name: 'app_article_show', methods: ['GET'])]
public function show(int $id): JsonResponse
{
$response = $this->articlesClient->request('GET', 'articles/'.$id);
// toArray() throws for 300-599, so a missing article surfaces as
// an exception rather than as an empty page.
return $this->json($response->toArray());
}
}
Discussion