Event Loop Internals
debt(d7/e5/b5/t9)
Closest to 'only careful code review or runtime testing' (d7). The detection_hints list PHPStan but mark automated as 'no', meaning the tool does not automatically flag blocking calls inside async code. Identifying sleep(), file_get_contents(), or synchronous DB calls inside ReactPHP/Amp loops requires careful code review or observing the symptom (entire loop stalling) at runtime. No linter rule fires automatically, placing this firmly at d7.
Closest to 'touches multiple files / significant refactor in one component' (e5). The quick_fix acknowledges this is not a one-line swap: every blocking I/O call (synchronous DB queries, file reads, sleep) must be replaced with async equivalents throughout the affected component or service. This commonly touches multiple files within a queue-worker or CLI context, but is not a full cross-cutting architectural rework.
Closest to 'persistent productivity tax' (b5). The term applies only to cli and queue-worker contexts (not all PHP contexts), so its reach is narrower than a system-wide concern. However, within async PHP codebases, the single-threaded event loop model shapes every I/O decision — developers must continuously reason about what blocks and what yields — imposing a persistent productivity tax on all work in that context.
Closest to 'catastrophic trap — the obvious way is always wrong' (t9). The misconception is explicitly stated: developers believe the event loop makes things run simultaneously (parallel), but it is a single thread processing one callback at a time. This directly contradicts intuition built from multi-threaded or OS-level concurrency models. The 'obvious' action — calling any synchronous I/O — silently stalls all other pending work, matching the t9 anchor exactly.
Also Known As
TL;DR
Explanation
The event loop monitors file descriptors (sockets, pipes) via OS primitives (epoll, kqueue, select). When an I/O operation completes, the loop places the callback on the task queue. The loop processes the task queue — running callbacks until empty — then checks for new I/O events. Node.js uses libuv; PHP's ReactPHP and Revolt use the same OS primitives. PHP Fibers integrate with event loops by suspending execution when awaiting I/O and resuming when the loop signals completion.
Diagram
flowchart TD
CALL[Call Stack] -->|empty?| CHECK{Event Loop
Check}
CHECK -->|Macro tasks| MAC[setTimeout<br/>setInterval<br/>I/O callbacks]
CHECK -->|Micro tasks| MIC[Promise.then<br/>await resolution]
MIC -->|runs first| CALL
MAC -->|runs after microtasks| CALL
IO[I/O ops<br/>fetch, fs, db] -->|non-blocking| KRNL[OS Kernel]
KRNL -->|complete| MAC
style MICRO fill:#238636,color:#fff
style MACRO fill:#1f6feb,color:#fff
style CALL fill:#6e40c9,color:#fff
Common Misconception
Why It Matters
Common Mistakes
- Long synchronous operations in the event loop — CPU-bound work blocks all other pending callbacks.
- Not understanding microtask vs macrotask queues — Promise callbacks (microtasks) run before setTimeout (macrotasks).
- Blocking system calls inside async code — file_get_contents() blocks the entire event loop; use async I/O.
- Not releasing control back to the event loop in generators — yield must be used to allow other tasks to run.
Code Examples
// Blocking the event loop:
Revolut\EventLoop\run(function(): void {
$data = file_get_contents('https://api.example.com/data'); // BLOCKS!
// While waiting (500ms), NO other async tasks can run
// Even simple timers and heartbeats stall
echo $data;
});
// Non-blocking — event loop runs other tasks while waiting:
use Amp\Http\Client\HttpClientBuilder;
use function Amp\async;
AmplEventLoop\run(function(): void {
$client = HttpClientBuilder::buildDefault();
// Multiple concurrent HTTP requests — loop handles all:
$futures = array_map(
fn($url) => async(fn() => $client->request(new Request($url))),
$urls
);
$responses = Amp\Future\awaitAll($futures); // Concurrent, non-blocking
});