Skip to main content
ArticlesProjectsUsesNowWork HistoryAbout
Technical Article

Fibers Plus the Polling API: What Async PHP Actually Looks Like Now

Published: 1 July 2026·12 min read·Category: PHP

I wrote about the Polling API RFC a few weeks back and framed it as underrated, mostly because the coverage kept missing that the real motivation was the internal php_poll.h API rather than the userspace Io\Poll classes. I stand by that. But I got a fair bit of feedback along the lines of “fine, but what do I actually build with it?” That is a reasonable question, and it is the one I want to answer here.

The timing is good for it too. The RFC passed 33 to 1 with four abstentions and closed on 3 June, and the implementation is already merged to master. Alpha 1 is scheduled for 2 July, feature freeze and Beta 1 land in mid-August, and GA is pencilled in for 19 November. Which means the Polling API has gone from RFC text to something you can compile off master and poke at right now, ahead of the formal alpha tag. So that is what I did.

At the same time, there is a genuinely live argument running through the PHP community right now about which async approach to actually use in production: Fibers with a userland event loop, ReactPHP, Swoole, or Amp v3 on Revolt. Nobody has tied that debate cleanly to what the Polling API changes. I want to do that properly, with real code, not just vibes.

Fibers are the concurrency model, the Polling API is the missing primitive

These two things get conflated constantly, so let me separate them cleanly before we build anything.

A Fiber is PHP’s cooperative concurrency primitive, in core since 8.1. It gives you a function that can pause itself with Fiber::suspend() and be resumed later with $fiber->resume(), keeping its own stack and local state intact across the pause. That is the entire feature. It does not know anything about sockets, timers, or I/O. It is a control-flow primitive, nothing more.

An event loop is the thing that decides when to resume a suspended fiber. Historically in PHP, that meant stream_select() under the hood, or reaching for a PECL extension like ext-uv or ext-event to get native epoll or kqueue. ReactPHP ships four separate loop implementations, StreamSelectLoop, ExtUvLoop, ExtEventLoop, ExtEvLoop, precisely because there was no single reliable native primitive to build on. AMPHP built its own equivalent story through Revolt.

The Polling API is that missing primitive. It does not replace Fibers, and it is not an event loop itself. It is the one thing underneath an event loop that PHP never had natively: a fast way to ask the operating system “which of these file descriptors are ready” without maintaining four separate backend implementations to get there.

Put together, Fibers give you pausable functions and the Polling API gives you an efficient way to know when to resume them. That is the whole story. Everything else, timers, cancellation, backpressure, is stuff you or a library builds on top.

Building the smallest possible scheduler

The best way to understand what Amp v3 and ReactPHP are actually doing internally is to build a toy version yourself. So let us do that: a scheduler that runs several fibers concurrently, each one fetching a URL over a raw non-blocking socket, resumed by a single Io\Poll\Context.

<?php
declare(strict_types=1);
use Io\Poll\{Context, Event};
final class MiniScheduler
{
private Context $poll;
/** @var array<int, Fiber> */
private array $fibers = [];
public function __construct()
{
$this->poll = new Context();
}
public function spawn(callable $task): void
{
$fiber = new Fiber($task);
$fiber->start();
if ($fiber->isTerminated()) {
return;
}
$this->fibers[spl_object_id($fiber)] = $fiber;
}
public function run(): void
{
while ($this->fibers !== []) {
foreach ($this->poll->wait(timeoutSeconds: 1) as $watcher) {
$fiber = $watcher->getData()['fiber'];
$fiber->resume($watcher);
if ($fiber->isTerminated()) {
unset($this->fibers[spl_object_id($fiber)]);
}
}
}
}
public function awaitReadable($stream): void
{
$fiber = Fiber::getCurrent();
$handle = new StreamPollHandle($stream);
$watcher = $this->poll->add($handle, [Event::Read], ['fiber' => $fiber]);
Fiber::suspend();
$watcher->remove();
}
public function awaitWritable($stream): void
{
$fiber = Fiber::getCurrent();
$handle = new StreamPollHandle($stream);
$watcher = $this->poll->add($handle, [Event::Write], ['fiber' => $fiber]);
Fiber::suspend();
$watcher->remove();
}
}

That is genuinely most of it. spawn() starts a fiber, which runs until it hits Fiber::suspend(). run() calls wait() on the poll context, which blocks until a watched stream is actually readable, then resumes exactly the fiber waiting on it. No polling in a tight loop, no scanning every stream on every tick. The operating system tells us the moment something is ready, and we hand control straight back to the fiber that cares.

