zlodes / http-client
Observable, type-safe HTTP client for modern PHP
Requires
- php: ^8.4
- guzzlehttp/guzzle: ^7.9
- psr/http-client: ^1.0
- psr/http-message: ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.50
- guzzlehttp/psr7: ^2.9
- phpcompatibility/php-compatibility: ^9.3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^12.0
- slevomat/coding-standard: ^8.22
- squizlabs/php_codesniffer: ^3.13
Suggests
- amphp/http-client: Async HTTP transport via fibers
- guzzlehttp/psr7: PSR-7 implementation for building requests
- open-telemetry/api: OpenTelemetry tracing support
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-26 13:36:15 UTC
README
Observable, type-safe HTTP client for PHP 8.4+.
Generic Request<TResponse> ensures $client->send(request: $request) returns the correct response type — verified by PHPStan at max level.
Features
- Type-safe — generic
Request<TResponse>toTResponseflow, fully validated by PHPStan - Composable — middleware pipeline for auth, retries, logging, metrics, tracing, and custom concerns
- Framework-agnostic — depends only on PSR interfaces (
psr/http-message,psr/http-client) - Fiber-compatible — synchronous API that transparently supports async via AMP/Revolt fibers
- Flexible hydration — default
ResponseHydratoron the client, per-request override viaHasResponseHydrator - Typed error handling — map
4xx/5xxresponses into typed exceptions with reusable error handlers - Per-request timeouts — set total and connect timeouts globally, per client, or per endpoint
- Default headers — set one header on every request; a caller-supplied value wins
Installation
composer require zlodes/http-client
You'll also need a PSR-7 implementation and a PSR-18 HTTP client:
composer require guzzlehttp/psr7 guzzlehttp/guzzle
Optional integrations such as logging and metrics are up to your application and middleware choices.
Quick Start
Define a request
use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Zlodes\Http\Client\Contract\Request as RequestContract; /** * @implements RequestContract<GetUserResponse> */ final readonly class GetUserRequest implements RequestContract { public function __construct(private int $userId) {} public function getName(): string { return 'users.get'; } public function buildRequest(): RequestInterface { return new Request( method: 'GET', uri: "https://api.example.com/users/{$this->userId}", ); } public function getResponseClass(): string { return GetUserResponse::class; } }
Define a response
use Zlodes\Http\Client\Contract\Response; final readonly class GetUserResponse implements Response { public function __construct( public int $id, public string $name, ) {} }
Implement a hydrator
use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\Contract\Request; use Zlodes\Http\Client\Contract\Response; use Zlodes\Http\Client\Contract\ResponseHydrator; final readonly class JsonHydrator implements ResponseHydrator { public function __construct(private SerializerInterface $serializer) {} public function hydrate(ResponseInterface $response, Request $request): Response { return $this->serializer->deserialize( data: (string) $response->getBody(), type: $request->getResponseClass(), format: 'json', ); } }
Wire it together
use Zlodes\Http\Client\HttpClient; use Zlodes\Http\Client\Transport\Psr18Transport; $transport = new Psr18Transport(client: $psr18Client); $client = new HttpClient( transport: $transport, responseHydrator: $responseHydrator, ); // PHPStan knows this returns GetUserResponse $response = $client->send( request: new GetUserRequest(userId: 42), ); echo $response->name;
Empty Response (Fire-and-Forget Requests)
For requests that don't return a meaningful body (e.g., DELETE, PUT that returns 204), use EmptyResponse to skip hydration entirely:
use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Zlodes\Http\Client\Contract\Request as RequestContract; use Zlodes\Http\Client\EmptyResponse; /** * @implements RequestContract<EmptyResponse> */ final readonly class DeleteUserRequest implements RequestContract { // ... public function getResponseClass(): string { return EmptyResponse::class; } }
The client detects EmptyResponse and returns a shared singleton instance — no hydrator is invoked, no deserialization happens.
$client->send(request: new DeleteUserRequest(userId: 42));
Collection Responses
For endpoints that return a JSON array of items (e.g. GET /users → [{...}, {...}]), implement CollectionRequest<TItem> to get a typed, iterable response without writing a per-endpoint wrapper.
use GuzzleHttp\Psr7\Request; use Psr\Http\Message\RequestInterface; use Zlodes\Http\Client\Contract\CollectionRequest; /** * @implements CollectionRequest<UserDto> */ final readonly class ListUsersRequest implements CollectionRequest { public function getName(): string { return 'users.list'; } public function buildRequest(): RequestInterface { return new Request(method: 'GET', uri: 'https://api.example.com/users'); } public function getResponseClass(): string { return CollectionResponse::class; } public function getItemClass(): string { return UserDto::class; } }
Branch on CollectionRequest inside your hydrator and use CollectionResponse::fromHydrated() to build a typed response from a denormalizer payload of unknown shape — it validates the array shape and the element types at runtime and gives you back a CollectionResponse<TItem> without manual @var casts:
use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\CollectionResponse; use Zlodes\Http\Client\Contract\CollectionRequest; use Zlodes\Http\Client\Contract\Request; use Zlodes\Http\Client\Contract\Response; use Zlodes\Http\Client\Contract\ResponseHydrator; final readonly class JsonHydrator implements ResponseHydrator { public function __construct(private SerializerInterface $serializer) {} public function hydrate(ResponseInterface $response, Request $request): Response { $body = (string) $response->getBody(); if ($request instanceof CollectionRequest) { $items = $this->serializer->deserialize( data: $body, type: $request->getItemClass() . '[]', format: 'json', ); return CollectionResponse::fromHydrated(request: $request, items: $items); } return $this->serializer->deserialize( data: $body, type: $request->getResponseClass(), format: 'json', ); } }
Per-Request Hydration Override
For APIs that need custom parsing, implement HasResponseHydrator on the request:
use Zlodes\Http\Client\Contract\HasResponseHydrator; use Zlodes\Http\Client\Contract\ResponseHydrator; final readonly class LegacyApiRequest implements RequestContract, HasResponseHydrator { public function __construct( private string $id, private ResponseHydrator $hydrator, ) {} public function getResponseHydrator(): ResponseHydrator { return $this->hydrator; } // ... getName(), buildRequest(), getResponseClass() }
The client checks for HasResponseHydrator first, then falls back to the default hydrator.
Error Responses
HttpClient::send() treats 4xx/5xx responses as failures. Register ErrorResponseHandler implementations to turn those responses into typed exceptions.
use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\Contract\ErrorResponseHandler; use Zlodes\Http\Client\Contract\Request as RequestContract; use Zlodes\Http\Client\Exception\HttpClientException; use Zlodes\Http\Client\Exception\HttpErrorException; final class ValidationFailedException extends HttpErrorException { public function __construct( public readonly array $errors, RequestContract $request, ResponseInterface $response, ) { parent::__construct( request: $request, response: $response, payload: $errors, message: 'Validation failed', ); } } final readonly class ValidationErrorHandler implements ErrorResponseHandler { public function supports(ResponseInterface $response, RequestContract $request): bool { return $response->getStatusCode() === 422 && $request instanceof CreateUserRequest; } public function toException(ResponseInterface $response, RequestContract $request): HttpClientException { /** @var array{errors: array<string, list<string>>} $payload */ $payload = json_decode( json: (string) $response->getBody(), associative: true, flags: JSON_THROW_ON_ERROR, ); return new ValidationFailedException( errors: $payload['errors'], request: $request, response: $response, ); } }
Register handlers globally on the client, after the middleware array:
$client = new HttpClient( transport: $transport, responseHydrator: $responseHydrator, middlewares: [], errorResponseHandlers: [new ValidationErrorHandler()], );
Or per request with HasErrorResponseHandlers; request-level handlers are checked before client-level ones.
$request = new CreateUserRequest(input: $input); try { $response = $client->send(request: $request); } catch (ValidationFailedException $e) { return CreateUserResult::validationFailed(errors: $e->errors); } catch (HttpErrorException $e) { // Fallback for unhandled upstream errors. Raw PSR-7 response is available on $e->response. }
Timeouts
Timeouts travel with the request as a TransportOptions value. null means "not set here" and never overwrites a value set earlier, so precedence is always the same: request > client > global default.
use Zlodes\Http\Client\Factory\Option\WithTimeout; // Global default, applied to every client from this factory $factory = new ClientFactory(defaults: [ new WithTransport($transport), new WithResponseHydrator($hydrator), new WithTimeout(timeout: 10, connectTimeout: 3), ]); // Per client: overrides only the values it sets $slowClient = $factory->make( new WithTimeout(timeout: 60), );
For a single endpoint, implement HasTransportOptions. Those values win over everything else:
use Zlodes\Http\Client\Contract\HasTransportOptions; use Zlodes\Http\Client\TransportOptions; final readonly class SlowReportRequest implements RequestContract, HasTransportOptions { public function getTransportOptions(): TransportOptions { return new TransportOptions(timeout: 120); } // ... getName(), buildRequest(), getResponseClass() }
WithTimeout only adds a TimeoutMiddleware to the pipeline, so the same behaviour is available without the factory:
use Zlodes\Http\Client\Middleware\TimeoutMiddleware; $client = new HttpClient( transport: $transport, responseHydrator: $responseHydrator, middlewares: [new TimeoutMiddleware(timeout: 10, connectTimeout: 3)], );
Which transport actually applies the options:
GuzzleTransportmaps them to Guzzle'stimeoutandconnect_timeout, and sends withhttp_errorsdisabled. A cURL timeout (error 28) is thrown asTransportTimeoutException, which extendsTransportException, so a timeout is distinguishable from a refused connection.Psr18Transportignores them. PSR-18 has no per-request options, so configure timeouts on the wrapped client instead.
Default headers
Each option sets one header. Stack them for more. If the request already has that header, the caller's value is left as-is. Header names are matched case-insensitively.
use Zlodes\Http\Client\Factory\Option\WithHeader; $factory = new ClientFactory(defaults: [ new WithTransport($transport), new WithResponseHydrator($hydrator), new WithHeader('User-Agent', 'B2CORE Monolith/22.45.0 (tenant: acme)'), new WithHeader('X-Tenant', 'acme'), ]);
WithHeader only adds a HeaderMiddleware to the pipeline, so the same behaviour is available without the factory:
use Zlodes\Http\Client\Middleware\HeaderMiddleware; $client = new HttpClient( transport: $transport, responseHydrator: $responseHydrator, middlewares: [ new HeaderMiddleware('User-Agent', 'B2CORE Monolith/22.45.0'), ], );
Middleware
Middleware follows an onion model. Each middleware receives a RequestContext and a RequestHandler $next:
use Zlodes\Http\Client\Contract\Middleware; use Zlodes\Http\Client\Contract\RequestHandler; use Zlodes\Http\Client\RequestContext; use Psr\Http\Message\ResponseInterface; final readonly class AuthMiddleware implements Middleware { public function process(RequestContext $context, RequestHandler $next): ResponseInterface { $authenticatedRequest = $context->httpRequest->withHeader( name: 'Authorization', value: 'Bearer ...', ); return $next->handle( context: $context->withHttpRequest(httpRequest: $authenticatedRequest), ); } }
Logging example
use Psr\Http\Message\ResponseInterface; use Psr\Log\LoggerInterface; use Zlodes\Http\Client\Contract\Middleware; use Zlodes\Http\Client\Contract\RequestHandler; use Zlodes\Http\Client\RequestContext; final readonly class LoggingMiddleware implements Middleware { public function __construct(private LoggerInterface $logger) {} public function process(RequestContext $context, RequestHandler $next): ResponseInterface { $this->logger->info( message: 'Sending HTTP request', context: [ 'name' => $context->requestName, 'method' => $context->httpRequest->getMethod(), 'uri' => (string) $context->httpRequest->getUri(), ], ); try { $response = $next->handle(context: $context); } catch (\Throwable $e) { $this->logger->error( message: 'HTTP request failed', context: [ 'name' => $context->requestName, 'method' => $context->httpRequest->getMethod(), 'uri' => (string) $context->httpRequest->getUri(), 'error' => $e->getMessage(), ], ); throw $e; } $this->logger->info( message: 'HTTP response received', context: [ 'name' => $context->requestName, 'status' => $response->getStatusCode(), ], ); return $response; } }
Metrics example
use Psr\Http\Message\ResponseInterface; use Zlodes\Http\Client\Contract\Middleware; use Zlodes\Http\Client\Contract\RequestHandler; use Zlodes\Http\Client\RequestContext; interface MetricsCollector { public function recordRequest(string $name, string $method, int $statusCode, float $duration): void; } final readonly class MetricsMiddleware implements Middleware { public function __construct(private MetricsCollector $collector) {} public function process(RequestContext $context, RequestHandler $next): ResponseInterface { $start = hrtime(as_number: true); $response = $next->handle(context: $context); $duration = (hrtime(as_number: true) - $start) / 1e9; $this->collector->recordRequest( name: $context->requestName, method: $context->httpRequest->getMethod(), statusCode: $response->getStatusCode(), duration: $duration, ); return $response; } }
Wire them in by passing middleware instances to HttpClient:
$client = new HttpClient( transport: $transport, responseHydrator: $responseHydrator, middlewares: [ new LoggingMiddleware(logger: $logger), new MetricsMiddleware(collector: $metricsCollector), ], );
ClientFactory
For applications with multiple API clients sharing common configuration (transport, logging middleware, hydrator), use ClientFactory with composable options instead of constructing HttpClient directly.
use Zlodes\Http\Client\Factory\ClientFactory; use Zlodes\Http\Client\Factory\Option\WithTransport; use Zlodes\Http\Client\Factory\Option\WithResponseHydrator; use Zlodes\Http\Client\Factory\Option\WithMiddleware; use Zlodes\Http\Client\Factory\Option\WithBaseUri; // Shared defaults for all clients $factory = new ClientFactory(defaults: [ new WithTransport($transport), new WithResponseHydrator($hydrator), new WithMiddleware($loggingMiddleware, $metricsMiddleware), ]); // Per-service clients add their own options $usersClient = $factory->make( new WithBaseUri(new Uri('https://users-api.example.com')), ); $billingClient = $factory->make( new WithBaseUri(new Uri('https://billing-api.example.com')), new WithMiddleware($billingAuthMiddleware), );
Available options
| Option | Behavior |
|---|---|
WithTransport(Transport) |
Sets the transport (last-writer-wins) |
WithResponseHydrator(ResponseHydrator) |
Sets the hydrator (last-writer-wins) |
WithMiddleware(Middleware ...) |
Appends middlewares (additive) |
WithErrorResponseHandler(ErrorResponseHandler ...) |
Appends error handlers (additive) |
WithBaseUri(UriInterface) |
Sets the base URI (last-writer-wins) |
WithTimeout(?timeout, ?connectTimeout) |
Appends a TimeoutMiddleware (additive) |
WithHeader(name, value) |
Appends a HeaderMiddleware (additive) |
Defaults are applied first, then make() options on top. Last-writer-wins options can be overridden per client; additive options accumulate across defaults and make() calls.
Custom client creator
Pass a custom closure to control how HttpClient is instantiated:
$factory = new ClientFactory( defaults: $defaults, clientCreator: function (HttpClientConfig $config): HttpClient { // Custom validation, decoration, etc. return new HttpClient( $config->transport, $config->responseHydrator, $config->middlewares, $config->errorResponseHandlers, $config->baseUri, ); }, );
Architecture
HttpClient::send(Request<T>)
├── builds RequestContext (PSR-7 request + name + factory + TransportOptions)
├── MiddlewarePipeline (onion chain)
│ ├── Middleware 1
│ ├── Middleware 2
│ ├── request-level TransportOptions (innermost, wins)
│ └── Transport::send(request, options)
└── ResponseHydrator::hydrate() → T
Development
composer install vendor/bin/phpstan analyse # static analysis at max level vendor/bin/phpunit # run tests