thingston/psr18

Implementation of PSR-18 standard for sending HTTP requests and receiving HTTP responses.

Maintainers

Package info

github.com/thingston/psr18

pkg:composer/thingston/psr18

Transparency log

Statistics

Installs: 6

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-06-28 14:49 UTC

This package is auto-updated.

Last update: 2026-06-28 14:52:58 UTC


README

Implementation of PSR-18 standard for sending HTTP requests and receiving HTTP responses.

Requirements

  • PHP 8.5 or newer
  • PHP cURL extension

Installation

composer require thingston/psr18

Quick start

<?php

use Thingston\Psr17\RequestFactory;
use Thingston\Psr18\Client;

$request = (new RequestFactory())->createRequest('GET', 'https://api.example.com/data');
$response = (new Client())->sendRequest($request);

echo $response->getStatusCode(); // 200
echo (string) $response->getBody();

Configuration

Client accepts optional named parameters for every concern. All policy parameters default to null (disabled):

use Thingston\Psr18\AuthPolicy;
use Thingston\Psr18\CircuitBreakerPolicy;
use Thingston\Psr18\Client;
use Thingston\Psr18\CookieJar;
use Thingston\Psr18\Curl\RequestOptions;
use Thingston\Psr18\Curl\ResponseOptions;
use Thingston\Psr18\ProxyOptions;
use Thingston\Psr18\RedirectPolicy;
use Thingston\Psr18\RetryPolicy;
use Thingston\Psr18\TimeoutPolicy;
use Thingston\Psr18\TlsPolicy;

$client = new Client(
    requestOptions:  new RequestOptions([RequestOptions::USER_AGENT => 'my-app/1.0']),
    responseOptions: new ResponseOptions([ResponseOptions::ENCODING => '']),
    retryPolicy:     new RetryPolicy(maxRetries: 3, delayMilliseconds: 200),
    redirectPolicy:  new RedirectPolicy(followRedirects: true, maxRedirects: 10),
    tlsPolicy:       new TlsPolicy(verifyPeer: true, verifyHost: true),
    timeoutPolicy:   new TimeoutPolicy(connectTimeoutMs: 5_000, transferTimeoutMs: 30_000),
    authPolicy:      AuthPolicy::bearer('my-token'),
    proxyOptions:    new ProxyOptions('http://proxy.internal:3128'),
    cookieJar:       new CookieJar(),
    circuitBreaker:  new CircuitBreakerPolicy(failureThreshold: 5, resetTimeoutSeconds: 60),
    logger:          $psr3Logger,
    middleware:      [$myMiddleware],
);

Redirect policy

Controls whether and how the client follows HTTP redirects. By default the client does not follow redirects unless a RedirectPolicy is provided.

use Thingston\Psr18\Client;
use Thingston\Psr18\RedirectPolicy;

// Follow up to 10 redirects (default)
$client = new Client(redirectPolicy: new RedirectPolicy());

// Follow redirects while preserving the HTTP method on all redirect types
$client = new Client(redirectPolicy: new RedirectPolicy(followRedirects: true, preserveMethod: true));

// Disable redirect following — caller receives the 3xx response
$client = new Client(redirectPolicy: new RedirectPolicy(followRedirects: false));
Parameter Type Default Effect
$followRedirects bool true Sets CURLOPT_FOLLOWLOCATION
$maxRedirects int 10 Sets CURLOPT_MAXREDIRS
$preserveMethod bool false Sets CURLOPT_POSTREDIR = CURL_REDIR_POST_ALL

RedirectPolicy::resolveLocation(string $location, RequestInterface $request): string is a utility for resolving relative Location header values when handling redirects manually.

Retry policy

Retries are opt-in and default to idempotent methods only (GET, HEAD, OPTIONS, PUT, DELETE, TRACE). The default failure set retries common transient cURL errors and HTTP 408, 425, 429, 500, 502, 503, 504.

use Thingston\Psr18\Client;
use Thingston\Psr18\RetryPolicy;

