Io\Poll: PHP 8.6 Finally Replaces stream_select()
PHP 8.6 ships a native Io\Poll API backed by epoll, kqueue, and WSAPoll. What it fixes about stream_select, and how the new Context and Watcher classes work.
If you have ever written a PHP script that watches more than a handful of sockets at once, you have hit the wall that is stream_select(). It is the tool PHP has always handed you for that job (socket_select() in ext-sockets is the same thing wearing a different hat), it is built on the POSIX select() syscall, and it carries every limitation select() has had since the 1980s. PHP 8.6 changes that. The Polling API RFC by Jakub Zelenka passed 33 to 1 with 4 abstentions in June 2026, and the implementation is in PHP 8.6.0 Beta 1, which shipped on August 13, 2026.
What was actually wrong with stream_select()
Three things, and the first one is the one that bites in production.
select() uses a fixed-size bitmask for file descriptors. On most systems that mask tops out around 1024 descriptors. Go past it and you are in undefined behavior territory, not a clean error. If you have ever wondered why every serious PHP async library ships a StreamSelectLoop as the fallback and then tries very hard to use ext-event or ext-uv instead, this is why.
Second, select() is O(n). It scans the entire descriptor set on every call, and you have to re-register every descriptor on every call because the kernel modifies your bitmasks in place. With 10,000 connections you are paying that cost thousands of times per second.
Third, PHP had no access to the modern mechanisms the operating system already provides. Linux has had epoll since 2002. BSD and macOS have kqueue. Solaris and illumos have event ports. Windows has WSAPoll. These maintain the descriptor set inside the kernel, so registration is a one-time cost and the wait operation is effectively O(1) with respect to the number of watched descriptors. PHP could not touch any of them without a PECL extension.
The API
Everything lives under the new Io\Poll namespace, which is the first occupant of a new top-level Io namespace PHP is claiming for future work. There are five pieces: Context, Watcher, the Handle marker interface, and the Backend and Event enums.
Context is the poll set. You construct one, add handles to it, and call wait():
<?php
use Io\Poll\{Context, Event};
$poll = new Context(); // Backend::Auto picks epoll/kqueue/WSAPoll for you
$server = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
stream_set_blocking($server, false);
$poll->add(new StreamPollHandle($server), [Event::Read], ['type' => 'server']);
echo "Listening on 8080 via {$poll->getBackend()->name}\n";
Note that StreamPollHandle is in the global namespace while the rest of the API is namespaced, which is a small wart in the RFC that you will trip over exactly once.
Context::add() returns a Watcher, and Context::wait() returns an array of the watchers that fired. That is the second real improvement over stream_select(): instead of getting back three mutated arrays of raw streams and then reverse-mapping them to your own connection objects, you attach arbitrary user data to each watcher and get it straight back.
while (true) {
// Signature mirrors stream_select: seconds, then microseconds.
// null seconds means block indefinitely.
$ready = $poll->wait(timeoutSeconds: 1);
foreach ($ready as $watcher) {
$meta = $watcher->getData();
$stream = $watcher->getHandle()->getStream();
if ($meta['type'] === 'server' && $watcher->hasTriggered(Event::Read)) {
$client = stream_socket_accept($stream, 0);
if ($client !== false) {
stream_set_blocking($client, false);
$poll->add(
new StreamPollHandle($client),
[Event::Read],
['type' => 'client', 'connectedAt' => time()],
);
}
continue;
}
if ($watcher->hasTriggered(Event::HangUp) || $watcher->hasTriggered(Event::Error)) {
$watcher->remove();
fclose($stream);
continue;
}
if ($watcher->hasTriggered(Event::Read)) {
$buffer = fread($stream, 8192);
if ($buffer === false || $buffer === '') {
$watcher->remove();
fclose($stream);
} else {
fwrite($stream, "phparch echo: {$buffer}");
}
}
}
}
Event::Error and Event::HangUp are output-only. Every backend monitors them automatically, so you never request them, you only check for them. That alone kills a whole class of bug, since stream_select() on Windows required stuffing sockets into the $except array to detect a failed connect. WSAPoll reports it natively.
Watchers are mutable and cheap to update. If you are writing a proxy and need to flip a connection between read and write interest, modifyEvents() does it in one kernel call:
$watcher->modifyEvents([Event::Write]);
$watcher->modify([Event::Read, Event::Write], $newUserData);
$watcher->modifyData($newUserData); // no syscall, just the associated value
Two events change the semantics of a watcher rather than what it watches. Event::OneShot removes the watcher automatically after it fires once, which is handy for connect-completion checks. Event::EdgeTriggered reports state transitions instead of state, which cuts syscalls further but obligates you to drain the socket until it would block. Edge triggering is epoll and kqueue only, so guard it:
if ($poll->getBackend()->supportsEdgeTriggering()) {
$poll->add($handle, [Event::Read, Event::EdgeTriggered]);
}
The part that is not about your code
The RFC is explicit that the userspace API is the secondary goal. The primary motivation is an internal php_poll.h that PHP core and extensions can share. That matters more than the new classes, because the list of things it unblocks is long: safe signal handling under ZTS (which FrankenPHP’s goroutine-based TSRM mode needs), replacing the ad-hoc event handling PHP-FPM does before accept(), and a cross-platform timer implementation that finally works properly on macOS.
Extensions register a php_poll_handle_ops struct with get_fd, is_valid, and cleanup callbacks. That is how SocketPollHandle and CurlPollHandle will arrive later without an API break. The Handle interface itself is a pure marker with no methods, and userland classes are forbidden from implementing it. Try, and you get a fatal error at class declaration time, because a user class cannot supply the C-level ops table the backend needs.
What it is not
This is not an event loop. There are no timers, no signals, no child process handling, no promises. Those are explicitly listed as future scope. If you want an event loop, you still want AMPHP, ReactPHP, or Revolt. The difference is that those libraries can now target one efficient native backend instead of maintaining a select fallback plus optional ext-event and ext-uv paths. Nicolas Grekas, Kévin Dunglas, Cees-Jan Kiewiet, and Bob Weinand all voted yes, which tells you the async ecosystem wants this.
There are also no backward incompatible changes. This is pure addition. The only conflict check the RFC ran was on the Io namespace itself, and it turned up a single active project that does not use Io\Poll.
Worth testing now
If you maintain anything that opens more than a few hundred sockets, a queue consumer, a WebSocket gateway, a fan-out HTTP client, this is worth building against on a scratch box. Beta 2 lands August 27, Beta 3 on September 10, RC1 on September 24, and GA is November 19, 2026. Bugs found in beta get fixed in beta. Bugs found in December become your problem for a year.
The realistic first move is not rewriting your server. It is auditing where you already call stream_select() and counting the descriptors. If that number can grow with traffic, you have a ceiling you probably did not know about.
Sources
- PHP RFC: Polling API
- PHP: News Archive 2026, PHP 8.6.0 Beta 1 announcement
- php-src PR #19572, Polling API implementation
- php-src commit 6c6fb56, user-facing API
- php-src commit 2d15108, internal API
- PHP 8.6 todo list, PHP Wiki
- epoll(7), Linux man pages
- kqueue(2), FreeBSD man pages
- WSAPoll function, Microsoft Learn