Event-Driven PHP: Event Loops, ReactPHP, Swoole, and Long-Running Workers
Understand how event-driven PHP differs from the traditional per-request model, how an event loop achieves concurrency through non-blocking I/O, how ReactPHP, Amp, and Swoole/OpenSwoole fit together with PHP Fibers, and how to avoid shared-mutable-state bugs in long-running worker processes.
Classic PHP -- running under Apache's mod_php or PHP-FPM -- follows a shared-nothing model: every HTTP request gets a brand-new PHP process (or a freshly reset request context), runs your script top to bottom, and then the whole process is torn down. Nothing you set in a global variable or static property survives past that one request. This simplicity is a huge part of why PHP has been forgiving to write for decades -- you never have to think about 'leftover state from the last request' because there isn't any. Event-driven PHP throws that assumption out: a single PHP process stays alive for hours or days, handling thousands of 'requests' (HTTP connections, WebSocket messages, queue jobs) one after another -- or even concurrently -- inside one long-running event loop. Tools like ReactPHP, Amp, and Swoole/OpenSwoole make this possible, and PHP 8.1's Fiber class gives modern async libraries a way to make that concurrency look almost like ordinary synchronous code. This lesson covers how the event loop model works, tours the ecosystem, and -- most importantly -- explains the new bug class this model introduces: state that silently leaks between 'requests' that no longer get a fresh process.
From Shared-Nothing to Long-Running Processes
In the traditional model, PHP-FPM keeps a pool of worker processes, but conceptually each one is disposable per request: the process is created (or reset to a known-clean state), your script runs, and everything it allocated -- variables, open resources, static properties -- disappears when the request ends. This 'shared-nothing' architecture is why you can deploy a new version of a PHP app by simply swapping files: there's no in-memory state to migrate, because there effectively isn't any that outlives a request.
Event-driven PHP inverts this: instead of one process per request, one process serves many requests over its entire lifetime, using a single-threaded event loop to interleave the work instead of relying on the OS to schedule separate processes. This buys enormous efficiency -- no process-startup cost per request, no re-parsing/re-bootstrapping your framework on every hit, and the ability to hold thousands of concurrent connections open (useful for WebSockets, long-polling, and Server-Sent Events) that would be prohibitively expensive as one OS process each. The cost is that you, the application author, now have to think explicitly about what state is safe to keep around between 'requests' and what isn't -- a concern classic PHP never required.
• Traditional (PHP-FPM/mod_php): one fresh process (or reset context) per request -- all state dies with the request. • Event-driven (ReactPHP/Amp/Swoole): one long-running process serves many requests over its lifetime. • Event-driven wins on throughput and enables long-lived connections (WebSockets, SSE, long-polling) that per-process models handle poorly. • Event-driven loses PHP's classic guarantee that nothing persists between requests -- that guarantee now has to be engineered deliberately.
The Event Loop and Non-Blocking I/O
An event loop is, at its core, just a loop: wait until something is ready (a timer fires, a socket has data to read, a socket is writable again), run whatever callback registered interest in that event, and repeat. This is the 'reactor pattern,' and under the hood it's built on OS primitives like select(), epoll (Linux), or kqueue (BSD/macOS) that let one thread efficiently ask 'which of these thousand sockets actually has something to do?' instead of checking each one in turn.
The concurrency this buys you is real, but it depends entirely on never blocking the loop. If a callback makes a traditional blocking call -- file_get_contents() against a slow URL, a synchronous PDO query, sleep() -- the single thread running the event loop is stuck waiting for that one operation, and every other connection sharing that loop stalls too, even though none of them are actually doing anything. This is the single most important mental model shift: in the per-process world, a slow request only slows down that one request; in the event-loop world, a slow request without proper non-blocking I/O slows down everyone sharing that worker.
• The loop waits for readiness (timer, socket readable/writable), runs the matching callback, then waits again. • OS-level readiness primitives (epoll/kqueue, wrapped by select()-style calls) let one thread monitor thousands of sockets cheaply. • A blocking call inside a handler blocks the ENTIRE loop -- every other 'concurrent' connection on that worker, not just the slow one. • Non-blocking I/O means: start the operation, register a callback for 'when it's ready,' and immediately return control to the loop.
ReactPHP and Amp: PHP's Async Ecosystem
ReactPHP is a collection of independent components built around a shared event loop (react/event-loop) -- react/http for an HTTP server, react/socket for raw TCP/TLS, react/mysql and react/http-client for non-blocking database and outbound HTTP access, and so on. Historically it used Promises (react/promise) for composing async operations (->then() chains), and newer versions add Fiber-based coroutine helpers that let you write async code that reads top-to-bottom instead of as a chain of callbacks.
Amp (amphp) takes a similar 'library, not a runtime' approach but has leaned further into Fibers: as of Amp v3, essentially the entire library is built on PHP's native Fiber class, exposing a small, structured-concurrency-flavored API (async(), await(), Future) instead of promise chains. Both ecosystems solve the same underlying problem -- non-blocking sockets, timers, and process management -- and differ mostly in API philosophy and how far each has moved from callbacks toward Fiber-based 'looks synchronous' code.
• ReactPHP: react/event-loop + independent components (react/http, react/socket, react/mysql, ...). • Amp: a Fiber-first library (as of v3) exposing async()/await()/Future instead of promise chains. • Both run as ordinary PHP libraries -- you write and deploy a long-running PHP script, not a separate server binary. • Both require every I/O-touching library in your stack to have a non-blocking-aware version, or you lose the concurrency benefit.
Swoole and OpenSwoole: A Different Approach
Swoole (and its community fork, OpenSwoole) is a C extension, not a userland library -- it replaces PHP-FPM entirely with its own event-loop-driven HTTP, WebSocket, and TCP server built directly into the PHP runtime. Its signature feature is coroutines: Swoole's scheduler transparently suspends the current coroutine whenever a 'hooked' blocking call (MySQL, Redis, file I/O, sleep(), curl with runtime hooks enabled) would block, and resumes it once the operation completes -- so your handler code can look exactly like ordinary synchronous PHP while behaving like the explicitly async ReactPHP/Amp examples underneath.
This coroutine-style concurrency is Swoole's main pitch over ReactPHP/Amp's explicit async style: you largely don't have to rewrite your database or HTTP client calls to use a special non-blocking API, because Swoole's runtime hooks intercept the standard ones. The tradeoff is that Swoole is a C extension you must install and that owns the whole request lifecycle (it commonly powers Laravel Octane and Hyperf), rather than a library you add to an otherwise ordinary PHP-FPM app.
• Swoole/OpenSwoole is a PHP extension, not a Composer library -- it replaces PHP-FPM's process model outright. • Its coroutine scheduler auto-suspends/resumes on hooked blocking calls, so handler code can look synchronous. • Powers frameworks like Laravel Octane and Hyperf that run an entire framework inside a long-running Swoole worker for throughput. • Because it owns the server, it also owns the 'process outlives many requests' tradeoff discussed later in this lesson.
How Fibers Fit In
PHP 8.1 introduced the Fiber class: a low-level primitive representing an independently pausable and resumable call stack. A function running inside a Fiber can call Fiber::suspend() to hand control back to whoever started or resumed it, and later be resumed with a value passed back in -- all without unwinding the call stack the way a return or exception would. On its own, a Fiber does nothing asynchronous; it's purely a mechanism for pausing and resuming a piece of code.
What Fibers enable is the thing that makes modern async PHP pleasant to write: a scheduler (ReactPHP's loop, Amp's runtime, Swoole's coroutine scheduler) can start a Fiber, let it run until it hits a would-block point, suspend it there, go do other work, and resume it later when the operation completes -- all while the code inside the Fiber reads like ordinary top-to-bottom synchronous PHP with no ->then() callbacks in sight. This is why Amp v3 and newer ReactPHP integrations moved away from promise-chain-heavy APIs toward coroutine-style async()/await() helpers: Fibers gave them a language-level building block to build that ergonomic API on top of, instead of simulating it with generators and callbacks.
• A Fiber is a pausable/resumable call stack -- suspend/resume, not automatic scheduling. • Fibers do no I/O and know nothing about event loops by themselves; a scheduler decides WHEN to resume one. • Amp v3 and newer ReactPHP async helpers are built on Fibers, which is why async code can look synchronous without callback chains. • Fibers are the PHP-level equivalent of what generator-based coroutines simulated in older async PHP code, done natively and more efficiently.
Event-Driven Architecture and the State-Management Risk
This is the risk that catches teams moving to event-driven PHP by surprise. In the per-process model, a static property, a global variable, or a singleton's cached value dies automatically at the end of every request -- so even sloppy use of global state was, in practice, harmless across requests. In a long-running worker, the process is the thing that persists, not the request, so anything stored in a static property, a global, or a singleton's instance property is now visible to every subsequent 'request' handled by that same worker -- including requests from completely different users.
This shows up in subtle places: an ORM's identity map or unit-of-work caching an entity fetched for user A can silently hand a stale (or worse, user A's) entity to user B's request if the ORM instance is reused as a long-lived singleton. A 'current user' helper that stashes the authenticated user on a static property will leak that user into the next request on the same worker if it's not explicitly cleared. Even PHP superglobals like $_SERVER or $_GET, which some event-driven frameworks populate manually per incoming request inside the handler, must be reset every time -- forgetting to do so lets one request's query string bleed into the next.
The fix is always the same shape: treat per-request data as something that must be constructed fresh for every request (often via a small dependency-injection container built inside the request handler) and never stored on anything with a lifetime longer than the request it belongs to. Long-running process managers for Swoole/ReactPHP/RoadRunner also commonly support periodically recycling (restarting) workers as a safety net against slow memory leaks or state that's hard to fully audit for correctness.
• Static properties, globals, and long-lived singletons now persist for the life of the WORKER, not the life of a request. • An ORM identity map / unit-of-work reused as a singleton can leak stale or cross-user entities between requests. • A 'current user' cached on a static property is a classic leak -- it silently answers the wrong user's request. • Superglobals populated manually per request (common in Swoole handlers) must be explicitly reset every time. • Fix: build request-scoped state fresh per request; recycle/restart workers periodically as a safety net, not a substitute for correctness.
A Minimal Event Loop, By Hand (stream_select)
stream_select() is PHP's userland equivalent of select()/epoll -- ReactPHP/Amp wrap a faster OS-level primitive, but the concept is identical. This is exactly why blocking calls are dangerous inside a real event loop: any function that waits on I/O directly, instead of registering a callback and returning, stalls this one loop for every connection sharing it.
<?php
// What an event loop actually does under the hood, stripped to its core:
// wait until a socket is ready, then run its callback -- repeat forever.
$sockets = [$socketA, $socketB]; // assume already-created, non-blocking streams
$read = $sockets;
$write = $except = [];
// Blocks only until AT LEAST ONE socket has data ready (or the 5s timeout).
$ready = stream_select($read, $write, $except, 5);
if ($ready > 0) {
foreach ($read as $socket) {
$data = fread($socket, 8192);
// dispatch $data to whichever callback registered interest in $socket
}
}
// A real event loop wraps this pattern in `while (true) { ... }` plus a
// registry mapping each stream resource to the callback that cares about it.Code Examples
One PHP process starts once and serves every incoming connection through the same event loop. The handler closure above is fast and does no blocking I/O, so it's safe as written -- but a call to a blocking database client or file_get_contents() inside that closure would stall every other connection this worker is currently handling, not just the one that made the call.
Common mistake: Calling a traditional blocking function (file_get_contents, a synchronous PDO query, sleep()) inside a ReactPHP handler -- it freezes the entire server for every connected client until that one call returns.
start() runs the fiber until it either finishes or calls Fiber::suspend(), at which point control returns to whoever called start()/resume() along with the value passed to suspend(). resume() later hands a value back INTO the fiber at the exact point it paused, continuing execution from there. This suspend/resume pair -- not any built-in scheduling -- is the entire primitive; a scheduler like ReactPHP's loop or Amp's runtime decides *when* to call resume(). Expected result: Fiber started Main code sees: paused -- waiting for a value Resumed with: here is the value you were waiting for async() starts the given closure on its own Fiber immediately and returns a Future representing its eventual result. Future\await() suspends the CALLING fiber until every given Future resolves, letting several independent operations run concurrently and be joined back together -- this is Amp's Fiber-based replacement for the older promise-chain (->then()) style of async PHP.
Compare this to the ReactPHP example: the handler code here reads like ordinary synchronous PHP, with no explicit ->then()/await, yet it achieves the same non-blocking concurrency because Swoole's coroutine runtime hooks the underlying I/O calls and suspends/resumes coroutines automatically -- coroutine style versus ReactPHP/Amp's explicit async style are two different ergonomics over the same underlying event-loop mechanism.
In classic PHP-FPM, the BAD version would be harmless -- the static property resets because the whole process dies after each request. In a long-running Swoole/ReactPHP worker, that static property is shared by every request the worker ever serves, so one user's authenticated data can silently leak into another user's response. The GOOD version uses only a local variable scoped to the closure invocation, so nothing survives past the request it belongs to.
Common mistake: Treating static/global state as implicitly 'per request' out of habit from PHP-FPM development -- in a long-running worker it persists and can leak across unrelated requests, including between different users.
<?php
require 'vendor/autoload.php';
use React\EventLoop\Loop;
use React\Http\HttpServer;
use React\Http\Message\Response;
use React\Socket\SocketServer;
$server = new HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) {
return new Response(
200,
['Content-Type' => 'text/plain'],
"Hello, World!\n"
);
});
$socket = new SocketServer('0.0.0.0:8080');
$server->listen($socket);
echo "Server running at http://0.0.0.0:8080\n";
Loop::run();<?php
$fiber = new Fiber(function (): void {
echo "Fiber started\n";
$value = Fiber::suspend('paused -- waiting for a value');
echo "Resumed with: {$value}\n";
});
$suspendedWith = $fiber->start();
echo "Main code sees: {$suspendedWith}\n";
// ... imagine an event loop deciding NOW is the right time to resume it ...
$fiber->resume('here is the value you were waiting for');<?php
require 'vendor/autoload.php';
use function Amp\async;
use function Amp\delay;
use Amp\Future;
$future1 = async(function (): string {
delay(1); // non-blocking -- yields to the scheduler, doesn't freeze the process
return 'first';
});
$future2 = async(function (): string {
delay(1);
return 'second';
});
// Both delay(1) calls run concurrently on Fibers under the hood,
// so the total wait is roughly 1 second, not 2.
[$a, $b] = Future\await([$future1, $future2]);
echo "{$a}, {$b}\n";<?php
use Swoole\Http\Server;
use Swoole\Http\Request;
use Swoole\Http\Response;
$server = new Server('0.0.0.0', 9501);
$server->on('request', function (Request $request, Response $response) {
// Runs inside its own lightweight coroutine. A call that LOOKS blocking
// (a hooked MySQL/Redis client, Swoole\Coroutine\System::sleep()) is
// transparently suspended and resumed by Swoole's scheduler, so it
// never actually freezes the worker the way a raw sleep() would.
\Swoole\Coroutine\System::sleep(0.001);
$response->header('Content-Type', 'application/json');
$response->end(json_encode(['hello' => 'world']));
});
$server->start();<?php
// -- BAD: a static property survives for the life of the WORKER PROCESS,
// not the life of a single request, in any event-driven server. --
final class CurrentUser
{
public static ?array $user = null; // DANGER: set once, read by every later request
}
$server->on('request', function ($request, $response) {
// If this line only runs on SOME requests, users who skip it will
// see whatever the LAST authenticated user's data was left here.
CurrentUser::$user = authenticate($request);
$response->end(json_encode(CurrentUser::$user));
});
// -- GOOD: build per-request state fresh, scoped to this one request only. --
$server->on('request', function ($request, $response) {
$user = authenticate($request); // a plain local variable, not a static property
$response->end(json_encode($user));
});Per-Request Processes vs. One Long-Running Event Loop
The failure mode is specific to the bottom diagram: anything written to shared memory by 'Request 1' is still sitting there, visible, when 'Request 2' runs on the same worker.
Traditional PHP-FPM (shared-nothing)
Request 1 --> [ fresh process ] --> Response --> process dies, state gone
Request 2 --> [ fresh process ] --> Response --> process dies, state gone
Event-Driven PHP (ReactPHP / Amp / Swoole)
Request 1 --\
Request 2 ---> [ ONE process, ONE event loop, running for hours/days ] --> Responses
Request 3 --/
^
all of these share the SAME memory space --
the SAME globals, statics, and singletonsImportant Rules
Never store per-request data (current user, request ID, cart contents) on a static property, global variable, or long-lived singleton in an event-driven worker -- construct it fresh per request instead.
A blocking call inside an event-loop callback stalls every OTHER concurrent connection sharing that loop/worker, not just the one that made the call -- always use the non-blocking/hooked equivalent.
A Fiber only suspends and resumes a call stack; something else (a loop, a coroutine scheduler) has to decide when to actually call resume() -- Fibers are a building block, not a scheduler.
Periodically recycle (restart) long-running workers as a defense-in-depth measure against slow memory leaks or state you failed to audit -- it is a safety net, never a substitute for scoping state correctly.
Where this shows up
Laravel Octane runs an entire Laravel application inside a long-running Swoole, RoadRunner, or FrankenPHP worker for throughput, and its documentation explicitly warns developers to avoid static state and singletons that accumulate request-specific data for exactly the reason covered here. High-throughput APIs, WebSocket/chat backends, and queue workers built on ReactPHP or Amp face the identical concern any time a container, cache, or 'current request' helper is wired up as a long-lived singleton instead of being rebuilt per request.
Assuming a static property or singleton resets between requests in an event-driven (ReactPHP/Amp/Swoole) worker, the way it always did under PHP-FPM.
Explicitly scope per-request data to local variables or a container instance created fresh inside the request handler, and never write user-specific data to anything with a longer lifetime than one request.
PHP-FPM's shared-nothing model made this mistake harmless for years, which is exactly why it's an easy habit to carry into event-driven PHP by accident -- the bug only appears once a worker starts serving more than one request, and it can silently leak one user's data into another user's response.
[multiple-choice] What is the fundamental difference between PHP-FPM's request model and an event-driven server like ReactPHP or Swoole? A. Event-driven PHP is just PHP-FPM with a faster network stack B. PHP-FPM gives each request a fresh process/context that's torn down afterward; event-driven PHP serves many requests from one long-running process sharing memory across them C. Event-driven PHP cannot handle HTTP at all, only WebSockets D. There is no meaningful difference for application code
B. PHP-FPM gives each request a fresh process/context that's torn down afterward; event-driven PHP serves many requests from one long-running process sharing memory across them — PHP-FPM's shared-nothing model discards all state after each request; event-driven servers keep one process alive across many requests, which is both the source of their efficiency and the source of the state-leak risk this lesson covers.
[multiple-choice] Why does a single blocking call inside an event-loop callback matter more than it would under PHP-FPM? A. It doesn't -- blocking calls behave identically in both models B. It only affects that one request, same as under PHP-FPM C. It stalls the single thread running the shared event loop, freezing every other concurrent connection on that worker too D. PHP automatically converts blocking calls to non-blocking ones in an event loop
C. It stalls the single thread running the shared event loop, freezing every other concurrent connection on that worker too — An event loop achieves concurrency by never blocking; one blocking call inside a callback occupies the only thread servicing that loop, so every other connection sharing the worker stalls until it returns -- unlike PHP-FPM, where a slow request only slows itself down.
[multiple-choice] What does PHP's Fiber class actually provide? A. A full async I/O scheduler and event loop built into the language B. A pausable/resumable call stack (suspend/resume) -- the scheduling of WHEN to resume it is left to a library like ReactPHP, Amp, or Swoole C. Automatic parallelism across multiple CPU cores D. A replacement for PHP-FPM that requires no other libraries
B. A pausable/resumable call stack (suspend/resume) -- the scheduling of WHEN to resume it is left to a library like ReactPHP, Amp, or Swoole — Fiber is a low-level suspend/resume primitive with no built-in I/O awareness or scheduling; async libraries build coroutine-style APIs (async()/await(), Loop::run()) on top of it.
[multiple-choice] How does Swoole's coroutine model differ in ergonomics from ReactPHP/Amp's async style? A. Swoole requires every I/O call to be rewritten with explicit ->then()/await syntax, while ReactPHP does not B. Swoole's scheduler transparently suspends/resumes coroutines on hooked I/O calls, letting handler code look synchronous, whereas ReactPHP/Amp expose async operations more explicitly C. They are identical in every respect D. Swoole cannot run an HTTP server at all
B. Swoole's scheduler transparently suspends/resumes coroutines on hooked I/O calls, letting handler code look synchronous, whereas ReactPHP/Amp expose async operations more explicitly — Swoole's runtime hooks intercept common blocking calls (MySQL, Redis, sleep, etc.) and suspend/resume coroutines automatically, so the code reads like ordinary synchronous PHP, while ReactPHP and (pre-v3) Amp traditionally exposed the async nature more explicitly.
[multiple-choice] Why is caching the 'current authenticated user' on a static property dangerous in a long-running Swoole/ReactPHP worker? A. It isn't dangerous -- static properties reset automatically between requests in any PHP execution model B. It only wastes a small amount of memory, with no correctness risk C. The static property persists for the life of the worker, so it can leak one user's data into a different user's request handled by the same process D. PHP forbids static properties inside event-driven request handlers
C. The static property persists for the life of the worker, so it can leak one user's data into a different user's request handled by the same process — Unlike PHP-FPM, where the process (and therefore every static property) is discarded after each request, a static property in a long-running worker persists across every request that worker serves, creating exactly the kind of cross-user data leak described in this lesson.
A Swoole HTTP handler stores the authenticated user like this: class Auth { public static ?User $current = null; } and sets Auth::$current = $authenticatedUser; near the top of the handler, then reads Auth::$current later in the same request to render a personalized response. Explain the bug this introduces under concurrent load, and rewrite the handler to remove it.
Because Auth::$current is a single static property shared by the whole worker process, a second request's assignment can overwrite it before the first request finishes reading it (or, even without a race, it simply persists incorrectly between sequential requests if a code path forgets to set it), letting one user see another user's data. Fix: pass the authenticated user as a local variable (or a value on a per-request container/context object created inside the handler) rather than storing it on a static property at all.
Ask what happens if two requests from different users are being processed by coroutines on the SAME worker at (or near) the same time, given that Auth::$current is one shared static slot.