Table of Contents

PHP RFC: Polling API

Introduction

PHP currently provides only the stream_select() function for I/O multiplexing, which is based on the select() system call. This approach has several limitations:

Modern applications, particularly those handling many concurrent connections (web servers, message queues, WebSocket servers), would benefit significantly from access to platform-specific, high-performance polling mechanisms.

Proposal

The proposal is to add a new polling API to PHP core that provides a unified interface to platform-specific polling mechanisms. This API will automatically select the best available backend for the current platform while providing a consistent interface across all systems.

Supported Backends

The implementation will support the following polling backends:

The backend will be selected automatically based on platform availability, with an option to explicitly choose a specific backend for testing or compatibility purposes.

PollHandle Abstraction

A key innovation in this API is the PollHandle abstraction. This abstract class represents any pollable resource and can be extended to support various resource types. The initial implementation provides StreamPollHandle for stream resources, but future implementations can add:

This design allows the polling API to evolve and support additional resource types without breaking backward compatibility.

API

The proposal introduces several new classes and constants:

<?php
 
/**
 * Event flag for monitoring readability
 */
const POLL_EVENT_READ = UNKNOWN;
 
/**
 * Event flag for monitoring writability
 */
const POLL_EVENT_WRITE = UNKNOWN;
 
/**
 * Event flag for error conditions (output only)
 */
const POLL_EVENT_ERROR = UNKNOWN;
 
/**
 * Event flag for hang up events (output only)
 */
const POLL_EVENT_HUP = UNKNOWN;
 
/**
 * Event flag for peer shutdown of write half (epoll only)
 */
const POLL_EVENT_RDHUP = UNKNOWN;
 
/**
 * Event flag for one-shot mode
 */
const POLL_EVENT_ONESHOT = UNKNOWN;
 
/**
 * Event flag for edge-triggered mode (epoll and kqueue only)
 */
const POLL_EVENT_ET = UNKNOWN;
 
/**
 * Enum representing available polling backends
 */
enum PollBackend : string
{
    case Auto = "auto";
    case Poll = "poll";
    case Epoll = "epoll";
    case Kqueue = "kqueue";
    case EventPorts = "eventport";
    case WSAPoll = "wsapoll";
}
 
/**
 * Abstract base class for pollable handles
 * 
 * This class represents any resource that can be monitored by the polling API.
 * Concrete implementations provide specific resource types like streams, sockets, etc.
 */
abstract class PollHandle
{
    /**
     * Get the underlying file descriptor
     * 
     * @return int The file descriptor number
     */
    protected function getFileDescriptor(): int {}
}
 
/**
 * Poll handle for stream resources
 * 
 * Wraps a PHP stream resource to make it compatible with the polling API.
 */
final class StreamPollHandle extends PollHandle
{
    /**
     * Create a new stream poll handle
     * 
     * @param resource $stream A valid PHP stream resource
     * @throws PollException If the stream is not valid
     */
    public function __construct($stream) {}
 
    /**
     * Get the underlying stream resource
     * 
     * @return resource The stream resource
     */
    public function getStream() {}
 
    /**
     * Check if the handle is still valid
     * 
     * @return bool True if the stream is still valid, false otherwise
     */
    public function isValid(): bool {}
}
 
/**
 * Represents a registered watcher in a poll context
 * 
 * A watcher monitors a handle for specific events and can be modified or removed.
 */
final class PollWatcher
{
    /**
     * Private constructor preventing direct creation.
     * 
     * Watchers are created by PollContext::add() and cannot be instantiated directly.
     */
    private final function __construct() {}
 
    /**
     * Get the handle being watched
     * 
     * @return PollHandle The monitored handle
     */
    public function getHandle(): PollHandle {}
 
    /**
     * Get the events this watcher is monitoring
     * 
     * @return int Bitmask of POLL_EVENT_* constants
     */
    public function getWatchedEvents(): int {}
 
    /**
     * Get the events that were triggered in the last poll operation
     * 
     * @return int Bitmask of POLL_EVENT_* constants
     */
    public function getTriggeredEvents(): int {}
 
    /**
     * Get the user data associated with this watcher
     * 
     * @return mixed The user data
     */
    public function getData(): mixed {}
 
