HTTP Client

composer require php-standard-library/http-client

The HTTP Client component provides an async HTTP client with connection pooling, HTTP/2 multiplexing, automatic protocol negotiation via ALPN, middleware support, and composable decorators for redirects and retries. All I/O is non-blocking and built on the PSL async runtime.

Basic Usage

Create a client, build a request, and send it. The returned transaction contains the response with status, headers, and a streaming body.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client();

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));

$tx = $client->send($request);

$tx->response->status;
$tx->response->protocolVersion;
$tx->response->headers->get('content-type');
$tx->response->body?->readAll();

Sending Data

Send a POST request with a JSON body and custom headers using IO\MemoryHandle for the request body.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\IO;
use Psl\URL;

$client = new Client\Client();

$request = new Message\Request(
    method: Message\METHOD_POST,
    url: URL\parse('https://httpbin.org/post'),
    headers: Message\FieldMap::from([
        ['Content-Type',  'application/json'],
        ['Accept',        'application/json'],
        ['Authorization', 'Bearer tok_example'],
    ]),
    body: new IO\MemoryHandle('{"name": "Alice", "email": "[email protected]"}'),
);

$tx = $client->send($request);

$tx->response->status;
$tx->response->body?->readAll();

Concurrent Requests

Use Async\concurrently() to send multiple requests in parallel over the same client. HTTP/2 connections are automatically multiplexed.

use Psl\Async;
use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client();

$transactions = Async\concurrently([
    'users' => static fn() => $client->send(new Message\Request(
        method: Message\METHOD_GET,
        url: URL\parse('https://httpbin.org/get?resource=users'),
    )),
    'posts' => static fn() => $client->send(new Message\Request(
        method: Message\METHOD_GET,
        url: URL\parse('https://httpbin.org/get?resource=posts'),
    )),
    'comments' => static fn() => $client->send(new Message\Request(
        method: Message\METHOD_GET,
        url: URL\parse('https://httpbin.org/get?resource=comments'),
    )),
]);

$transactions['users']->response->status;
$transactions['posts']->response->body?->readAll();

Redirects

RedirectClient wraps any client and automatically follows 3xx redirects with credential stripping on cross-origin hops and method rewriting per RFC 9110.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\RedirectClient(new Client\Client(), maxRedirects: 5, autoReferrer: true);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://httpbin.org/redirect/3'));

$tx = $client->send($request);

$tx->response->status;

Retries

RetryClient wraps any client and retries idempotent requests on transport-level failures with exponential backoff.

use Psl\DateTime\Duration;
use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\RetryClient(
    new Client\RedirectClient(new Client\Client()),
    maxAttempts: 3,
    backoff: Duration::milliseconds(200),
    backoffMultiplier: 2,
);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/api/data'));

$tx = $client->send($request);

$tx->response->status;

Configuration

ClientConfiguration controls TLS settings, protocol version preferences, response size limits, base URL, and proxy configuration.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\HTTP\Message\ProtocolVersion;
use Psl\TLS;
use Psl\URL;

$configuration = new Client\ClientConfiguration(
    maxResponseHeaderSize: 16_384,
    maxResponseBodySize: 50_000_000,
    baseUrl: URL\parse('https://api.example.com/v2'),
    tlsConfiguration: new TLS\ClientConfiguration(minimumVersion: TLS\Version::Tls12),
    protocolVersions: [ProtocolVersion::V20, ProtocolVersion::V11],
);

$client = new Client\Client(configuration: $configuration);

$request = new Message\Request(method: Message\METHOD_GET, url: null, requestTarget: '/users');

$tx = $client->send($request);

$tx->response->status;

Per-Request Overrides

SendConfiguration overrides specific client settings for a single request without affecting the client default.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\HTTP\Message\ProtocolVersion;
use Psl\URL;

$client = new Client\Client();

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com/large-file.tar.gz'));

$tx = $client->send(
    $request,
    new Client\SendConfiguration(maxResponseBodySize: 500_000_000, protocolVersions: [ProtocolVersion::V11]),
);

$tx->response->status;
$tx->response->body?->readAll();

SSRF Protection

DeniedDestinationsMiddleware inspects the resolved peer IP after connection establishment and blocks requests to private, loopback, and link-local addresses.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client(middleware: [Client\Middleware\DeniedDestinationsMiddleware::forPrivateNetworkRanges()]);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));
$tx = $client->send($request);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('http://169.254.169.254/metadata'));
$tx = $client->send($request);

