Release:
v0.1.0-beta2|CHANGELOG.mdPSR Compliance: PSR-18 (Psr\Http\Client\ClientInterface), PSR-7 messages, PSR-17 factories
A high-performance PSR-18 HTTP client tuned for FrankenPHP resident-worker proxying. Holds a single persistent \CurlHandle plus a \CurlMultiHandle, reused via curl_reset() across every sendRequest() so libcurl's DNS cache and keep-alive pool stay warm. The transfer is driven through the multi interface, so the worker parks on curl_multi_select() (a socket-level wait) instead of blocking inside curl_exec(). Bodies stream in 8 KiB chunks in both directions — request bodies are pulled from the PSR-7 request stream and response bodies are pushed into a PSR-7 stream — so neither side is ever materialised whole in worker memory.
No behavioural changes since Beta-1 — lockstep version bump only. The client surface, security defaults, and streaming guarantees remain as described below.
- Persistent socket reuse. A single
\CurlHandleand a single\CurlMultiHandlelive for the worker's lifetime;curl_reset()between requests keeps the connection pool, DNS cache, and TLS session cache warm. Source:Client.php(// Holds a single persistent CurlHandle plus a CurlMultiHandle that are reused via curl_reset() ...). - Non-blocking transfer. The transfer runs through the persistent
\CurlMultiHandle(curl_multi_exec+curl_multi_select) rather than the blockingcurl_exec(). The worker never busy-spins, and a slow legacy backend can no longer pin it on a blocking syscall beyond the hard timeout ceiling. The multi handle is also the building block for future concurrent fan-out. - Bidirectional bounded streaming (8 KiB chunks). Request bodies are streamed via
CURLOPT_READFUNCTION+CURLOPT_UPLOAD(pullingCHUNK_SIZE = 8 * 1024bytes at a time from the PSR-7 request stream) instead of buffering the whole payload withCURLOPT_POSTFIELDS. Known lengths are advertised withCURLOPT_INFILESIZE; unknown lengths fall back toTransfer-Encoding: chunked. Response bodies stream viaCURLOPT_WRITEFUNCTION. A multi-gigabyte multipart upload is proxied without a RAM spike. - SEC-03 SSRF allowlist.
Client::applyRequest()sets bothCURLOPT_PROTOCOLSandCURLOPT_REDIR_PROTOCOLStoCURLPROTO_HTTP | CURLPROTO_HTTPS(verbatim,Client.php:178-179), blocking SSRF pivots viafile://,gopher://,dict://,ldap://, etc. — even when a caller-supplied URL or a server-suppliedLocationheader tries to switch protocols mid-flight.
composer require waffle-commons/http-clientYou also need PSR-17 factory implementations for ResponseFactoryInterface and StreamFactoryInterface. The framework defaults to waffle-commons/http; nyholm/psr7 works equally well.
| Class | Role |
|---|---|
Waffle\Commons\HttpClient\Client |
final readonly PSR-18 client. Persistent cURL easy + multi handles, non-blocking transfer, hardcoded 1s connect / 10s total timeouts, request and response bodies streamed in 8 KiB chunks. |
Waffle\Commons\HttpClient\Exception\HttpClientException |
Base class for client errors (e.g. handle init failure). Implements Psr\Http\Client\ClientExceptionInterface. |
Waffle\Commons\HttpClient\Exception\NetworkException |
Transport-layer failures (DNS, connect/read timeout, TLS, reset). Implements Psr\Http\Client\NetworkExceptionInterface. |
Waffle\Commons\HttpClient\Exception\RequestException |
Protocol-level failures or empty responses. Implements Psr\Http\Client\RequestExceptionInterface. |
use Nyholm\Psr7\Factory\Psr17Factory;
use Waffle\Commons\HttpClient\Client;
$psr17 = new Psr17Factory();
$client = new Client(
responseFactory: $psr17,
streamFactory: $psr17,
);
$request = $psr17->createRequest('GET', 'https://api.example.com/users');
$response = $client->sendRequest($request);
echo $response->getStatusCode(); // 200
echo (string) $response->getBody(); // streamed bodyThe client enforces a minimum security baseline that callers cannot lower:
| Option | Value | Why |
|---|---|---|
CURLOPT_PROTOCOLS |
CURLPROTO_HTTP | CURLPROTO_HTTPS |
SEC-03 SSRF allowlist on the request URL. |
CURLOPT_REDIR_PROTOCOLS |
CURLPROTO_HTTP | CURLPROTO_HTTPS |
SEC-03 SSRF allowlist on any redirect target. |
CURLOPT_SSL_VERIFYPEER |
true |
Forces full certificate validation. |
CURLOPT_SSL_VERIFYHOST |
2 |
Forces hostname match against the certificate. |
CURLOPT_FOLLOWLOCATION |
false |
The client never silently follows redirects — callers must handle them explicitly. |
CURLOPT_CONNECTTIMEOUT_MS |
1_000 |
Hard 1-second ceiling. Cannot be raised. |
CURLOPT_TIMEOUT_MS |
10_000 |
Hard 10-second ceiling. Cannot be raised. A hung legacy backend must never lock a worker thread. |
final readonly class Clientwith promoted constructor properties.- Typed integer constants for every timeout/chunk-size value (
CONNECT_TIMEOUT_MS,TIMEOUT_MS,CHUNK_SIZE). #[\Override]on the PSR-18 implementation method.matchexpression for HTTP-version negotiation.
An active dependency perimeter is enforced on every CI run by vendor/bin/mago guard (bundled into composer mago; zero baselines). The rules live in mago.toml under [guard.perimeter] — a forbidden use statement fails the build, not a reviewer.
Production code under Waffle\Commons\HttpClient may depend only on:
Waffle\Commons\HttpClient\**— itselfWaffle\Commons\Contracts\**— the shared contracts package, the only Waffle dependency permittedPsr\**— PSR interfaces (PSR-7 / PSR-17 / PSR-18)@global+Psl\**— PHP core (includingext-curl) and the PHP Standard Library
Test code under WaffleTests\Commons\HttpClient is unrestricted (@all); the tests/src/ClientTest.php php-mock fixture is listed in [guard].excludes because it re-declares the production namespace to stub libcurl. Structural rules are guarded too: interfaces must be named *Interface, Exception\** classes must end in *Exception, and any Enum\** namespace may hold only enum declarations.
Contract-first, component-agnostic by construction: components compose through waffle-commons/contracts, never directly through one another.
docker exec -w /waffle-commons/http-client waffle-dev composer testsThe test suite uses php-mock/php-mock-phpunit to stub libcurl entry points (curl_init, curl_setopt_array, curl_multi_exec, curl_multi_info_read, …), so PHPUnit runs hermetically without network I/O. Dedicated tests assert the SEC-03 protocol allowlist, the chunked request-body upload, and the non-blocking multi-handle transfer.
MIT — see LICENSE.md.