belisoful / prado-http2
HTTP/2 (RFC 9113) for the PRADO PHP framework, implemented over the system libnghttp2 via FFI. Provides general HTTP/2 sessions and streams; the basis for HTTP/2 servers, clients, and RFC 8441 WebSocket multiplexing.
Package info
github.com/belisoful/prado-http2
Type:prado4-extension
pkg:composer/belisoful/prado-http2
Requires
- php: >=8.1.0
- ext-ffi: *
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.94
- phpdocumentor/shim: ^3
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^10
- pradosoft/prado: ^4.4
Suggests
- ext-openssl: TLS with ALPN 'h2' for HTTP/2 over TLS.
- lib-nghttp2: The system libnghttp2 shared library, bound via FFI for HTTP/2 framing, HPACK, and flow control.
README
HTTP/2 (RFC 9113) for the PRADO PHP Framework (version 4.4+), implemented over the system libnghttp2 bound through PHP FFI.
A complete, correct HTTP/2 stack is large — frame layer, HPACK header compression, stream state machine, and connection/stream flow control. Rather than reimplement that in PHP, this extension binds nghttp2 (the reference C implementation that powers curl, nginx, and Apache) and lets it own the protocol. PRADO keeps the socket and the bytes; nghttp2 does the framing.
The session I/O is memory based: you feed received bytes in and drain produced bytes out, so the same code drives a real socket or an in-process test that pumps bytes between two sessions. This makes HTTP/2 testable without sockets or TLS (cleartext h2c).
It is the foundation for HTTP/2 servers and clients, and for RFC 8441 WebSocket-over-HTTP/2 multiplexing (used by the prado-websockets extension).
Requirements
| Requirement | Scope | Purpose |
|---|---|---|
| PHP 8.1 or higher | required | — |
ext-ffi |
required | Binds libnghttp2 at runtime |
System libnghttp2 |
suggested | The HTTP/2 framing engine, loaded at runtime (brew install libnghttp2, apt-get install libnghttp2-dev) |
ext-openssl |
suggested | HTTP/2 over TLS with ALPN h2; cleartext h2c needs nothing extra |
PRADO Framework ^4.4 |
dev | TComponent, TModule, TException, the IO layer |
FFI note.
ffi.enablemust permit FFI (CLI always allows it; for other SAPIs usepreloadortrue). HTTP/2 here is intended for a long-running PHP process (a socket server or client), not a per-request web SAPI.
Installation
composer require pradosoft/prado-http2
The library is resolved in this order: an explicit path set with TNgHttp2::setLibraryPath(), the PRADO_NGHTTP2_LIB environment variable, then platform defaults (Homebrew//usr/local on macOS, the common libnghttp2.so.14 sonames on Linux, nghttp2.dll on Windows). TNgHttp2::isAvailable() reports whether it loads, so an application can fall back to HTTP/1.1 when HTTP/2 is unavailable.
What it provides
| Class | Role |
|---|---|
Prado\IO\Http2\TNgHttp2 |
The libnghttp2 FFI binding: library resolution, the C-declaration surface, and version()/isAvailable()/strerror() helpers and protocol constants |
Prado\IO\Http2\TH2Session |
One HTTP/2 connection (server or client): drives nghttp2, manages streams, moves bytes with receive()/send(), exposes connection control (resetStream/goaway/ping/settings/flow control), and raises stream and diagnostic events |
Prado\IO\Http2\TH2Stream |
One HTTP/2 stream as a duplex PSR-7 StreamInterface: carries headers, buffers incoming DATA, queues outgoing DATA, and can cancel() |
Prado\IO\Http2\TH2Options |
Optional session tuning (PeerMaxConcurrentStreams, NoAutoWindowUpdate), applied at creation |
Prado\IO\Http2\TH2Alpn |
TLS/ALPN helper: advertises h2 in an ssl stream context and reads the negotiated protocol back (needs ext-openssl) |
Prado\IO\Http2\THttp2Exception |
An HTTP/2 failure (library missing, session error); extends TIOException |
Architecture
your transport (socket, test pipe, ...)
│ bytes in bytes out ▲
▼ │
TH2Session::receive() ──► libnghttp2 ──► TH2Session::send()
│ (framing, HPACK, flow control via nghttp2)
▼ events
onRequest / onResponse / onInformationalResponse / onTrailers / onData / onClose
│
▼
TH2Stream (one per HTTP/2 stream — a duplex StreamInterface)
nghttp2 is callback driven. The callbacks (and the data provider) are built once and shared by every session — a closure handed to FFI is retained for the FFI instance's life, so per-session closures would leak. Each callback routes to the owning session through nghttp2's user_data (a per-session id). Outgoing DATA is pulled by the shared data provider: bytes written to a stream are queued, the stream is resumed, and nghttp2 reads them on the next send().
Usage
Server
use Prado\IO\Http2\TH2Session; use Prado\IO\Http2\TNgHttp2; $session = new TH2Session(true); // server side $session->submitSettings([ TNgHttp2::SETTINGS_ENABLE_CONNECT_PROTOCOL => 1, // RFC 8441 (optional) ]); $session->attachEventHandler('onRequest', function ($session, $stream) { // $stream->getHeaders() includes the pseudo-headers (:method, :path, :authority, ...) $session->respond($stream, [':status' => '200']); $stream->write('hello'); $stream->markLocalClosed(); // finish the body — emits END_STREAM }); $session->attachEventHandler('onData', function ($session, $stream) { $body = $stream->getContents(); // bytes received on this stream }); $session->attachEventHandler('onClose', function ($session, $stream) { // stream finished }); // Pump a transport: feed what you read, write what it produces. $transport->write($session->send()); // initial SETTINGS while (/* connection open */) { $session->receive($transport->read(65536)); // drives the events above $transport->write($session->send()); }
Client
$session = new TH2Session(false); // client side $session->submitSettings([]); $session->attachEventHandler('onResponse', function ($session, $stream) { $status = $stream->getHeader(':status'); // the final (non-1xx) response }); $session->attachEventHandler('onInformationalResponse', function ($session, $stream) { // a 1xx response (100 Continue, 103 Early Hints) before the final response }); $session->attachEventHandler('onTrailers', function ($session, $stream) { // a trailing header block after the body (e.g. grpc-status) }); $session->attachEventHandler('onData', function ($session, $stream) { $body = $stream->getContents(); }); $stream = $session->request([ ':method' => 'GET', ':scheme' => 'https', ':authority' => 'example.com', ':path' => '/', ]); $stream->markLocalClosed(); // a GET has no body — end the request $transport->write($session->send()); // preface + SETTINGS + request $session->receive($transport->read(65536));
Streaming and Extended CONNECT
TH2Stream is a full PSR-7 StreamInterface (write() queues outgoing DATA and resumes the stream; read()/getContents() return buffered incoming DATA; it is not seekable). Per PSR-7 it throws when written to after it is local-closed/closed, or read after it is closed/detached. This supports request/response bodies and long-lived tunnels alike. An RFC 8441 Extended CONNECT (:method CONNECT, :protocol websocket) opens a bidirectional stream that carries arbitrary bytes both ways — the basis for WebSocket-over-HTTP/2.
A finite body is finished two ways: markLocalClosed() flushes the queued bytes and ends the stream (END_STREAM), and sendTrailers([...]) flushes the body and then ends the stream with a trailing header block (HTTP/2 trailers, e.g. grpc-status). close() instead discards the buffers and detaches the stream.
Connection control and tuning
TH2Session exposes the lifecycle primitives a real server/client needs:
resetStream($id, $code)/TH2Stream::cancel($code)— cancel one stream (RST_STREAM) without closing the connection.goaway($code)— graceful shutdown (GOAWAY): no new streams, in-flight ones finish.ping($payload)— keepalive / round-trip timing.getRemoteSetting($id)/getLocalSetting($id)— read negotiated SETTINGS (e.g. a client checkinggetRemoteSetting(SETTINGS_ENABLE_CONNECT_PROTOCOL)before an Extended CONNECT).wantsIo()— whether the session still has I/O pending, so an event loop knows when to stop.isRequestAllowed()— client-side: whether a newrequest()is permitted (not GOAWAY-ed, under the limit).submitWindowUpdate()/consume()— manual flow control, paired withTH2Options::setNoAutoWindowUpdate().
Tuning is passed at creation: new TH2Session(true, (new TH2Options())->setPeerMaxConcurrentStreams(100)). Diagnostic events onFrameSent/onFrameNotSent/onInvalidFrame/onSessionError surface frame activity and protocol errors.
TNgHttp2 directly
TNgHttp2::ffi() returns the bound \FFI instance for code that needs the raw nghttp2 API. Constants cover the SETTINGS ids (SETTINGS_*), frame types (FRAME_*), HTTP/2 error codes (NO_ERROR, CANCEL, REFUSED_STREAM, INTERNAL_ERROR, PROTOCOL_ERROR, STREAM_CLOSED), the FLAG_END_STREAM/DATA_FLAG_* flags, and ERR_DEFERRED. TNgHttp2::version() returns the loaded library version; TNgHttp2::strerror($code) describes an nghttp2 error.
PRADO integration
The package declares its Prado metadata under extra.prado in composer.json (the format TApplicationConfiguration reads):
"extra": { "prado": { "error-messages": "config/errorMessages.txt", "class-map": "config/classMap.json" } }
error-messages registers the http2_* codes and class-map registers the short class name → FQN map, both system-wide for every installed extension, so they resolve with no further wiring. The extension has no bootstrap module — nothing else to configure.
TLS with ALPN (h2)
Browsers speak HTTP/2 only over TLS, negotiated through the h2 ALPN protocol (RFC 9113 §3.3). TH2Alpn supplies the two HTTP/2-specific pieces; PHP's OpenSSL stream layer terminates the TLS connection, and the session stays transport-agnostic.
use Prado\IO\Http2\TH2Alpn; use Prado\IO\Http2\TH2Session; $ctx = stream_context_create(['ssl' => TH2Alpn::sslOptions([ 'local_cert' => '/path/server.pem', ])]); $listen = stream_socket_server('tls://0.0.0.0:443', $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, $ctx); $conn = stream_socket_accept($listen); // TLS handshake completes here TH2Alpn::requireH2($conn); // confirm the peer negotiated h2 $session = new TH2Session(true); // ... pump fread($conn) -> $session->receive(); fwrite($conn, $session->send()) ...
A client mirrors this with stream_socket_client('tls://...') and TH2Alpn::negotiatedH2($conn) to fall back to HTTP/1.1 when h2 is not offered. Certificates and cipher policy remain the caller's concern.
Limitations
- HTTP/3 is out of scope. HTTP/3 runs over QUIC, whose TLS key schedule needs hooks PHP's OpenSSL bindings do not expose; there is no usable pure-PHP or FFI-simple path today.
- Full TLS termination is the caller's responsibility. Certificates, cipher policy, and the listen/accept loop belong to the caller (often a reverse proxy).
TH2Alpnhandles the one HTTP/2-specific TLS step, negotiating theh2ALPN protocol (see TLS with ALPN); the caller then pumps the TLS stream into a session. Cleartexth2cneeds no TLS. - Not a web-SAPI module. A request-scoped SAPI (PHP-FPM, mod_php) does not expose the raw socket; use this in a long-running process.
Development
composer install vendor/bin/phpunit --testsuite unit # unit tests (dual in-process sessions) vendor/bin/php-cs-fixer fix --dry-run src/ # code style vendor/bin/phpstan analyse src/ --memory-limit=512M # static analysis
Tests drive a server and a client TH2Session against each other in-process (no sockets, no TLS), so they run anywhere libnghttp2 is installed and skip cleanly where it is not.
License
BSD-3-Clause. See LICENSE.