Proxy Modes

The client supports three proxy modes, configured via ClientConfiguration or overridden per-request via SendConfiguration:

Both proxy types can be combined: when both $socksConfiguration and $proxyConfiguration are set, the SOCKS5 proxy tunnels the TCP connection to the HTTP proxy, which then handles HTTP forward proxying or CONNECT tunneling.

SOCKS Proxy

Route all TCP connections through a SOCKS5 proxy using $socksConfiguration. TLS and ALPN negotiation happen on top of the tunneled connection.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\Socks;
use Psl\URL;

$configuration = new Client\ClientConfiguration(socksConfiguration: new Socks\Configuration(
    proxyHost: 'proxy.example.com',
    proxyPort: 1080,
    username: 'user',
    password: 'secret', // @mago-expect lint:no-literal-password
));

$client = new Client\Client(configuration: $configuration);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));

$tx = $client->send($request);

$tx->response->status;

HTTP Proxy

Use $proxyConfiguration to route requests through an HTTP proxy. Configure the proxy URL, authentication, and bypass rules via ProxyConfiguration. HTTPS targets use CONNECT tunneling while plain HTTP targets use forward proxying with absolute-form request targets.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$configuration = new Client\ClientConfiguration(
    proxyConfiguration: new Client\ProxyConfiguration(
        URL\parse('http://proxy.example.com:8080'),
        skipProxyFor: [
            'localhost',
            '.internal.example.com',
        ],
    ),
);

$client = new Client\Client(configuration: $configuration);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));
$tx = $client->send($request);

$tx->response->status;

HTTP/2 over Plaintext (h2c)

For servers that support HTTP/2 without TLS, set protocolVersions to only ProtocolVersion::V20. The client uses prior-knowledge mode per RFC 9113 Section 3.4 - it sends the HTTP/2 connection preface directly on the TCP connection, skipping TLS and ALPN negotiation entirely.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client(
    configuration: new Client\ClientConfiguration(protocolVersions: [Message\ProtocolVersion::V20]),
);

$tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('http://localhost:8080/')));

$tx->response->protocolVersion; // ProtocolVersion::V20
$tx->response->status; // 200
$tx->response->body?->readAll();

Request Body Rules

TRACE requests that include a body are rejected with a RequestException per RFC 9110 Section 9.3.8. GET and HEAD requests are allowed to carry a body (useful for Elasticsearch-style query bodies), although this is not recommended per RFC 9110 Section 9.3.1.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\IO;
use Psl\URL;

$client = new Client\Client();

// TRACE requests with a body throw RequestException per RFC 9110 Section 9.3.8
try {
    $client->send(new Message\Request(
        method: Message\METHOD_TRACE,
        url: URL\parse('https://example.com'),
        body: new IO\MemoryHandle('trace body'),
    ));
} catch (Client\Exception\RequestException $e) {
    // "TRACE requests must not include a body."
    $e->getMessage();
}

// TRACE without a body is allowed
$tx = $client->send(new Message\Request(method: Message\METHOD_TRACE, url: URL\parse('https://example.com')));

// GET with a body is allowed (useful for Elasticsearch-style query bodies)
// but not recommended per RFC 9110 Section 9.3.1
$tx = $client->send(new Message\Request(
    method: Message\METHOD_GET,
    url: URL\parse('https://elasticsearch.example.com/_search'),
    headers: new Message\FieldMap([['content-type', 'application/json']]),
    body: new IO\MemoryHandle('{"query":{"match_all":{}}}'),
));

$tx->response->status;
$tx->response->body?->readAll();

Connection Timeout

SendConfiguration::$connectionTimeout sets the maximum duration for establishing a connection (TCP handshake + TLS handshake) on a per-request basis. When set, the connector's cancellation token is linked with a timeout token scoped to this duration. The overall request cancellation token still applies independently.

use Psl\Async;
use Psl\DateTime\Duration;
use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client();

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));

// Limit the connection phase (TCP + TLS handshake) to 5 seconds.
// The overall request cancellation token still applies independently.
try {
    $tx = $client->send($request, new Client\SendConfiguration(connectionTimeout: Duration::seconds(5)));

    $tx->response->status; // 200
} catch (Async\Exception\CancelledException) {
    // @mago-expect lint:no-empty-catch-clause - Connection took longer than 5 seconds
}

Callbacks

SendConfiguration provides two per-request callbacks for observing connection and protocol events:

ClientConfiguration also accepts an onInformationalResponse callback that applies to all requests. When both the client-level and per-request callbacks are set, the client-level callback fires first, followed by the per-request callback.

use Psl\HTTP\Client;
use Psl\HTTP\Client\Connection\ConnectionMetadata;
use Psl\HTTP\Message;
use Psl\URL;

$client = new Client\Client();

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));

$tx = $client->send(
    $request,
    new Client\SendConfiguration(
        onConnection: static function (ConnectionMetadata $metadata): void {
            // Peer address is the resolved IP after DNS resolution
            $metadata->peerAddress->host; // e.g. "93.184.216.34"
            $metadata->peerAddress->port; // e.g. 443

            // Local address is the ephemeral socket on this machine
            $metadata->localAddress->host; // e.g. "192.168.1.100"
            $metadata->localAddress->port; // e.g. 52431

            // TLS state is available for HTTPS connections
            if ($metadata->tlsState !== null) {
                $metadata->tlsState->version; // e.g. TLS\Version::Tls13
                $metadata->tlsState->cipherName; // e.g. "TLS_AES_256_GCM_SHA384"
                $metadata->tlsState->alpnProtocol; // e.g. "h2"
                $metadata->tlsState->peerCertificate; // server certificate
            }
        },
        onInformationalResponse: static function (Message\Response $response): void {
            // Fires for each 1xx response (100 Continue, 103 Early Hints, etc.)
            $response->status; // e.g. 103
            $response->headers->getAll('link'); // e.g. ["</style.css>; rel=preload; as=style"]
        },
    ),
);

$tx->response->status;

Informational Responses

HTTP servers may send one or more 1xx informational responses before the final response. These are delivered in two ways:

  1. The onInformationalResponse callback (on ClientConfiguration or SendConfiguration) fires as each 1xx response arrives.
  2. All 1xx responses are collected in Transaction::$informational in chronological order.

Common informational responses include 100 Continue (the server is ready for the request body), 102 Processing (WebDAV, long-running operation), and 103 Early Hints (preliminary headers for resource preloading per RFC 8297).

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

// Client-level callback fires for all requests
$client = new Client\Client(
    configuration: new Client\ClientConfiguration(onInformationalResponse: static function (Message\Response $response): void {
        if ($response->status === 103) {
            // Extract Link headers from 103 Early Hints for resource preloading
            foreach ($response->headers->getAll('link') as $link) {
                // e.g. "</style.css>; rel=preload; as=style"
                // Begin preloading the linked resource
                $link;
            }
        }
    }),
);

$request = new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com'));

// Per-request callback fires after the client-level callback
$tx = $client->send($request, new Client\SendConfiguration(onInformationalResponse: static function (Message\Response $response): void {
    // This fires after the client-level callback for each 1xx response
    if ($response->status === 100) {
        // Server acknowledged Expect: 100-continue, body will be sent
    }
}));

// Informational responses are also collected in the transaction
foreach ($tx->informational as $info) {
    $info->status; // 100, 102, 103, etc.
    $info->headers; // headers from the informational response
}

$tx->response->status; // final 2xx-5xx response

Connection Metadata

ConnectionMetadata captures the transport-level details of a connection at acquisition time. It is available through the onConnection callback on SendConfiguration.

Property Type Description
localAddress Network\Address Local socket endpoint (IP and ephemeral port)
peerAddress Network\Address Resolved remote address after DNS resolution
tlsState TLS\ConnectionState or null TLS session details for HTTPS, null for plaintext

The TLS\ConnectionState includes version, cipherName, cipherBits, alpnProtocol, peerCertificate, and peerCertificateChain.

Configuration Merging

SendConfiguration fields override the client's ClientConfiguration on a per-request basis via ClientConfiguration::withOverrides(). Null fields in SendConfiguration inherit from the client default; non-null fields replace the client value for that request only.

The following fields are overridable per-request:

Field Overridable
maxResponseHeaderSize Yes
maxResponseBodySize Yes
baseUrl Yes
tlsConfiguration Yes
protocolVersions Yes
proxyConfiguration Yes
onInformationalResponse Yes (merged, not replaced)
connectionTimeout Per-request only (not on ClientConfiguration)
onConnection Per-request only (not on ClientConfiguration)
socksConfiguration No (client-only)
unixSocket No (client-only)
h2ClientConfiguration No (client-only)

The onInformationalResponse callback receives special treatment: when both the client and send configuration define one, they are composed into a single callback that invokes the client-level callback first, then the per-request callback.

