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.
- 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
composer require zlodes/http-clientYou'll also need a PSR-7 implementation and any PSR-18 HTTP client, for example:
composer require guzzlehttp/psr7 symfony/http-clientOptional integrations such as logging and metrics are up to your application and middleware choices.
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;
}
}use Zlodes\Http\Client\Contract\Response;
final readonly class GetUserResponse implements Response
{
public function __construct(
public int $id,
public string $name,
) {}
}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',
);
}
}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;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));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',
);
}
}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.
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 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)],
);The bundled Psr18Transport ignores them. PSR-18 has no per-request options, so configure timeouts on the wrapped client instead. To honour per-request options, implement your own Transport and read them from the TransportOptions argument. Throw TransportTimeoutException (extends TransportException) on timeout so callers can tell a timeout from a refused connection.
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 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),
);
}
}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;
}
}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),
],
);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),
);| 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.
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,
);
},
);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
composer install
vendor/bin/phpstan analyse # static analysis at max level
vendor/bin/phpunit # run tests