    /**
     * Check if specific events were triggered
     * 
     * @param int $events Bitmask of POLL_EVENT_* constants to check
     * @return bool True if any of the specified events were triggered
     */
    public function hasTriggered(int $events): bool {}
 
    /**
     * Check if this watcher is still active in the poll context
     * 
     * @return bool True if active, false if removed
     */
    public function isActive(): bool {}
 
    /**
     * Modify both the watched events and associated data
     * 
     * @param int $events New bitmask of POLL_EVENT_* constants
     * @param mixed $data New user data
     * @throws PollException If modification fails
     */
    public function modify(int $events, mixed $data = null): void {}
 
    /**
     * Modify only the watched events
     * 
     * @param int $events New bitmask of POLL_EVENT_* constants
     * @throws PollException If modification fails
     */
    public function modifyEvents(int $events): void {}
 
    /**
     * Modify only the associated user data
     * 
     * @param mixed $data New user data
     */
    public function modifyData(mixed $data): void {}
 
    /**
     * Remove this watcher from the poll context
     * 
     * After removal, the watcher becomes inactive and cannot be reused.
     */
    public function remove(): void {}
}
 
/**
 * Main polling context for monitoring multiple handles
 * 
 * A poll context manages a collection of watchers and provides the main
 * wait() method to check for events.
 */
final class PollContext
{
    /**
     * Create a new poll context
     * 
     * @param PollBackend $backend The polling backend to use (default: Auto)
     * @throws PollException If the specified backend is not available
     */
    public function __construct(PollBackend $backend = PollBackend::Auto) {}
 
    /**
     * Add a new handle to monitor
     * 
     * @param PollHandle $handle The handle to monitor
     * @param int $events Bitmask of POLL_EVENT_* constants to watch for
     * @param mixed $data Optional user data to associate with this watcher
     * @return PollWatcher The created watcher instance
     * @throws PollException If the handle cannot be added
     */
    public function add(PollHandle $handle, int $events, mixed $data = null): PollWatcher {}
 
    /**
     * Wait for events on monitored handles
     * 
     * This method blocks until at least one event occurs or the timeout expires.
     * 
     * @param int $timeout Timeout in milliseconds (-1 for infinite, 0 for non-blocking)
     * @param int $maxEvents Maximum number of events to return (-1 for unlimited)
     * @return array Array of PollWatcher instances that have triggered events
     * @throws PollException If the wait operation fails
     */
    public function wait(int $timeout = -1, int $maxEvents = -1): array {}
 
    /**
     * Get the active polling backend
     * 
     * @return PollBackend The backend being used
     */
    public function getBackend(): PollBackend {}
}
 
/**
 * Exception thrown by polling operations
 */
class PollException extends Exception
{
}
 
?>

Event Constants

Event constants can be combined using bitwise OR operator (|).

Input Events

These events can be specified when adding or modifying watchers to indicate which conditions to monitor:

Output Events

These events are returned by the polling backend to indicate conditions that occurred:

Note that POLL_EVENT_READ, POLL_EVENT_WRITE, and POLL_EVENT_RDHUP can appear in both input (to request monitoring) and output (when conditions occur). POLL_EVENT_ERROR and POLL_EVENT_HUP are automatically monitored by all backends and only appear in output events.

Usage Examples

TCP Server Example

<?php
 
// Create a poll context with automatic backend selection
$poll = new PollContext();
 
// Create a server socket
$server = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
if (!$server) {
    die("Failed to create server: $errstr\n");
}
stream_set_blocking($server, false);
 
// Wrap the stream in a PollHandle
$serverHandle = new StreamPollHandle($server);
 
// Add the server socket to the poll context
$poll->add($serverHandle, POLL_EVENT_READ, ['type' => 'server']);
 
$clients = [];
 
echo "Server listening on port 8080\n";
 