All other overridable fields, including proxyConfiguration, are fully replaced by the per-request value when set. For example, setting proxyConfiguration on SendConfiguration completely overwrites the client-level proxy settings; they are not merged.

Middleware

Connection-level middleware runs after the connection is established but before the HTTP exchange. Middleware has access to the ConnectionInterface, including the resolved peer address and TLS state. This enables security checks (SSRF protection), logging, metrics, and request/response transformation.

Middleware must do one of two things:

  1. Delegate: Call $handler->handle(...) to continue the chain. The handler calls finalize() internally.
  2. Short-circuit: Return a Transaction directly. When short-circuiting, you MUST call $connection->finalize() on the transaction before returning it. Without finalization, the underlying connection is never released back to the pool, causing a connection leak.

Middleware is registered via the Client constructor and applied in order: the first middleware in the list is the outermost (executed first).

use Psl\Async\CancellationTokenInterface;
use Psl\Async\NullCancellationToken;
use Psl\DateTime;
use Psl\HTTP\Client;
use Psl\HTTP\Client\Connection\ConnectionInterface;
use Psl\HTTP\Client\Handler\HandlerInterface;
use Psl\HTTP\Client\Middleware\MiddlewareInterface;
use Psl\HTTP\Message;
use Psl\URL;

// Middleware that delegates to the next handler (pass-through with logging)
final readonly class TimingMiddleware implements MiddlewareInterface
{
    public function process(
        ConnectionInterface $connection,
        Message\Request $request,
        Client\ClientConfiguration $configuration,
        HandlerInterface $handler,
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
    ): Message\Transaction {
        $start = DateTime\Timestamp::monotonic();

        // Delegate to the next handler. The handler calls finalize() internally.
        $transaction = $handler->handle($connection, $request, $configuration, $cancellation);

        $elapsed = DateTime\Timestamp::monotonic()->since($start)->toString();
        // Log: "GET https://example.com completed in 42.5ms"

        return $transaction;
    }
}

// Middleware that short-circuits the chain (returns a cached response)
final readonly class CacheMiddleware implements MiddlewareInterface
{
    public function __construct(
        /** @var array<string, Message\Response> */
        private array $cache = [],
    ) {}

    public function process(
        ConnectionInterface $connection,
        Message\Request $request,
        Client\ClientConfiguration $configuration,
        HandlerInterface $handler,
        CancellationTokenInterface $cancellation = new NullCancellationToken(),
    ): Message\Transaction {
        $cacheKey = $request->method . ':' . $request->requestTarget;

        if (isset($this->cache[$cacheKey])) {
            // Short-circuit: MUST call finalize() to release the connection back to the pool
            return $connection->finalize(new Message\Transaction([], null, $this->cache[$cacheKey]));
        }

        // No cache hit, delegate to the next handler
        return $handler->handle($connection, $request, $configuration, $cancellation);
    }
}

$client = new Client\Client(middleware: [
    new TimingMiddleware(),
    new CacheMiddleware(),
]);

$tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')));

$tx->response->status;

HTTP/1.0 Mode

Setting protocolVersions to [ProtocolVersion::V10] sends HTTP/1.0 requests. Connection: close is implicit per RFC 1945, so each request uses a separate TCP connection. Keep-alive and chunked transfer encoding are not available. Trailers are not supported.

use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\URL;

// Force HTTP/1.0 for legacy server compatibility.
// Connection: close is implicit; no keep-alive, no chunked transfer encoding.
$client = new Client\Client(
    configuration: new Client\ClientConfiguration(protocolVersions: [Message\ProtocolVersion::V10]),
);

$tx = $client->send(new Message\Request(
    method: Message\METHOD_GET,
    url: URL\parse('http://legacy-server.example.com/status'),
));

$tx->response->protocolVersion; // ProtocolVersion::V10
$tx->response->status;
$tx->response->body?->readAll();

Trailers

Request trailers are sent via Request::$trailers, an Awaitable<FieldMap> that resolves after the body. Response trailers are available via Response::$trailers, which resolves after the response body is fully consumed.

Trailers require chunked transfer encoding (HTTP/1.1) or HTTP/2 DATA frames. They are not available with HTTP/1.0. Declare expected trailer field names using the Trailer header.

use Psl\Async;
use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\IO;
use Psl\URL;

$client = new Client\Client();

