Skip to content

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 compatibleClientInterface fully 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 methodsget(), post(), put(), patch(), delete(), head() with automatic JSON encoding

Installation

bash
composer require jardisadapter/http

GitHub: jardisAdapter/http

Required PHP extension: ext-curl

Basic Usage

Simple Requests

php
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

php
$request = $factory->createRequest('GET', 'https://api.example.com/users')
    ->withHeader('Accept', 'application/json');

$response = $client->sendRequest($request);
$response->getStatusCode();       // 200
$response->getBody()->getContents(); // Response body

Configuration

All options are controlled via ClientConfig. Only configured features are instantiated:

php
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:

php
$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/data

Authentication

php
// 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 used

Default Headers

php
$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:

php
$config = new ClientConfig(
    maxRetries: 3,       // 3 retries after the first attempt
    retryDelayMs: 200,   // 200ms base delay
);

Exponential Backoff

AttemptDelay
1 (initial)immediate
2200ms
3400ms
4800ms

What triggers a retry?

SituationRetry?
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:

php
$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

php
$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:

ExceptionCause
NetworkExceptionDNS error, connection refused, timeout, SSL error
RequestExceptionInvalid request, cURL initialization error
HttpClientExceptionBase for both (implements PSR-18 ClientExceptionInterface)
php
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 execution

Directory 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 URI

API Reference

HttpClient

MethodSignatureDescription
sendRequestsendRequest(RequestInterface $request): ResponseInterfacePSR-18 standard
getget(string $uri, array $headers = []): ResponseInterfaceGET request
postpost(string $uri, array $data = [], array $headers = []): ResponseInterfacePOST with JSON
putput(string $uri, array $data = [], array $headers = []): ResponseInterfacePUT with JSON
patchpatch(string $uri, array $data = [], array $headers = []): ResponseInterfacePATCH with JSON
deletedelete(string $uri, array $headers = []): ResponseInterfaceDELETE
headhead(string $uri, array $headers = []): ResponseInterfaceHEAD

ClientConfig

PropertyTypeDefaultDescription
timeoutint30Request timeout (seconds)
connectTimeoutint10Connection timeout
baseUrl?stringnullBase URL
verifySslbooltrueSSL verification
defaultHeadersarray[]Default headers
bearerToken?stringnullBearer token
basicUser?stringnullBasic auth user
basicPassword?stringnullBasic auth password
maxRetriesint0Retry attempts
retryDelayMsint100Base delay (ms)

Complete Example

API client with base URL, Bearer auth, default headers and retry:

php
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();
}