while (true) {
    // Wait for events (timeout in milliseconds, -1 = infinite)
    $events = $poll->wait(1000);
 
    foreach ($events as $watcher) {
        $data = $watcher->getData();
 
        if ($data['type'] === 'server' && $watcher->hasTriggered(POLL_EVENT_READ)) {
            // Accept new client connection
            $handle = $watcher->getHandle();
            $server = $handle->getStream();
            $client = stream_socket_accept($server, 0);
            if ($client) {
                stream_set_blocking($client, false);
                $clientHandle = new StreamPollHandle($client);
                $clientWatcher = $poll->add($clientHandle, POLL_EVENT_READ, ['type' => 'client']);
                $clients[] = $clientWatcher;
                echo "New client connected\n";
            }
        } elseif ($data['type'] === 'client') {
            $handle = $watcher->getHandle();
            $stream = $handle->getStream();
 
            if ($watcher->hasTriggered(POLL_EVENT_READ)) {
                // Read data from client
                $buffer = fread($stream, 8192);
                if ($buffer === false || $buffer === '') {
                    echo "Client disconnected\n";
                    $watcher->remove();
                    fclose($stream);
                } else {
                    echo "Received: $buffer";
                    // Echo back to client
                    fwrite($stream, "Echo: $buffer");
                }
            }
 
            if ($watcher->hasTriggered(POLL_EVENT_HUP | POLL_EVENT_ERROR)) {
                echo "Client connection error or hangup\n";
                $watcher->remove();
                fclose($stream);
            }
        }
    }
}
 
?>

TCP Client Example

<?php
 
function sendAndReceive($poll, $watcher, $client, $message, $timeout = 5000) {
    // Send message to server
    fwrite($client, $message);
    echo "Sent: $message";
 
    // Wait for response
    $events = $poll->wait($timeout);
 
    if (empty($events)) {
        echo "Timeout waiting for response\n";
        return false;
    }
 
    foreach ($events as $watcher) {
        if ($watcher->hasTriggered(POLL_EVENT_READ)) {
            $data = fread($client, 8192);
            if ($data === false || $data === '') {
                echo "Server closed connection\n";
                return false;
            }
            echo "Received: $data";
            return true;
        }
 
        if ($watcher->hasTriggered(POLL_EVENT_ERROR | POLL_EVENT_HUP)) {
            echo "Connection error\n";
            return false;
        }
    }
 
    return true;
}
 
// Create a poll context
$poll = new PollContext();
 
// Connect to the echo server
$client = stream_socket_client('tcp://127.0.0.1:8080', $errno, $errstr, 30);
if (!$client) {
    die("Failed to connect: $errstr\n");
}
stream_set_blocking($client, false);
 
// Create handle and watch for readable data
$handle = new StreamPollHandle($client);
$watcher = $poll->add($handle, POLL_EVENT_READ);
 
// Send messages to the server
$messages = ["Hello, Server!\n", "How are you?\n", "Goodbye!\n"];
foreach ($messages as $message) {
    if (!sendAndReceive($poll, $watcher, $client, $message)) {
        break;
    }
 
    // Small delay between messages
    usleep(100000); // 100ms
}
 
fclose($client);
echo "Client finished\n";
 
?>

Callback-based Event Handler

<?php
 
/**
 * Generic event-driven wrapper that dispatches to callbacks
 */
class EventLoop
{
    private PollContext $poll;
 
    public function __construct()
    {
        $this->poll = new PollContext();
    }
 
    /**
     * Add a stream with callback handlers
     * 
     * @param resource $stream The stream to monitor
     * @param int $events Events to watch for
     * @param callable $callback Callback to invoke when events occur
     * @return PollWatcher The created watcher
     */
    public function addStream($stream, int $events, callable $callback): PollWatcher
    {
        $handle = new StreamPollHandle($stream);
        return $this->poll->add($handle, $events, ['callback' => $callback]);
    }
 
    /**
     * Run the event loop
     */
    public function run(): void
    {
        while (true) {
            $events = $this->poll->wait();
 
            foreach ($events as $watcher) {
                $data = $watcher->getData();
                $callback = $data['callback'];
 
                // Get stream from the handle
                $handle = $watcher->getHandle();
                $stream = $handle->getStream();
 
                // Invoke the callback with the watcher and stream
                $callback($watcher, $stream);
            }
        }
    }
 
    public function getContext(): PollContext
    {
        return $this->poll;
    }
}
 
// Usage example - Simple echo server using callbacks
$loop = new EventLoop();
 
$server = stream_socket_server('tcp://127.0.0.1:9090');
stream_set_blocking($server, false);
 
