PHP 8.6's Io\Poll: stream_select() Finally Gets a Replacement

PHP 8.6's polling API swaps stream_select()'s 1024 descriptor cap for epoll and kqueue. The Io\Poll classes, the Handle restriction, and the Laravel reality.

Steven Richardson
Steven Richardson
· 8 min read

PHP has shipped exactly one I/O multiplexing primitive for its entire life. stream_select() is a wrapper around select(), a system call from 1983, and anyone who has written a long-running PHP process has eventually run into its ceiling. PHP 8.6, out on 19 November 2026, adds a second one.

The Polling API RFC passed 33–1 with four abstentions and is marked Implemented. It is written for extension authors, so here is what the userspace half actually looks like — and an honest answer to whether you will ever type it.

Why stream_select() had to go#

The RFC's "Current Situation in PHP" section lists four problems, and they are all real:

  • Limited scalability — select() caps at roughly 1024 file descriptors on most systems.
  • Poor performance — O(n) in the number of descriptors, scanned on every single call.
  • No access to modern mechanisms — no epoll on Linux, no kqueue on BSD or macOS, no event ports on Solaris.
  • No advanced features — no edge-triggered notifications, no one-shot mode.

This is why AMPHP and ReactPHP have shipped multiple driver backends for years, and why their READMEs tell you to install ext-ev or ext-uv if you care about throughput. The "install an extension for performance" footnote was always papering over a hole in the language.

Here is the shape of the old way — two streams, one loop:

<?php

declare(strict_types=1);

$read = [$socket, $pipe];
$write = $except = [];

// stream_select() mutates $read in place, so it has to be rebuilt every iteration
while (true) {
    $ready = $read;
    $w = $e = [];

    if (stream_select($ready, $w, $e, 1) === false) {
        break;
    }

    foreach ($ready as $stream) {
        // No way to tell read-ready from hangup — you find out by reading
        $data = fread($stream, 8192);
    }
}

Two things hurt here. You rebuild the array on every iteration because stream_select() takes it by reference and overwrites it. And when a stream comes back ready, you have no idea why — a closed peer and a readable socket look identical until you call fread() and inspect the result.

What the PHP 8.6 polling API actually gives you#

Six types, all under Io\Poll except one:

Type Role
Io\Poll\Context The poll set. Holds watchers, exposes wait().
Io\Poll\Watcher One registration. Returned by Context::add(), never constructed directly.
Io\Poll\Handle Marker interface for pollable resources.
StreamPollHandle The one concrete handle in 8.6. Wraps a stream resource.
Io\Poll\Backend Enum: Auto, Poll, Epoll, Kqueue, EventPorts, WSAPoll.
Io\Poll\Event Enum: Read, Write, Error, HangUp, ReadHangUp, OneShot, EdgeTriggered.

The same loop, rewritten:

<?php

declare(strict_types=1);

use Io\Poll\{Context, Event};

$poll = new Context(); // Backend::Auto — epoll on Linux, kqueue on macOS

$poll->add(new StreamPollHandle($socket), [Event::Read], ['name' => 'socket']);
$poll->add(new StreamPollHandle($pipe), [Event::Read], ['name' => 'pipe']);

while (true) {
    // Returns only the watchers that fired — no scanning the full set
    foreach ($poll->wait(1) as $watcher) {
        $name = $watcher->getData()['name'];
        $stream = $watcher->getHandle()->getStream();

        if ($watcher->hasTriggered(Event::HangUp)) {
            $watcher->remove();   // deregister before closing
            fclose($stream);
            continue;
        }

        if ($watcher->hasTriggered(Event::Read)) {
            handle($name, fread($stream, 8192));
        }
    }
}

Registration is persistent — you add a handle once and the kernel keeps the interest list. wait() hands back only the watchers that triggered, and hasTriggered() tells you which condition fired, so hangup and readable are finally distinguishable. The arbitrary $data payload you attach on add() comes back on the watcher, which removes the usual array of streams keyed by (int) $stream that every stream_select() loop grows.

