crystoline/xpsr-http-client
Outgoing HTTP for XPSR applications: a configured PSR-18 client (Psr\Http\Client\ClientInterface), named clients with their own base URL and headers, and a fake client for tests. It is backed by Symfony HttpClient, which uses cURL when the extension is loaded and PHP streams otherwise.
composer require crystoline/xpsr-http-client
Add the provider to app.registry in config/app.php:
Crystoline\Xpsr\HttpClient\HttpClientServiceRegistry::class,
Then inject the client. Requests and responses are PSR-7 messages from the application's PSR-17 factories:
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
public function __construct(
private readonly ClientInterface $http,
private readonly RequestFactoryInterface $requests,
) {}
$response = $this->http->sendRequest($this->requests->createRequest('GET', 'https://api.example.com/status'));
if ($response->getStatusCode() !== 200) {
// 4xx and 5xx responses are returned, not thrown.
}
$status = json_decode((string) $response->getBody(), true);
Configuration
Configuration is optional. Create config/http_client.php to change the defaults:
return [
'timeout' => 10, // seconds without activity before a request fails
'max_duration' => 0, // seconds a whole request may take; 0 for no limit
'max_redirects' => 5, // 0 returns 3xx responses instead of following them
'headers' => ['User-Agent' => 'my-app/1.0'],
// Named clients, each a service: http_client.<name>
'clients' => [
'github' => [
'base_uri' => 'https://api.github.com',
'headers' => ['Accept' => 'application/vnd.github+json', 'Authorization' => 'Bearer ' . env('GITHUB_TOKEN')],
'timeout' => 5,
],
],
];
Unknown options and malformed values fail when a client is first used, with the name of the setting, for example http_client.clients.github.timeout must be a number of seconds greater than 0.
A named client starts from the default options. Its headers are added to the default headers and replace a default header of the same name, ignoring case. Its base_uri resolves relative request URLs, so createRequest('GET', 'repos/php-fig/http-client') on the github client requests https://api.github.com/repos/php-fig/http-client. Bind it to the class that uses it in config/factory.php:
App\Services\GitHub::class => static fn (Psr\Container\ContainerInterface $c) => new App\Services\GitHub(
$c->get('http_client.github'),
$c->get(Psr\Http\Message\RequestFactoryInterface::class),
),
Symfony HttpClient takes proxies from the http_proxy, https_proxy, and no_proxy environment variables (uppercase HTTP_PROXY only on the command line, because web servers let clients set it). TLS certificates are always verified.
Errors
PSR-18 defines what is thrown:
| Situation | Result |
|---|---|
| Any HTTP response, including 4xx and 5xx | Returned as a response |
Connection refused, DNS failure, TLS failure, timeout or max_duration exceeded |
Psr\Http\Client\NetworkExceptionInterface |
| A request that cannot be sent, such as a URL without a host | Psr\Http\Client\RequestExceptionInterface |
Both exceptions implement Psr\Http\Client\ClientExceptionInterface and return the request from getRequest().
Symfony's API
Symfony\Contracts\HttpClient\HttpClientInterface is registered too, with the default options. Inject it for what PSR-18 cannot express: streaming large downloads, or sending several requests at once:
$responses = [];
foreach ($urls as $url) {
$responses[] = $this->client->request('GET', $url); // starts all requests
}
foreach ($responses as $response) {
$data[] = $response->toArray(); // waits for each
}
Testing
Crystoline\Xpsr\HttpClient\Testing\FakeClient answers requests from a queue and records them, so tests never reach the network. Replace the client with swap() from the skeleton's tests/TestCase.php:
use Crystoline\Xpsr\HttpClient\Testing\FakeClient;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestInterface;
$factory = new Psr17Factory();
$fake = new FakeClient(
$factory->createResponse(200)->withBody($factory->createStream('{"status":"ok"}')),
static fn (RequestInterface $request) => $factory->createResponse(503),
);
$this->swap(ClientInterface::class, $fake); // or 'http_client.github'
$response = $this->get('/status');
self::assertSame('https://api.example.com/status', (string) $fake->lastRequest()->getUri());
self::assertSame(1, $fake->remaining());
A queued closure receives the request and returns a response, or throws to simulate a failure. A request with nothing left in the queue fails with a LogicException naming the request, so an unexpected call does not pass silently.
Services
| Service ID | Provides |
|---|---|
Psr\Http\Client\ClientInterface |
The default PSR-18 client. Shared. |
Symfony\Contracts\HttpClient\HttpClientInterface |
The same client with Symfony's API. Shared. |
http_client.<name> |
A PSR-18 client for each entry in clients. Shared, and sharing the default client's connections. |
Requires PHP 8.4+.