Skip to content

HTTP client

Quiote ships a PSR-18 HTTP client for outbound requests. It’s a named-client factory (modelled on .NET’s IHttpClientFactory): you configure a client once by name, resolve it by name, and the same instance is reused for the worker’s lifetime rather than rebuilt per call. It’s also the central egress seam that makes outbound trace propagation and CLIENT spans work (see Telemetry).

Nothing here is on by default — the client is opt-in, and its default transport is a zero-dependency curl PSR-18 client. If Guzzle (which is a PSR-18 client) is installed, it’s used automatically; it’s never required.

Clients are configured in code rather than in a Config/* file. The usual home for that call is a plugin’s register() method — the registrar exposes httpClient() for exactly this (shown in Resolving the factory below) — so every client is defined once at boot and resolved by name anywhere afterwards.

Register a named client’s configuration on the HttpClientFactory, then resolve it:

use Quiote\Http\Client\{HttpClientFactory, HttpClientConfig};
$factory->configure('github', function (HttpClientConfig $c): void {
$c->baseUri('https://api.github.com')
->header('Accept', 'application/vnd.github+json')
->retry(attempts: 3, baseDelayMs: 100);
});
$response = $factory->client('github')->get('/repos/quioteframework/quiote');

client($name) builds the client on first use and memoizes it; client() with no name resolves the default client. The configurator runs lazily, once.

HttpClientConfig methods:

MethodEffect
baseUri(string)Prefix for relative request URIs.
header(string $name, string $value)A default header sent on every request.
headers(array)Multiple default headers at once.
retry(int $attempts, int $baseDelayMs = 100)Retry policy — retries transient failures (network errors, 429, 5xx) with backoff.
transport(ClientInterface)Use a specific PSR-18 client instead of the auto-selected default.

HttpClient implements PSR-18 ClientInterface (sendRequest()), plus convenience methods:

$client = $factory->client('github');
$client->get('/user');
$client->post('/repos/acme/app/issues', [
'headers' => ['Content-Type' => 'application/json'],
'body' => json_encode(['title' => 'Bug']),
]);
$client->put('/gists/123', $options);
$client->delete('/gists/123');
$client->request('PATCH', '/issues/1', $options);

The options array these convenience methods accept has exactly two keys — headers (a name => value map) and body (a raw string). There is no json option: encode the payload yourself with json_encode() and set Content-Type in headers, as above. $options in the put/request calls is just such an array.

HttpClientFactory is registered as a container singleton (alias http_client_factory), so inject it where you need it:

use Quiote\Http\Client\HttpClientFactory;
final class GitHubService
{
public function __construct(private HttpClientFactory $http) {}
public function repo(string $full): array
{
$res = $this->http->client('github')->get("/repos/$full");
return json_decode((string) $res->getBody(), true);
}
}

See The DI container for injection. From a plugin, contribute a named client with $registrar->httpClient('github', fn($c) => ...).

A “transport” is any PSR-18 ClientInterface. Two are available out of the box:

  • CurlTransport — the zero-dependency default; builds PSR-7 responses via Nyholm and maps curl connection/timeout failures to the PSR-18 network/request exceptions.
  • Guzzle — used automatically if guzzlehttp/guzzle is installed (it already implements PSR-18); no adapter needed.

TransportFactory::default() returns Guzzle if present, else curl. Override per client with HttpClientConfig::transport(), or globally with HttpClientFactory::setDefaultTransportFactory().

When telemetry is enabled, every request through HttpClient automatically:

  • opens a SpanKind::Client span (category Quiote.Http.Client, name "HTTP {method}"),
  • injects the W3C traceparent header into the outbound request, so the downstream service continues the same trace,
  • records http.request.method, url.full, and http.response.status_code, and captures exceptions.

All of it is gated on Trace::enabled() and guarded — telemetry never changes whether a request succeeds. This is the outbound half of context propagation: requests made through this client are traced end to end; requests made with a raw curl/socket bypass it.