0

Event loops in PHP — ReactPHP, Amp, Swoole overview

Advanced5 min read·php-11-003
compare

Concept

An event loop is the engine behind async I/O in PHP. At its core it runs an infinite iteration: collect all active I/O descriptors (sockets, file handles), call stream_select() (or epoll/kqueue at the OS level) with a short timeout, wake up every descriptor that became readable or writable, invoke its associated callback, then repeat. This allows thousands of open connections to be managed in a single thread without blocking.

ReactPHP is the oldest and most widely adopted event loop library in the PHP ecosystem. Its architecture is purely callback-based — you register callbacks for read, write, and timer events. ReactPHP's Loop facade wraps OS-level multiplexing (preferring ext-eventext-evext-uvstream_select fallback). The react/promise library adds a Promise abstraction on top of the callback layer. ReactPHP's design philosophy is immutable streams and pure function-style composition.

Amp (version 3) takes a different approach: it integrates deeply with PHP 8.1 Fibers. Rather than requiring you to chain .then() callbacks, Amp lets you write straight-line code inside Fibers. Its Amp\async() function launches a Fiber and returns a Future. Awaiting the Future suspends the current Fiber until the value is ready. Amp ships with fiber-native HTTP client, MySQL, Redis, and file system libraries (amphp/*). This is the closest PHP gets to Go's goroutines in ergonomics.

Swoole (and its fork OpenSwoole) is a C extension, not a pure-PHP library. It replaces PHP's standard blocking I/O functions (PDO, cURL, file I/O) with non-blocking coroutine-aware equivalents using hook magic (Swoole\Runtime::enableCoroutineHook()). Swoole runs a pre-fork multi-process server where each worker runs a coroutine scheduler. It is significantly faster than ReactPHP and Amp but requires the extension and complicates deployment.

LibraryParadigmRequires extensionI/O hookBest for
ReactPHPCallbacks + PromisesNo (stream_select)ManualPortable async, CLI tools
Amp v3Fibers + FuturesNoManual (amphp libs)Modern coroutine-style PHP
SwooleCoroutinesYes (ext-swoole)AutomaticHigh-throughput servers
OpenSwooleCoroutinesYes (ext-openswoole)AutomaticSwoole fork, newer API

Code Example

php
<?php
declare(strict_types=1);

/**
 * Example 1: ReactPHP — concurrent HTTP requests via callback-based loop.
 * Requires: composer require react/http react/event-loop
 */

use React\EventLoop\Loop;
use React\Http\Browser;
use React\Promise\Promise;

// The loop is a singleton — Loop::get() returns the default instance.
$browser = new Browser();

$promises = [
    $browser->get('https://httpbin.org/delay/1'),
    $browser->get('https://httpbin.org/delay/1'),
    $browser->get('https://httpbin.org/delay/1'),
];

// React\Promise\all() resolves when ALL promises resolve.
// Total time ≈ 1 s, not 3 s.
\React\Promise\all($promises)->then(
    function (array $responses): void {
        foreach ($responses as $response) {
            echo 'Status: ' . $response->getStatusCode() . "\n";
        }
    }
);

// This call BLOCKS until there is no more work registered with the loop.
Loop::run();

// -------------------------------------------------------

/**
 * Example 2: Amp v3 — concurrent HTTP requests using Fibers.
 * Requires: composer require amphp/http-client
 */

use Amp\Http\Client\HttpClientBuilder;
use Amp\Http\Client\Request;
use function Amp\async;
use function Amp\await;

// Amp\async() starts a new Fiber and returns a Future<T>.
$futures = [
    async(fn () => (new HttpClientBuilder())->build()->request(new Request('https://httpbin.org/delay/1'))),
    async(fn () => (new HttpClientBuilder())->build()->request(new Request('https://httpbin.org/delay/1'))),
    async(fn () => (new HttpClientBuilder())->build()->request(new Request('https://httpbin.org/delay/1'))),
];

// Amp\Future\await() suspends the current Fiber until all futures resolve.
$responses = \Amp\Future\await($futures);

foreach ($responses as $response) {
    echo 'Status: ' . $response->getStatus() . "\n";
}

// -------------------------------------------------------

/**
 * Example 3: Manual stream_select() event loop (no library).
 * Shows the raw mechanic both ReactPHP and Amp build upon.
 */

$servers = [];
for ($i = 0; $i < 3; $i++) {
    $socket = stream_socket_client('tcp://httpbin.org:80', $errno, $errstr, 5);
    stream_set_blocking($socket, false);
    fwrite($socket, "GET /delay/1 HTTP/1.0\r\nHost: httpbin.org\r\n\r\n");
    $servers[] = $socket;
}

$done = [];
while (count($done) < count($servers)) {
    $read = array_diff($servers, $done);
    $write = $except = null;
    stream_select($read, $write, $except, 5); // blocks until a socket is readable

    foreach ($read as $socket) {
        $data = fread($socket, 8192);
        if ($data === '' || feof($socket)) {
            $done[] = $socket;
            fclose($socket);
        }
    }
}
echo "All done\n";

Interview Q&A

Q: How does stream_select() enable non-blocking I/O in PHP without a C extension?

stream_select() is a thin PHP wrapper over the POSIX select() system call (or poll() on some platforms). You pass it arrays of stream resources to watch for readability, writability, and exceptions, plus a timeout. The OS kernel monitors all those descriptors in one syscall and returns only the ones that are ready. This means PHP can watch 500 sockets with a single syscall rather than polling each one. The function is built into PHP core — no extension needed. Its limitation is the OS FD_SETSIZE cap (typically 1024 file descriptors on Linux) and higher overhead than epoll/kqueue, which is why production event loops prefer ext-event or ext-uv when available.


Q: What is the fundamental architectural difference between ReactPHP's callback model and Amp v3's Fiber model?

ReactPHP uses inversion of control: you register callbacks on Promises, and the event loop calls them. Your code is fragmented across many closures (callback hell, or flattened via .then() chains). Amp v3 restores straight-line control flow: your code is a Fiber that calls await() and appears to block, but under the hood the event loop suspends the Fiber and resumes it when the I/O completes. The resulting code reads like synchronous PHP but executes concurrently. The trade-off is that Amp v3 requires PHP 8.1+ and all I/O libraries must be Amp-native (use amphp/* packages) — you cannot transparently mix blocking PDO with Amp's scheduler.


Q: Why does Swoole's coroutine hook (Swoole\Runtime::enableCoroutineHook()) matter, and what are its risks?

Swoole replaces PHP's standard blocking I/O functions (PDO, MySQLi, cURL, sleep(), file operations) with coroutine-aware non-blocking equivalents via monkey-patching at the C extension level. This means existing code that calls PDO::query() works concurrently without any code changes — Swoole intercepts the call, registers the socket with its event loop, suspends the coroutine, and resumes it when the result arrives. The risk: libraries that use internal C-level blocking or that hold global state can behave incorrectly. Static singletons (database connection pools, authentication state) that work fine in FPM (share-nothing per request) can leak between coroutines in Swoole's long-lived process model. This is exactly the problem Laravel Octane documents in its "things that may break" guide.