// Constant 200 ms delay, up to 3 retries
$client = new Client(
    retryPolicy: new RetryPolicy(maxRetries: 3, delayMilliseconds: 200),
);

// Exponential backoff: 100 ms → 200 ms → 400 ms, capped at 2 000 ms
$client = new Client(
    retryPolicy: new RetryPolicy(
        maxRetries: 4,
        delayMilliseconds: 100,
        backoffMultiplier: 2.0,
        maxDelayMilliseconds: 2_000,
    ),
);

// Retry only on GET and HEAD, and only on 503
$client = new Client(
    retryPolicy: new RetryPolicy(
        maxRetries: 3,
        methods: ['GET', 'HEAD'],
        statusCodes: [503],
    ),
);
Parameter Type Default Effect
$maxRetries int 0 Maximum retry attempts
$delayMilliseconds int 100 Base delay between retries
$backoffMultiplier float 1.0 1.0 = constant; 2.0 = exponential doubling
$maxDelayMilliseconds int 0 Cap on computed delay; 0 = no cap
$methods string[] GET,HEAD,OPTIONS,PUT,DELETE,TRACE HTTP methods eligible for retry
$statusCodes int[] 408,425,429,500,502,503,504 HTTP status codes that trigger a retry
$errorCodes int[]|null transient cURL errors cURL error codes that trigger a retry; null = built-in defaults

When the server returns a Retry-After header on a retryable response, the client honours it (capped at 60 s) instead of using the computed delay.

TLS policy

Groups SSL/TLS configuration into a named object rather than raw cURL option arrays.

use Thingston\Psr18\Client;
use Thingston\Psr18\TlsPolicy;

// Custom CA bundle and client certificate
$client = new Client(
    tlsPolicy: new TlsPolicy(
        verifyPeer: true,
        verifyHost: true,
        caInfo: '/etc/ssl/certs/ca-bundle.crt',
        certificate: '/path/to/client.crt',
        privateKey: '/path/to/client.key',
    ),
);

// Disable verification (testing only — never in production)
$client = new Client(
    tlsPolicy: new TlsPolicy(verifyPeer: false, verifyHost: false),
);

Timeout policy

Separates connection timeout from transfer timeout.

use Thingston\Psr18\Client;
use Thingston\Psr18\TimeoutPolicy;

$client = new Client(
    timeoutPolicy: new TimeoutPolicy(
        connectTimeoutMs: 3_000,   // fail fast if the host is unreachable
        transferTimeoutMs: 30_000, // allow 30 s for the full response
    ),
);
Parameter Type Default Effect
$connectTimeoutMs int 10_000 Sets CURLOPT_CONNECTTIMEOUT_MS
$transferTimeoutMs int 0 (unlimited) Sets CURLOPT_TIMEOUT_MS; 0 disables the transfer timeout

TimeoutPolicy takes precedence over any equivalent options set in RequestOptions.

Auth policy

Handles Basic, Bearer, and Digest authentication without manually building Authorization headers.

use Thingston\Psr18\AuthPolicy;
use Thingston\Psr18\Client;

$client = new Client(authPolicy: AuthPolicy::basic('alice', 's3cr3t'));
$client = new Client(authPolicy: AuthPolicy::bearer('eyJhbGciOi...'));
$client = new Client(authPolicy: AuthPolicy::digest('alice', 's3cr3t'));

Basic and Bearer auth inject an Authorization header. Digest auth uses curl's native challenge-response mechanism via CURLOPT_HTTPAUTH.

Proxy options

use Thingston\Psr18\Client;
use Thingston\Psr18\ProxyOptions;

$client = new Client(
    proxyOptions: new ProxyOptions(
        proxy: 'http://proxy.corp:3128',
        proxyType: CURLPROXY_HTTP,       // default; use CURLPROXY_SOCKS5 etc. as needed
        userPassword: 'proxyuser:proxypass',
        noProxy: 'localhost,127.0.0.1,.internal',
    ),
);

Concurrent requests

MultiClient wraps curl_multi_* and sends multiple requests in parallel. It accepts the same policy parameters as Client except retryPolicy, circuitBreaker, logger, and middleware.

