DEV Community

Mike Pultz
Mike Pultz

Posted on

PHP Speaks QUIC Now, and OpenSSL Did the Hard Part

I released php-quic 1.0.0, a PHP extension that gives you raw QUIC transport with first-class access to streams. It links against the same OpenSSL that PHP already links against, and that is the whole trick.

Why This Was Awkward Before

QUIC is not a protocol you bolt on in userland. It is TLS 1.3, congestion control, loss recovery, and stream multiplexing, all riding on UDP, and all of it has to be right before you can send a single useful byte.

You could get there from PHP if you were willing to bind to a foreign QUIC library like ngtcp2 or quiche through FFI. That works, but now your PHP app carries a second TLS stack, a second set of CVEs to track, a build story that involves Rust, and a version matrix that has nothing to do with the one your distro maintains. For a language whose entire deployment story is "the package manager handles it," that is a lot of rope.

What Changed

OpenSSL 3.5 shipped a native QUIC stack. Client and server, in the library PHP is already built against.

That reframes the problem. The extension is no longer "embed a QUIC implementation into PHP." It is "expose the QUIC that is already sitting there." No new TLS stack, no FFI layer, no Rust toolchain in your build. If your OpenSSL is patched, your QUIC is patched.

The requirements fall out of that directly:

  • PHP 8.4 or newer (8.5 on Windows), NTS or ZTS
  • OpenSSL 3.5.0 or newer, built with QUIC support

Transport, Not HTTP/3

The thing I most wanted to avoid was shipping an HTTP/3 client and calling it a QUIC library.

QUIC is a transport. HTTP/3 is one protocol that runs on it, and it is not the interesting one for everybody. DNS-over-QUIC runs on it. So does anything you want to invent that needs multiplexed, ordered, loss-recovered streams without head-of-line blocking across them.

So php-quic hands you connections and streams, and stays out of the framing business. If you want HTTP/3, you build HEADERS frames and QPACK on top of it, and the README has a worked example. If you want something else, nothing is in your way.

A client is about as small as it can be:

$conn   = new Quic\Connection('example.com', 443, ['alpn' => 'myproto']);
$stream = $conn->openStream();

$stream->write("hello", true);
$reply = $stream->read(65535);

$conn->close();
Enter fullscreen mode Exit fullscreen mode

DNS-over-QUIC in a Dozen Lines

This is the example I actually care about, having spent a long time in Net_DNS2. DoQ is just a length-prefixed DNS message on a QUIC stream, with doq as the ALPN:

$conn   = new Quic\Connection('dns.adguard-dns.com', 853, ['alpn' => 'doq']);
$stream = $conn->openStream();

$stream->write(pack('n', strlen($query)) . $query, true);

$len  = unpack('n', $stream->read(2))[1];
$resp = $stream->read($len);

$conn->close();
Enter fullscreen mode Exit fullscreen mode

That is the entire transport. One query per stream, which is exactly what RFC 9250 asks for, and streams are cheap.

The Server Side Is Real

Not just a client. You can listen:

$listener = new Quic\Listener('0.0.0.0', 4433, [
    'local_cert' => '/etc/ssl/server.crt',
    'local_pk'   => '/etc/ssl/server.key',
    'alpn'       => 'myproto',
]);

while (true)
{
    $conn   = $listener->accept();
    $stream = $conn->acceptStream();

    $data = '';
    while (($chunk = $stream->read(65535)) !== null)
    {
        if ($chunk !== '') $data .= $chunk;
    }

    $stream->write("echo: $data", true);
    $conn->close();
}
Enter fullscreen mode Exit fullscreen mode

That loop is blocking, which is fine for a demo and wrong for production. For anything real there is a Quic\poll() primitive that takes streams and readiness flags, so you can drive many connections from one process:

Quic\poll([[$stream, Quic\POLL_READ | Quic\POLL_ERROR]], 1.0);
Enter fullscreen mode Exit fullscreen mode

Mutual TLS, SNI, certificate pinning, and custom ALPN are all there.

What It Deliberately Does Not Do

The OpenSSL QUIC stack does not implement everything in the QUIC spec family, so neither do I. Missing:

  • RFC 9221 unreliable datagrams
  • 0-RTT and TLS 1.3 early data
  • Connection migration
  • Server-side idle-timeout control

None of these are required for HTTP/3 or DNS-over-QUIC, which is why I shipped without them rather than waiting. If you need datagrams for a media transport, this is not your extension yet.

There is one operational wart worth knowing before you deploy: about 300 bytes of per-stream state sticks around on a connection until that connection closes. On a long-lived connection chewing through a huge number of streams, that adds up, so recycle connections periodically. I would rather document that than pretend it isn't there.

Speed

Measured on a 2-core box, so read these as shape, not as a leaderboard:

Mode Single process Two processes, SO_REUSEPORT
New connection plus request ~60 tx/s ~85 to 100 tx/s
Request on a persistent connection ~5k to 7k tx/s ~6k to 9k tx/s

The gap between those two rows is the entire point. A fresh QUIC connection costs a handshake, and a handshake costs real money. Reuse the connection and you are into the thousands. SO_REUSEPORT buys roughly 1.3x to 1.7x on two cores and scales about linearly as you add them, because every connection is multiplexed over a single UDP socket per process.

Install It

pie install mikepultz/php-quic
Enter fullscreen mode Exit fullscreen mode

Or build from source with ./configure --with-quic. BSD-3-Clause, and the source is at github.com/mikepultz/php-quic.

If you break it, I want the issue. Especially the server path under load, which is where the interesting bugs live.

Top comments (0)