// Sending request trailers using an Awaitable<FieldMap>.
// Trailers are sent after the body with chunked transfer encoding (H1) or after DATA frames (H2).
$deferred = new Async\Deferred();
$request = new Message\Request(
    method: Message\METHOD_POST,
    url: URL\parse('https://example.com/upload'),
    headers: new Message\FieldMap([
        ['content-type', 'application/octet-stream'],
        ['trailer',      'checksum'],
    ]),
    body: new IO\MemoryHandle('file contents here'),
    trailers: $deferred->getAwaitable(),
);

// Resolve the trailers after the body is ready to be sent.
// In practice, you would compute the checksum while streaming the body.
$deferred->complete(new Message\FieldMap([['checksum', 'sha256=abc123...']]));

$tx = $client->send($request);

// Reading response trailers. Trailers are available after the body is fully consumed.
$body = $tx->response->body?->readAll();

if ($tx->response->trailers !== null) {
    $trailerFields = $tx->response->trailers->await();
    $trailerFields->get('server-timing'); // e.g. "db;dur=53"
}

Connection Pooling

The default PooledConnector manages connection reuse across requests:

Pool Release and Finalize

For HTTP/1.x keep-alive connections, the pooled connection is not released until the response body is fully consumed. The response body handle defers the pool release until EOF or close. This prevents a second request from reusing a connection while the first response body is still being read.

This is why middleware that short-circuits the handler chain MUST call $connection->finalize(): finalize wraps the response body (if any) so the connection is properly returned to the pool. Without it, the connection leaks.

For HTTP/2 connections, stream lifecycle is managed independently, so finalize returns the transaction as-is.

Error Handling

The client throws a structured exception hierarchy. Transport-level exceptions from Psl\Network and Psl\IO propagate unwrapped.

use Psl\Async;
use Psl\HTTP\Client;
use Psl\HTTP\Message;
use Psl\IO;
use Psl\Network;
use Psl\URL;

$client = new Client\RedirectClient(new Client\Client());

try {
    $tx = $client->send(new Message\Request(method: Message\METHOD_GET, url: URL\parse('https://example.com')));

    $tx->response->status;
} catch (Client\Exception\RequestException $e) {
    IO\write_line('Invalid request: %s', $e->getMessage());
} catch (Client\Exception\ProtocolException $e) {
    IO\write_line('Bad response: %s', $e->getMessage());
} catch (Client\Exception\TooManyRedirectsException $e) {
    IO\write_line('Redirect loop: %s', $e->getMessage());
} catch (Network\Exception\RuntimeException $e) {
    IO\write_line('Connection failed: %s', $e->getMessage());
} catch (IO\Exception\RuntimeException $e) {
    IO\write_line('I/O error: %s', $e->getMessage());
} catch (Async\Exception\CancelledException) {
    IO\write_line('Request cancelled');
}

Exception Hierarchy

Exception When Thrown
Exception\RuntimeException Base class for all HTTP client errors
Exception\RequestException Request is invalid (missing URL, malformed target, TRACE with body)
Exception\ProtocolException Malformed response, unsupported protocol, body size exceeded
Exception\TooManyRedirectsException Redirect limit exceeded (via RedirectClient)
Network\Exception\RuntimeException Connection refused, DNS failure, connect timeout
IO\Exception\RuntimeException Read/write failure on the underlying socket
Async\Exception\CancelledException Cancellation token fired during any stage

Protocol Support

Version Transport Negotiation Multiplexing Status
HTTP/1.0 TCP / TLS Explicit No Supported
HTTP/1.1 TCP / TLS Default fallback No (persistent connections) Supported
HTTP/2 TLS (h2) / TCP (h2c) ALPN Yes (streams over single connection) Supported
HTTP/3 QUIC ALPN Yes (independent streams, no HOL blocking) Planned

HTTP/3 support is not yet available. The client's connector and connection abstractions are designed to accommodate QUIC-based transports in the future without breaking the public API.

RFC Compliance

RFC Coverage
RFC 1945 HTTP/1.0 message format
RFC 9110 HTTP semantics, methods, status codes, redirects, idempotency
RFC 9112 HTTP/1.1 message syntax, chunked transfer encoding, persistent connections
RFC 9113 HTTP/2 binary framing, HPACK, flow control, server push, multiplexing
RFC 9114 HTTP/3 over QUIC (planned, not yet implemented)
RFC 8297 103 Early Hints informational responses
RFC 3986 URI resolution for base URL and redirect Location headers
RFC 1928 SOCKS5 proxy protocol
RFC 7231 Historical redirect method rewriting (301/302 to GET)

See src/Psl/HTTP/Client/ for the full API.