use Thingston\Psr17\RequestFactory;
use Thingston\Psr18\MultiClient;

$factory = new RequestFactory();
$client = new MultiClient();

$responses = $client->sendAll([
    $factory->createRequest('GET', 'https://api.example.com/users'),
    $factory->createRequest('GET', 'https://api.example.com/orders'),
    $factory->createRequest('GET', 'https://api.example.com/products'),
]);

// Responses are indexed in the same order as the requests
echo $responses[0]->getStatusCode();

Cookie jar

Persists session cookies across requests using CURLOPT_COOKIEFILE and CURLOPT_COOKIEJAR. A temporary file is created automatically when no path is given and deleted on destruction.

use Thingston\Psr18\Client;
use Thingston\Psr18\CookieJar;

// Temporary file, automatically deleted when the CookieJar goes out of scope
$jar = new CookieJar();
$client = new Client(cookieJar: $jar);

// Persistent file
$client = new Client(cookieJar: new CookieJar('/var/run/session-cookies.txt'));

CookieJar::getPath(): string returns the underlying file path. CookieJar::clear(): void truncates the file, discarding all stored cookies without changing the path.

Circuit breaker

Opens the circuit after a configurable number of consecutive failures and throws immediately — without attempting the request — until the reset timeout elapses.

use Thingston\Psr18\CircuitBreakerPolicy;
use Thingston\Psr18\Client;

$circuitBreaker = new CircuitBreakerPolicy(
    failureThreshold: 5,    // open after 5 consecutive failures
    resetTimeoutSeconds: 30, // try again after 30 s
);

$client = new Client(circuitBreaker: $circuitBreaker);

States: closed (normal) → open (blocking) → half-open (probe allowed) → closed.

Middleware

A MiddlewareInterface pipeline wraps sendRequest() with cross-cutting behaviour. Middleware runs in registration order (first registered = outermost wrapper).

use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Thingston\Psr18\Client;
use Thingston\Psr18\MiddlewareInterface;

final class RequestIdMiddleware implements MiddlewareInterface
{
    public function process(RequestInterface $request, callable $next): ResponseInterface
    {
        return $next($request->withHeader('X-Request-Id', uniqid()));
    }
}

$client = new Client(middleware: [new RequestIdMiddleware()]);

PSR-3 logging

Inject any Psr\Log\LoggerInterface to receive structured log entries for requests, responses, retries, and errors. Zero-cost when no logger is injected.

use Thingston\Psr18\Client;

$client = new Client(logger: $monologLogger);

Log levels: debug for normal request/response, warning for retries and failures.

Raw cURL options

For anything not covered by a policy, pass raw cURL options via RequestOptions or ResponseOptions. Reserved options (those the client manages internally) are rejected at construction time.

use Thingston\Psr18\Client;
use Thingston\Psr18\Curl\RequestOptions;
use Thingston\Psr18\Curl\ResponseOptions;

$client = new Client(
    requestOptions: (new RequestOptions())
        ->withOption(RequestOptions::USER_AGENT, 'my-app/1.0'),
    responseOptions: (new ResponseOptions())
        ->withOption(ResponseOptions::ENCODING, ''),
);

RequestOptions constants: USER_AGENT, CONNECT_TIMEOUT, TIMEOUT, FOLLOW_LOCATION, MAX_REDIRECTS.

RequestOptions ships with defaults applied to every request: USER_AGENT = 'thingston/http-client' and CONNECT_TIMEOUT = 10 (seconds). These can be overridden via RequestOptions or superseded by the corresponding policy object.

Notes

  • The request URI must be absolute and include a host.
  • HTTP/1.0, HTTP/1.1, HTTP/2, and HTTP/3 are supported when the installed cURL build supports it.
  • Policy options (redirect, TLS, proxy, timeout, auth, cookie jar) always override any equivalent raw options set in RequestOptions.
  • RetryPolicy only retries idempotent methods by default; POST and PATCH are excluded unless explicitly added to the methods list.