HTTP Client
PSR-18 HTTP client with handler pipeline, retry and its own PSR-7 implementation: without Guzzle, without Symfony.
Introduction
HTTP clients in PHP often mean: Guzzle with 30+ dependencies, or Symfony HttpClient with framework coupling. Anyone who just needs a clean PSR-18 client that uses cURL and is configurable faces a choice between too much and too little.
jardisadapter/http is the lean alternative. Two public classes (HttpClient and ClientConfig) with its own PSR-7/PSR-17 implementation and a configurable handler pipeline:
- PSR-18 compatible —
ClientInterfacefully implemented, including correct exception classification - Own PSR-7 + PSR-17 — Request, Response, Stream, Uri and Factory without external dependencies
- Handler pipeline — Base URL, default headers, Bearer/Basic auth are only instantiated when configured
- Retry with Exponential Backoff — automatic retries on 5xx and network errors
- Replaceable transport — cURL transport by default, custom transport via closure for tests
- Convenience methods —
get(),post(),put(),patch(),delete(),head()with automatic JSON encoding
Installation
composer require jardisadapter/httpGitHub: jardisAdapter/http
Required PHP extension: ext-curl
Basic Usage
Simple Requests
use JardisAdapter\Http\HttpClient;
use JardisAdapter\Http\Config\ClientConfig;
use JardisAdapter\Http\Message\Psr17Factory;
$factory = new Psr17Factory();
$client = new HttpClient($factory, $factory, $factory, $factory);
// GET
$response = $client->get('https://api.example.com/users');
$body = json_decode($response->getBody()->getContents(), true);
// POST with automatic JSON encoding
$response = $client->post('https://api.example.com/users', [
'name' => 'John Doe',
'email' => 'john@example.com',
]);
// PUT, PATCH, DELETE
$client->put('https://api.example.com/users/42', ['name' => 'Jane Doe']);
$client->patch('https://api.example.com/users/42', ['email' => 'jane@example.com']);
$client->delete('https://api.example.com/users/42');PSR-18 Standard API
$request = $factory->createRequest('GET', 'https://api.example.com/users')
->withHeader('Accept', 'application/json');
$response = $client->sendRequest($request);
$response->getStatusCode(); // 200
$response->getBody()->getContents(); // Response bodyConfiguration
All options are controlled via ClientConfig. Only configured features are instantiated:
use JardisAdapter\Http\Config\ClientConfig;
$config = new ClientConfig(
timeout: 30, // Request timeout in seconds
connectTimeout: 10, // Connection timeout
baseUrl: 'https://api.example.com/v1', // Base URL for relative paths
verifySsl: true, // SSL verification
defaultHeaders: [ // Default headers for every request
'Accept' => 'application/json',
'X-Api-Version' => '2',
],
bearerToken: 'eyJhbG...', // Bearer token (takes precedence over Basic)
maxRetries: 3, // Retry attempts on 5xx/network error
retryDelayMs: 200, // Base delay for Exponential Backoff
);
$client = new HttpClient($factory, $factory, $factory, $factory, $config);Base URL
Relative paths are automatically resolved:
$config = new ClientConfig(baseUrl: 'https://api.example.com/v1');
$client = new HttpClient($factory, $factory, $factory, $factory, $config);
$client->get('/users'); // → https://api.example.com/v1/users
$client->get('/users/42'); // → https://api.example.com/v1/users/42
// Absolute URLs are not modified
$client->get('https://other.api.com/data'); // → https://other.api.com/dataAuthentication
// Bearer token (OAuth2, JWT, API keys)
$config = new ClientConfig(bearerToken: 'eyJhbG...');
// → Authorization: Bearer eyJhbG...
// Basic auth
$config = new ClientConfig(basicUser: 'admin', basicPassword: 's3cret');
// → Authorization: Basic YWRtaW46czNjcmV0
// Bearer takes precedence — if both are set, only Bearer is usedDefault Headers
$config = new ClientConfig(defaultHeaders: [
'Accept' => 'application/json',
'X-Tenant-Id' => 'acme',
]);
// Per-request headers override default headers
$client->get('/users', ['Accept' => 'text/xml']);
// → Accept: text/xml (per-request wins)Retry
Automatic retry on server errors and network problems:
$config = new ClientConfig(
maxRetries: 3, // 3 retries after the first attempt
retryDelayMs: 200, // 200ms base delay
);Exponential Backoff
| Attempt | Delay |
|---|---|
| 1 (initial) | immediate |
| 2 | 200ms |
| 3 | 400ms |
| 4 | 800ms |
What triggers a retry?
| Situation | Retry? |
|---|---|
| HTTP 5xx (server error) | Yes |
| Network error (DNS, timeout, connection refused) | Yes |
| HTTP 4xx (client error) | No — returned immediately |
| HTTP 2xx/3xx (success) | No — returned immediately |
Behavior when retries are exhausted
- All retries on 5xx consumed → last 5xx response is returned (no throw)
- All retries on exception consumed → last exception is thrown
Custom Transport (Tests)
The cURL transport can be replaced by a closure, ideal for tests without an HTTP server:
$client = new HttpClient(
$factory, $factory, $factory, $factory,
config: new ClientConfig(),
transport: function ($request, $config) use ($factory) {
return $factory->createResponse(200)
->withBody($factory->createStream('{"mocked": true}'));
},
);
$response = $client->get('https://api.example.com/users');
// Status: 200, Body: {"mocked": true}Request Capturing
$captured = null;
$transport = function ($request) use (&$captured, $factory) {
$captured = $request;
return $factory->createResponse(200);
};
$client = new HttpClient(
$factory, $factory, $factory, $factory,
config: new ClientConfig(baseUrl: 'https://api.example.com'),
transport: $transport,
);
$client->get('/users');
$captured->getUri()->__toString(); // 'https://api.example.com/users'
$captured->getMethod(); // 'GET'Error Handling
PSR-18 compliant: HTTP error codes (4xx, 5xx) are not exceptions, but valid responses. Only transport errors throw exceptions:
| Exception | Cause |
|---|---|
NetworkException | DNS error, connection refused, timeout, SSL error |
RequestException | Invalid request, cURL initialization error |
HttpClientException | Base for both (implements PSR-18 ClientExceptionInterface) |
use JardisAdapter\Http\Exception\NetworkException;
use JardisAdapter\Http\Exception\RequestException;
try {
$response = $client->get('https://api.example.com/users');
if ($response->getStatusCode() >= 400) {
// HTTP error — not an exception, but a response
}
} catch (NetworkException $e) {
// Network problem
$failedRequest = $e->getRequest();
} catch (RequestException $e) {
// Invalid request
}Architecture
The package follows the Closure-Orchestrator-Pattern with a two-stage pipeline:
HttpClient ← Orchestrator (PSR-18)
├── Transformers (Request → Request)
│ ├── BaseUrl ← Resolve relative URLs
│ ├── DefaultHeaders ← Set default headers
│ ├── BearerAuth ← Authorization: Bearer
│ └── BasicAuth ← Authorization: Basic
└── Transport (Request → Response)
└── Retry (optional, when maxRetries > 0) ← wraps CurlTransport
└── CurlTransport ← cURL executionDirectory Structure
src/
├── HttpClient.php ← Orchestrator (PSR-18)
├── Config/
│ └── ClientConfig.php ← Configuration
├── Handler/
│ ├── BaseUrl.php ← URL resolution
│ ├── DefaultHeaders.php ← Default headers
│ ├── BearerAuth.php ← Bearer token
│ ├── BasicAuth.php ← Basic auth
│ ├── CurlTransport.php ← cURL transport
│ └── Retry.php ← Retry wrapper
├── Exception/
│ ├── HttpClientException.php
│ ├── NetworkException.php
│ └── RequestException.php
└── Message/
├── Psr17Factory.php ← PSR-17 factory
├── Request.php ← PSR-7 request
├── Response.php ← PSR-7 response
├── Stream.php ← PSR-7 stream
└── Uri.php ← PSR-7 URIAPI Reference
HttpClient
| Method | Signature | Description |
|---|---|---|
sendRequest | sendRequest(RequestInterface $request): ResponseInterface | PSR-18 standard |
get | get(string $uri, array $headers = []): ResponseInterface | GET request |
post | post(string $uri, array $data = [], array $headers = []): ResponseInterface | POST with JSON |
put | put(string $uri, array $data = [], array $headers = []): ResponseInterface | PUT with JSON |
patch | patch(string $uri, array $data = [], array $headers = []): ResponseInterface | PATCH with JSON |
delete | delete(string $uri, array $headers = []): ResponseInterface | DELETE |
head | head(string $uri, array $headers = []): ResponseInterface | HEAD |
ClientConfig
| Property | Type | Default | Description |
|---|---|---|---|
timeout | int | 30 | Request timeout (seconds) |
connectTimeout | int | 10 | Connection timeout |
baseUrl | ?string | null | Base URL |
verifySsl | bool | true | SSL verification |
defaultHeaders | array | [] | Default headers |
bearerToken | ?string | null | Bearer token |
basicUser | ?string | null | Basic auth user |
basicPassword | ?string | null | Basic auth password |
maxRetries | int | 0 | Retry attempts |
retryDelayMs | int | 100 | Base delay (ms) |
Complete Example
API client with base URL, Bearer auth, default headers and retry:
use JardisAdapter\Http\HttpClient;
use JardisAdapter\Http\Config\ClientConfig;
use JardisAdapter\Http\Message\Psr17Factory;
use JardisAdapter\Http\Exception\NetworkException;
$factory = new Psr17Factory();
$client = new HttpClient(
$factory, $factory, $factory, $factory,
new ClientConfig(
baseUrl: 'https://api.erp-system.com/v2',
bearerToken: $apiToken,
defaultHeaders: [
'Accept' => 'application/json',
'X-Tenant-Id' => 'acme-corp',
],
maxRetries: 2,
retryDelayMs: 500,
timeout: 15,
),
);
// Load products
$response = $client->get('/products', ['X-Page-Size' => '50']);
$products = json_decode($response->getBody()->getContents(), true);
// Create order
$response = $client->post('/orders', [
'customer_id' => 42,
'items' => [
['product_id' => 'P-001', 'quantity' => 2],
['product_id' => 'P-002', 'quantity' => 1],
],
]);
if ($response->getStatusCode() === 201) {
$order = json_decode($response->getBody()->getContents(), true);
echo "Order created: " . $order['id'];
}
// Error handling
try {
$response = $client->get('/health');
} catch (NetworkException $e) {
// API unreachable — after 2 retries
echo "API unreachable: " . $e->getMessage();
}