Event loops in PHP — ReactPHP, Amp, Swoole overview
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-event → ext-ev → ext-uv → stream_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.
| Library | Paradigm | Requires extension | I/O hook | Best for |
|---|---|---|---|---|
| ReactPHP | Callbacks + Promises | No (stream_select) | Manual | Portable async, CLI tools |
| Amp v3 | Fibers + Futures | No | Manual (amphp libs) | Modern coroutine-style PHP |
| Swoole | Coroutines | Yes (ext-swoole) | Automatic | High-throughput servers |
| OpenSwoole | Coroutines | Yes (ext-openswoole) | Automatic | Swoole fork, newer API |
Code Example
<?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.