Part of the Jardis Business Platform — Enterprise-grade PHP components for Domain-Driven Design
HTTP requests without overhead. A lean PSR-18 client built on cURL — designed for DDD applications that call external APIs, send webhooks, or integrate services. No framework, no middleware stack, no dependency bloat. Just what you need.
- Two classes to learn —
HttpClient+ClientConfig. Includes its own PSR-7/PSR-17 implementation — zero external dependencies - Handler pipeline — each concern is its own invokable, orchestrated internally by the client
- Retry with backoff — automatic retry on 5xx and network errors
- PSR-18 compatible — works with any PSR-18-capable code
- 96% test coverage — integration tests against real HTTP requests, not mocks
composer require jardisadapter/httpuse JardisAdapter\Http\HttpClient;
use JardisAdapter\Http\Config\ClientConfig;
use JardisAdapter\Http\Message\Psr17Factory;
$psr17 = new Psr17Factory();
$client = new HttpClient($psr17, $psr17, $psr17, $psr17, new ClientConfig(
baseUrl: 'https://api.example.com/v2',
));
$response = $client->get('/users');
$data = json_decode((string) $response->getBody(), true);$response = $client->post('/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);$client->put('/users/1', ['name' => 'Jane Doe']);
$client->patch('/users/1', ['status' => 'active']);
$client->delete('/users/1');$response = $client->get('/reports', ['Accept' => 'text/csv']);
$response = $client->post('/import', $data, ['X-Request-Id' => 'abc-123']);$psr17 = new Psr17Factory();
$client = new HttpClient($psr17, $psr17, $psr17, $psr17, new ClientConfig(
baseUrl: 'https://api.example.com/v2',
timeout: 10,
connectTimeout: 5,
verifySsl: true,
defaultHeaders: ['Accept' => 'application/json'],
bearerToken: 'eyJhbGciOiJI...',
maxRetries: 3,
retryDelayMs: 200,
));
$response = $client->get('/users');
$response = $client->post('/orders', ['product' => 'Widget', 'quantity' => 3]);$psr17 = new Psr17Factory();
$client = new HttpClient($psr17, $psr17, $psr17, $psr17, new ClientConfig(
bearerToken: 'eyJhbGciOiJI...',
));
// Authorization: Bearer eyJhbGciOiJI... is set automatically$psr17 = new Psr17Factory();
$client = new HttpClient($psr17, $psr17, $psr17, $psr17, new ClientConfig(
basicUser: 'api-user',
basicPassword: 'secret',
));$psr17 = new Psr17Factory();
$client = new HttpClient($psr17, $psr17, $psr17, $psr17, new ClientConfig(
maxRetries: 3, // Up to 3 retries on 5xx
retryDelayMs: 200, // Exponential backoff: 200ms, 400ms, 800ms
));Automatically retries on HTTP 5xx and transport errors (HttpClientException, which covers both NetworkException and RequestException). No retry on 4xx — those are caller errors.
The client does not throw exceptions on HTTP 4xx/5xx — those are valid responses. Exceptions are only thrown for actual errors:
| Exception | When |
|---|---|
NetworkException |
DNS failure, connection refused, timeout |
RequestException |
Invalid request (malformed URI) |
use JardisAdapter\Http\Exception\NetworkException;
try {
$response = $client->get('/users');
} catch (NetworkException $e) {
// Network problem — retry was already active (if configured)
}
if ($response->getStatusCode() >= 400) {
// Handle HTTP errors yourself
}The client implements Psr\Http\Client\ClientInterface. For full control over the request, use sendRequest():
use JardisAdapter\Http\Message\Psr17Factory;
$factory = new Psr17Factory();
$request = $factory->createRequest('OPTIONS', 'https://api.example.com');
$response = $client->sendRequest($request);The user only sees HttpClient + ClientConfig. Internally, the client orchestrates a pipeline of invokable handlers — built from the config:
HttpClient (Orchestrator)
│
│ Convenience methods: get(), post(), put(), patch(), delete(), head()
│ └── internally create PSR-7 requests
│
│ Transformers (Request → Request, built from config):
│ ├── BaseUrl resolve relative URLs
│ ├── DefaultHeaders set default headers
│ ├── BearerAuth add bearer token
│ └── BasicAuth add basic auth
│
│ Transport (Request → Response, built from config):
│ ├── CurlTransport cURL-based transport
│ └── Retry wraps transport with exponential backoff
│
▼
sendRequest():
foreach transformer → $request = $transform($request)
return $transport($request, $config)
Each handler is an invokable object (__invoke) — independently testable, replaceable, composable. Only what is configured gets instantiated.
The transport is a closure — replaceable without changing the client:
$psr17 = new Psr17Factory();
$client = new HttpClient(
requestFactory: $psr17,
streamFactory: $psr17,
responseFactory: $psr17,
uriFactory: $psr17,
config: new ClientConfig(),
transport: function (RequestInterface $request, ClientConfig $config) use ($psr17) {
return $psr17->createResponse(200)
->withBody($psr17->createStream('{"mocked": true}'));
},
);In a Jardis DDD project, the client is automatically configured via ENV:
HTTP_BASE_URL=https://api.example.com
HTTP_TIMEOUT=30
HTTP_CONNECT_TIMEOUT=10
HTTP_VERIFY_SSL=true
HTTP_BEARER_TOKEN=eyJhbGciOiJI...
HTTP_MAX_RETRIES=3
HTTP_RETRY_DELAY_MS=200The HttpClientHandler in JardisApp builds the client and registers it in the ServiceRegistry. Your domain code receives ClientInterface via injection — without ever importing HttpClient directly.
cp .env.example .env # One-time setup
make install # Install dependencies
make phpunit # Run tests
make phpstan # Static analysis (Level 8)
make phpcs # Coding standards (PSR-12)PolyForm Shield License 1.0.0 — free for all use including commercial. Only restriction: don't build a competing framework.