Event::OneShot removes a watcher automatically after it fires once. Event::EdgeTriggered switches to reporting state changes rather than state — the technique that lets epoll and kqueue hold up past ten thousand connections — and you can ask whether the platform supports it before relying on it:

if ($poll->getBackend()->supportsEdgeTriggering()) {
    $poll->add($handle, [Event::Read, Event::EdgeTriggered]);
}

One line to see what your platform picked:

php -r 'echo (new Io\Poll\Context())->getBackend()->name, PHP_EOL;'
# Epoll on Linux, Kqueue on macOS and BSD, WSAPoll on Windows, Poll elsewhere

If you want that on CI before 8.6 is on your dev machine, add it as a matrix leg — the setup is the same one I walked through in matrix testing Laravel across PHP and database versions.

The Handle restriction that trips everyone up#

Io\Poll\Handle defines no methods. It is a type marker, and the real work happens in a C-level operations table (php_poll_handle_ops) that each concrete handle registers to tell the backend how to extract a file descriptor and check validity.

Because a userland class cannot provide that table, PHP refuses to let you try:

<?php

// This does not fail at runtime. It fails at class declaration time.
final class RedisPollHandle implements \Io\Poll\Handle
{
}

// Fatal error: Io\Poll\Handle cannot be implemented by user classes

That looks hostile until you see what it buys: any Handle reaching the polling backend is guaranteed to have a working ops table, so there is no "implemented the interface but nothing happens" failure mode. If you need to poll something exotic, get a stream resource for it and wrap that in StreamPollHandle. SocketPollHandle and CurlPollHandle are named in the RFC's future scope, along with TimerHandle and SignalHandle.

Where the PHP 8.6 polling API touches Laravel#

Short version: you will almost certainly never write new Io\Poll\Context() in application code, and nothing in a controller or a job should reach for it.

Read the RFC carefully and this is by design. The primary motivation is an internal API — a unified php_poll.h that PHP core and extensions can share. The userspace classes are the secondary benefit that falls out of doing that properly. The internal targets named in the RFC are signal handling under ZTS, PHP-FPM worker event handling before accept(), cross-platform timers, and extensions like sockets and curl.

Which is where it reaches you indirectly:

  • ZTS signal handling is exactly what FrankenPHP's goroutine-based threading mode needs, so this lands underneath Octane on FrankenPHP in production.
  • A WebSocket server is the textbook case for scalable polling. Reverb's loop sits on ReactPHP, so the win arrives when the library adopts the backend rather than when you change anything — relevant if you are scaling Reverb with presence channels.
  • Long-running daemons — queue:work, Horizon's supervisor, Pail's log tailing — all sit on the same plumbing that running queues in production depends on.

Nothing in that list exposes Io\Poll to you. The honest framing is that this raises the floor for everything built on top, and the people it helps most will never see it.

Gotchas and Edge Cases#

StreamPollHandle is not in the Io\Poll namespace. This is the one that will cost you ten minutes. The RFC declares it as a plain final class StreamPollHandle implements \Io\Poll\Handle with no namespace, php-src error messages refer to it unqualified, and Symfony's polyfill README lists it separately from "Io\Poll\Context, Io\Poll\Watcher and StreamPollHandle". Several published write-ups — including some you will find at the top of a search — open with use Io\Poll\{Context, Event, StreamPollHandle};, which will fail. Import Context and Event from the namespace and leave StreamPollHandle unqualified.

Check wait()'s signature on your own build before copying anyone's loop. RFC version 1.4 documents wait(?int $timeoutSeconds = null, int $timeoutMicroseconds = 0, ?int $maxEvents = null), keeping stream_select()'s split seconds/microseconds convention. But Time\Duration also landed in 8.6 as a shared timeout type, and Symfony's polyfill states flatly that Context::wait() takes its timeout as a Time\Duration. Published examples disagree on this. One line settles it:

php -r 'echo (string) new ReflectionMethod(Io\Poll\Context::class, "wait"), PHP_EOL;'

Closing a polled stream. StreamPollHandle went through several use-after-free fixes during the 8.6 cycle, all stemming from a closed stream behind a live handle. Call $watcher->remove() before fclose(), in that order, and do not hold a Watcher past the life of its Context.

The exceptions are specific, so catch specifically. Everything extends Io\Poll\PollException, which extends Io\IoException — the same family as PHP 8.6's stream error model:

try {
    $poll = new Context(Backend::Epoll); // explicit — Auto is the right default
    $watcher = $poll->add($handle, [Event::Read]);
} catch (\Io\Poll\BackendUnavailableException) {
    // Asked for a backend this platform does not have
} catch (\Io\Poll\HandleAlreadyWatchedException) {
    // Same handle added to the same context twice
} catch (\Io\Poll\InvalidHandleException) {
    // Stream closed or never pollable in the first place
}

FailedPollOperationException carries the error constants (ERROR_INTERRUPTED, ERROR_NOMEM, ERROR_AGAIN and the rest), so a signal interrupting wait() is distinguishable from a genuine failure. All Io\Poll objects are non-serialisable with strict properties, so do not try to cache one.

It is still not an event loop. No timers, no signals, no child process handling in the first cut. If you need those, use Revolt, AMPHP or ReactPHP on top — the point is that they can now sit on one native backend instead of three. A follow-up RFC, Polling API Additions, proposes exactly those pieces.

Wrapping Up#

Try the one-liner above on an 8.6 build, confirm which backend your platform picked, and then go back to not thinking about it — the value here is almost entirely indirect. If you are planning the move, the Laravel on PHP 8.6 upgrade checklist covers what actually breaks, and PHP 8.4's fibers are still the async primitive you are more likely to touch in application code.

FAQ#

What is the Io\Poll API in PHP 8.6?

Io\Poll is a new set of classes in PHP 8.6 that provide I/O multiplexing — watching many streams at once and being told which ones are ready. It wraps whichever polling mechanism the platform provides best, and exposes that through a Context, a Watcher per registration, and the Backend and Event enums. The RFC was authored by Jakub Zelenka and passed 33–1.

Does Io\Poll replace stream_select()?

For new code that polls more than a handful of streams, yes. stream_select() is not deprecated and continues to work, but it caps at roughly 1024 descriptors, scans every descriptor on every call, and cannot tell you why a stream became ready. Io\Poll has none of those limits and returns only the watchers that actually triggered, with the specific event attached.

Which polling backend does PHP 8.6 use on Linux and macOS?

epoll on Linux and kqueue on BSD and macOS. Solaris and illumos get event ports, Windows gets WSAPoll, and anything else falls back to POSIX poll(). Backend selection is automatic via Backend::Auto, and the RFC advises against overriding it outside of testing — an explicit backend that is unavailable throws rather than silently falling back.

Can I implement Io\Poll\Handle in my own class?

No. Io\Poll\Handle can only be implemented by internal classes written in C, because every handle type has to register a C-level operations table that tells the backend how to get its file descriptor. A userland class that tries fails at class declaration time with "Io\Poll\Handle cannot be implemented by user classes". Wrap a stream in StreamPollHandle instead.

Is Io\Poll an event loop?

No, and the RFC is explicit that it is deliberately minimal and focused — it provides polling, not a complete event loop. There are no timers, no signal handling and no child process management in the 8.6 version. Libraries like AMPHP, ReactPHP and Revolt still provide the loop; what changes is that they can build it on one native backend rather than maintaining several.

Do I need Io\Poll in a Laravel application?

Almost certainly not directly. Nothing in a controller, job, command or Livewire component has a reason to poll file descriptors, and Laravel exposes none of this. You benefit through the runtime instead — FrankenPHP signal handling under threads, PHP-FPM worker event handling, and async libraries consolidating onto a single backend. Treat it as infrastructure you inherit rather than an API to adopt.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.