Now the actual task, a non-blocking fetch over a raw socket. This needed one correction from my first draft of this: writing to a non-blocking stream can short-write, so the request has to be written in a loop that awaits writability whenever the kernel send buffer pushes back, rather than trusting a single fwrite() call to send the whole thing:

<?php
declare(strict_types=1);
function writeAll(MiniScheduler $scheduler, $stream, string $data): void
{
while ($data !== '') {
$written = fwrite($stream, $data);
if ($written === false) {
throw new RuntimeException('Write failed');
}
if ($written === 0) {
$scheduler->awaitWritable($stream);
continue;
}
$data = substr($data, $written);
}
}
function fetch(MiniScheduler $scheduler, string $host, string $path): string
{
$stream = stream_socket_client("tcp://{$host}:80", $errno, $errstr, 30);
if ($stream === false) {
throw new RuntimeException("Connect to {$host} failed: {$errstr}");
}
stream_set_blocking($stream, false);
writeAll($scheduler, $stream, "GET {$path} HTTP/1.1\r\nHost: {$host}\r\nConnection: close\r\n\r\n");
$response = '';
while (!feof($stream)) {
$scheduler->awaitReadable($stream);
$response .= fread($stream, 8192);
}
fclose($stream);
return $response;
}

On termination: the loop relies on feof() flipping true after a read that hits end of stream, which is the same pattern the RFC’s own TCP client example uses. It holds here for a reason worth being explicit about: awaitReadable() only requests Event::Read, but Event::Error and Event::HangUp are automatically monitored by every backend regardless of what you asked for, so wait() still returns the watcher when the server closes the connection. The code does not branch on hasTriggered() before calling fread(), it just calls it unconditionally once woken, and fread() on a closed non-blocking stream returns an empty string and sets the EOF flag rather than blocking or erroring. That is what makes the final iteration exit cleanly instead of hanging on the last descriptor. If you want to be stricter about it, checking hasTriggered(Event::Read) before reading and handling Event::HangUp separately is the more defensive version, I just did not want to bury the core loop under branches that do not change the outcome for a plain HTTP GET.

Wire five of these into a scheduler and run them concurrently:

<?php
declare(strict_types=1);
$scheduler = new MiniScheduler();
$hosts = [
'example.com',
'httpbin.org',
'jsonplaceholder.typicode.com',
];
foreach ($hosts as $host) {
$scheduler->spawn(function () use ($scheduler, $host) {
$body = fetch($scheduler, $host, '/');
echo "{$host}: " . strlen($body) . " bytes\n";
});
}
$scheduler->run();

Three fibers, each blocked on its own socket, all resumed independently by one Io\Poll\Context. This is, structurally, what Revolt’s driver does and what ReactPHP’s loop does. We just wrote the smallest version that still works.

What I am not showing you, on purpose

This scheduler has no timers, no cancellation tokens, no error propagation from a fiber back to the caller, no protection against a fiber that never suspends and blocks everyone else, and no DNS handling beyond whatever stream_socket_client() gives you for free. That is not an oversight. It is the point.

Amp v3 and ReactPHP exist because building a correct, safe version of this is genuinely hard, and getting cancellation and backpressure right across hundreds of concurrent fibers is not a weekend project. Do not take this scheduler anywhere near production. Take the understanding it gives you into whichever library you actually reach for.

Benchmarking it, honestly

I want to be upfront about the limits here before I give you numbers. PHP 8.6 is still pre-alpha as I write this, self-compiled off master, not a packaged build you can apt install. There is no stable release, no distro packages, and the Io\Poll implementation can still change before GA. Treat what follows as directional, not a production SLA.

I ran the three-host fetch above four ways: sequential blocking file_get_contents() calls, the mini scheduler above on the 8.6 alpha build, ReactPHP’s StreamSelectLoop on 8.4, and Amp v3 on Revolt on 8.4. Same three hosts, same simple GET request, ten runs each, median taken.

Sequential blocking came in around the sum of all three round trips, no surprise there, since each request waits for the last to finish before starting. The mini scheduler and ReactPHP’s StreamSelectLoop landed close to each other, both roughly bounded by the slowest single request rather than the sum, which is exactly what you want from concurrent I/O. Amp v3 on Revolt was marginally ahead of both, which tracks, since Revolt has years of tuning behind it that my forty-line scheduler does not.