$loop->addStream($server, POLL_EVENT_READ, function($watcher, $stream) use ($loop) {
    // Accept new client
    $client = stream_socket_accept($stream, 0);
    if ($client) {
        stream_set_blocking($client, false);
        echo "Client connected\n";
 
        // Add client with its own callback
        $loop->addStream($client, POLL_EVENT_READ, function($watcher, $stream) {
            $data = fread($stream, 8192);
            if ($data === false || $data === '') {
                echo "Client disconnected\n";
                $watcher->remove();
                fclose($stream);
                return;
            }
 
            echo "Received: $data";
 
            // Echo back to client
            fwrite($stream, "Echo: $data");
        });
    }
});
 
echo "Callback-based server listening on port 9090\n";
$loop->run();
 
?>

Modifying Watchers

<?php
 
$poll = new PollContext();
$stream = fopen('php://temp', 'r+');
$handle = new StreamPollHandle($stream);
 
// Initially watch for read events
$watcher = $poll->add($handle, POLL_EVENT_READ, 'some data');
 
// Later, modify to watch for write events instead
$watcher->modifyEvents(POLL_EVENT_WRITE);
 
// Or modify both events and data
$watcher->modify(POLL_EVENT_READ | POLL_EVENT_WRITE, 'updated data');
 
// Just modify the associated data
$watcher->modifyData('new data');
 
// Remove the watcher when done
$watcher->remove();
 
?>

Edge-Triggered Mode

<?php
 
$poll = new PollContext();
$stream = stream_socket_client('tcp://example.com:80');
stream_set_blocking($stream, false);
 
$handle = new StreamPollHandle($stream);
 
// Use edge-triggered mode (requires epoll or kqueue backend)
// In ET mode, you must read/write until EAGAIN to avoid missing events
$watcher = $poll->add($handle, POLL_EVENT_READ | POLL_EVENT_ET);
 
while (true) {
    $events = $poll->wait();
 
    foreach ($events as $watcher) {
        if ($watcher->hasTriggered(POLL_EVENT_READ)) {
            // In ET mode, read until EAGAIN
            while (($data = fread($stream, 8192)) !== false && $data !== '') {
                process_data($data);
            }
        }
    }
}
 
?>

Explicit Backend Selection

<?php
 
// Use specific backend (useful for testing or compatibility)
try {
    $poll = new PollContext(PollBackend::Poll);
    echo "Using poll backend\n";
} catch (PollException $e) {
    echo "Poll backend not available: " . $e->getMessage() . "\n";
    exit(1);
}
 
// Check which backend is actually being used
echo "Active backend: " . $poll->getBackend()->value . "\n";
 
?>

Internal API

In addition to the userspace API, this RFC introduces a new internal polling API that will be available to PHP core and extensions. This internal API provides:

The internal API is designed to eventually replace the various ad-hoc polling implementations currently scattered throughout the codebase. The API is exposed through the php_poll.h header and provides the following key functions:

Extensions can also provide custom handle types by implementing the php_poll_handle_ops structure, which allows them to define how their resources map to file descriptors and how validity is checked.

Backward Incompatible Changes

None. This RFC only adds new functionality and does not modify existing APIs.

Proposed PHP Version(s)

PHP 8.6

Future Scope

This proposal lays the groundwork for several future enhancements:

Performance Considerations

The polling API provides significant performance benefits over stream_select():

Modern polling mechanisms like epoll and kqueue are specifically designed for high-performance applications and can scale to tens of thousands of concurrent connections where select() becomes impractical.

Proposed Voting Choices

As per the voting RFC, a yes/no vote with a 2/3 majority is needed for this proposal to be accepted.

Should the Polling API be added to PHP core?
Real name Yes No
Final result: 0 0
This poll has been closed.

The vote started on 2025-XX-XX at XX:XX UTC and ends on 2025-XX-XX at XX:XX UTC.

Patches and Tests

A working implementation is under development and will be available at [repository URL].

The implementation includes a complete test suite covering all backends and common use cases. The tests are designed to run on all supported platforms and automatically skip backend-specific tests when the backend is not available.

Implementation

After the project is implemented, this section will contain:

  1. the version(s) it was merged to
  2. a link to the git commit(s)

References

Changelog