PHPackages                             zlodes/http-client - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. zlodes/http-client

ActiveLibrary

zlodes/http-client
==================

Observable, type-safe HTTP client for modern PHP

0.3.0(1mo ago)129↑313.8%proprietaryPHPPHP ^8.4CI passing

Since Apr 6Pushed 1mo agoCompare

[ Source](https://github.com/zlodes/php-http-client)[ Packagist](https://packagist.org/packages/zlodes/http-client)[ RSS](/packages/zlodes-http-client/feed)WikiDiscussions main Synced 1mo ago

READMEChangelog (4)Dependencies (9)Versions (5)Used By (0)

zlodes/http-client
==================

[](#zlodeshttp-client)

Observable, type-safe HTTP client for PHP 8.4+.

Generic `Request` ensures `$client->send(request: $request)` returns the correct response type — verified by PHPStan at max level.

Features
--------

[](#features)

- **Type-safe** — generic `Request` to `TResponse` flow, 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 `ResponseHydrator` on the client, per-request override via `HasResponseHydrator`
- **Typed error handling** — map `4xx/5xx` responses into typed exceptions with reusable error handlers

Installation
------------

[](#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
-----------

[](#quick-start)

### Define a request

[](#define-a-request)

```
use GuzzleHttp\Psr7\Request;
use Psr\Http\Message\RequestInterface;
use Zlodes\Http\Client\Contract\Request as RequestContract;

/**
 * @implements RequestContract
 */
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

[](#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

[](#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

[](#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)
-----------------------------------------

[](#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
 */
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));
```

Per-Request Hydration Override
------------------------------

[](#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
---------------

[](#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} $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.
}
```

Middleware
----------

[](#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

[](#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

[](#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
-------------

[](#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

[](#available-options)

OptionBehavior`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)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

[](#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
------------

[](#architecture)

```
HttpClient::send(Request)
    ├── builds RequestContext (PSR-7 request + name + factory)
    ├── MiddlewarePipeline (onion chain)
    │   ├── Middleware 1
    │   ├── Middleware 2
    │   └── Transport::send() (innermost)
    └── ResponseHydrator::hydrate() → T

```

Development
-----------

[](#development)

```
composer install
vendor/bin/phpstan analyse    # static analysis at max level
vendor/bin/phpunit            # run tests
```

###  Health Score

41

—

FairBetter than 89% of packages

Maintenance94

Actively maintained with recent releases

Popularity12

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity44

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

32d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/6fa5fb01ca9b1e8c18db29e23230211b62f313df293186942b8f009835209956?d=identicon)[zlodes](/maintainers/zlodes)

---

Top Contributors

[![zlodes](https://avatars.githubusercontent.com/u/1114387?v=4)](https://github.com/zlodes "zlodes (7 commits)")

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StylePHP\_CodeSniffer

Type Coverage Yes

### Embed Badge

![Health badge](/badges/zlodes-http-client/health.svg)

```
[![Health](https://phpackages.com/badges/zlodes-http-client/health.svg)](https://phpackages.com/packages/zlodes-http-client)
```

###  Alternatives

[cakephp/cakephp

The CakePHP framework

8.8k18.5M1.6k](/packages/cakephp-cakephp)[shopify/shopify-api

Shopify API Library for PHP

4634.8M16](/packages/shopify-shopify-api)[swisnl/json-api-client

A PHP package for mapping remote JSON:API resources to Eloquent like models and collections.

211473.2k12](/packages/swisnl-json-api-client)[laudis/neo4j-php-client

Neo4j-PHP-Client is the most advanced PHP Client for Neo4j

184616.9k31](/packages/laudis-neo4j-php-client)[neos/flow

Flow Application Framework

862.0M450](/packages/neos-flow)[neos/flow-development-collection

Flow packages in a joined repository for pull requests.

144179.3k3](/packages/neos-flow-development-collection)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