The interesting result was not the timing, it was the CPU behaviour. stream_select() based loops do measurably more userspace work rescanning descriptor sets as the number of watched streams grows. The Io\Poll\Context version stayed flat. Three streams versus thirty streams cost roughly the same per wait() call, because epoll does not rescan the whole set, it just tells you what is ready. That is the actual payoff, and it will not show up clearly until someone benchmarks this at real concurrency, hundreds or thousands of streams, not three.

One honest gap in this comparison: ReactPHP and Amp v3 ran on 8.4, the mini scheduler ran on 8.6 pre-alpha, so what I measured is loop implementation and PHP version changing together, not the loop in isolation. A same-version comparison, ReactPHP and Amp v3 both running against a Polling API backend on the same 8.6 build, would isolate the loop’s contribution properly. That is not possible yet because neither project has shipped an Io\Poll driver, which is rather the point of this whole piece. Take the CPU behaviour as the more reliable signal here, since that is architectural and does not depend on which PHP build ran it, and take the wall-clock numbers as a rough sanity check rather than a verdict.

Where this leaves Fibers versus ReactPHP versus Swoole versus Amp v3

This is the argument I said I would tie together, so here is where I land on it.

Swoole is not really part of this comparison, and treating it as one option among four is where a lot of the online debate goes wrong. Swoole is a different PHP runtime. It replaces how your application boots, patches core functions to be non-blocking transparently, and gives you a production HTTP server with worker processes built in. You do not add Swoole to an app, you build the app for Swoole from the start. It is genuinely excellent for the right workload, and the C-level coroutine switching gives it a real edge over userland Fibers when you need every millisecond and have the ops discipline to manage a compiled extension in your Docker images. The Polling API changes nothing about that trade-off, since Swoole was never limited by the thing the Polling API fixes.

ReactPHP and Amp v3 are the ones actually affected here, and in the same direction. Both exist to give you an event loop on top of Fibers without asking you to rewrite your application around a different runtime. Both have spent years maintaining multiple backend implementations, StreamSelectLoop, ExtUvLoop, ExtEventLoop, ExtEvLoop for ReactPHP, an equivalent spread for Amp, purely to paper over the fact that PHP core never gave them a single reliable native polling primitive. The Polling API is that primitive, finally, and it collapses the reason those parallel implementations exist. I would expect Revolt in particular to pick this up quickly as a native backend, since Revolt already sits underneath Amp v3 as the shared low-level driver, and this is exactly the kind of internal plumbing it was built to abstract over.

None of this makes the choice between ReactPHP and Amp v3 disappear. That is still mostly a style question. Amp v3’s fiber-first API reads closer to synchronous code, ReactPHP’s promise-chaining style gives you more explicit control over the loop if you want it, and they are interoperable through revolt/event-loop-adapter-react if you genuinely need both in one process. What the Polling API does is remove the excuse for either of them to keep maintaining bespoke native backends, and it means a five dollar droplet running vanilla PHP 8.6 gets the same epoll performance that used to require a compiled PECL extension most shared hosts never had.

A quick PestPHP check on the scheduler

If you want to verify the concurrency claim rather than take my word for the timings, this is roughly how I tested it, using two servers that each sleep for a fixed period before responding, to confirm the total time tracks the slowest request rather than the sum:

<?php
declare(strict_types=1);
it('resumes concurrent fibers without serialising the wait time', function () {
$scheduler = new MiniScheduler();
$started = microtime(true);
foreach (['sleepy-one.test', 'sleepy-two.test'] as $host) {
$scheduler->spawn(function () use ($scheduler, $host) {
fetch($scheduler, $host, '/sleep?ms=200');
});
}
$scheduler->run();
$elapsed = microtime(true) - $started;
expect($elapsed)->toBeLessThan(0.35);
});

Two requests that each take roughly two hundred milliseconds finishing in well under the combined four hundred is the whole test. It is not a sophisticated assertion, but it is the one that actually matters here, and it is the one that fails immediately if you accidentally write blocking code inside a fiber and forget it.

The practical takeaway

If you are building application code today, reach for Amp v3 or ReactPHP, not a hand-rolled scheduler, and pick based on whether you want fiber-native ergonomics or explicit promise control. If you are running a workload where every millisecond and every byte of memory per connection matters and you have the operational maturity for a compiled extension, Swoole earns its complexity. If you maintain one of those libraries yourself, the Polling API is the thing to go build against right now, while it is still ahead of alpha and your feedback can actually shape what ships in November.

PHP spent a decade being told it could not do this properly. Turns out it just needed the operating system to stop being a stranger.