Async PHP that is still just PHP. phasync runs thousands of coroutines in one PHP process,
on PHP's own fibers. No promises, no ->then(), no special runtime to install: a function
that waits on the network reads top to bottom, returns a value and throws exceptions like any
other. Use it in one function of an existing PHP-FPM application, or as the engine of a
server that holds tens of thousands of connections.
// In any controller, under PHP-FPM or anywhere else: three HTTP calls at once, not one after another
[$user, $orders, $recommendations] = phasync::run(fn () => array_map(phasync::await(...), [
phasync::go(fn () => fetch("https://api.example.com/users/$id")),
phasync::go(fn () => fetch("https://api.example.com/users/$id/orders")),
phasync::go(fn () => fetch("https://api.example.com/users/$id/recommendations")),
]));The request takes as long as the slowest of the three calls, not their sum. fetch() is an
ordinary function (below); nothing else in the application changes.
- No colored functions. A coroutine is a plain closure. Code that waits looks exactly like
code that doesn't, so async stays an implementation detail of the function that needs it,
instead of spreading
Promisereturn types through your codebase. - Structured, not fire-and-forget.
phasync::run()returns only when every coroutine it started has finished. An exception in a coroutine surfaces where you wait for it. Cancellation and timeouts are exceptions thrown into the coroutine, sofinallyblocks run. - Starts small. One
phasync::run()in one function is a complete phasync program. There is no application-wide event loop to adopt first, no framework to switch to. - Built for load. One event loop per process, with waiting built on PHP streams:
stream_select()out of the box, epoll with the optional phasync extension. Waits on sockets cost no objects of their own, pools (phasync\Util\Pool) reuse database connections across coroutines, and hot paths leave nothing for the garbage collector. - Legacy code joins in. With the phasync extension loaded, the code you already have (MySQL through PDO or
mysqli, curl and Guzzle,
file_get_contents(),http://streams, DNS lookups,sleep()) waits cooperatively inside coroutines instead of blocking the process. No rewrite. - Yours to own. MIT, the extension included, and no dependencies beyond PHP, PSR interfaces and
fiber-interop/scope: small enough for you, or your coding agent, to read whole and maintain for a decade. See the Ennerd philosophy.
Under PHP-FPM or any SAPI: concurrency inside a request. FPM stays exactly as it is, one
request per process. Inside the request, phasync::run() overlaps independent work: API calls,
queries on separate connections, file and DNS work. The response goes out when the slowest part
is done, not when the sum of them is.
Under swerve: an asynchronous server, end to end. Your
PSR-15 application stays loaded, and every worker serves thousands of requests at once, each in
a Scope of its own. The same code that overlapped three API calls under FPM now also
overlaps requests, WebSocket connections and background work.
Each step is useful on its own, and none requires the next.
| Step | Add | What you get |
|---|---|---|
| 1 | phasync/phasync |
Concurrent I/O inside one request, on your existing FPM setup. Wait with phasync's APIs: phasync::readable(), CurlAwaiter, MySQLiAwaiter. |
| 2 | The extension, bundled in phasync's release packages | Libraries you did not write (MySQL through PDO or mysqli, Guzzle, curl_exec(), files, http:// streams, DNS) cooperate inside coroutines, unchanged. Epoll instead of stream_select(). |
| 3 | phasync/swerve |
A long-running PSR-15 server: the app boots once, each worker serves thousands of connections, streaming bodies, Server-Sent Events, WebSockets. Slim, mini and other PSR-15 frameworks run as they are. |
| 4 | phasync/tether |
Live server-side components over one WebSocket per tab, in the style of Blazor Server and Phoenix LiveView. PHP 8.3. |
For your own protocols, phasync/net has TCP, UDP and Unix
socket servers and clients on the same loop.
On one 56-thread server, each at its fastest worker count, a hello-world PSR-15 app on swerve served 157,000 to 285,000 requests per second from 64 to about 28,000 connections: in the same range as Go's net/http (162,000 to 317,000) and Node's http module (135,000 to 246,000), and ahead of both at 10,000 connections. A Slim app on swerve served 2 to 3 times what an Express app served on Node. Method and raw results.
composer require phasync/phasyncphasync needs PHP 8.2 or later and nothing else. It runs under PHP-FPM, Apache's mod_php, the CLI, and long-running servers.
use phasync\Services\CurlAwaiter;
function fetch(string $url): string
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
// Inside a coroutine this waits without blocking the others; outside, it just runs
return CurlAwaiter::await($ch);
}
$pages = phasync::run(function () {
$a = phasync::go(fn () => fetch('https://www.php.net/'));
$b = phasync::go(fn () => fetch('https://getcomposer.org/'));
return [phasync::await($a), phasync::await($b)];
});phasync::run($fn)runs$fnas a coroutine, and returns its result once it and every coroutine it started have finished. Called inside a coroutine, it runs$fnin that coroutine and waits only for what$fnstarted.phasync::go($fn)starts a coroutine and returns it at once;phasync::await($coroutine)waits for its result, or throws its exception.phasync::sleep($seconds)pauses only the current coroutine;phasync::sleep()lets the others run for a moment.phasync::cancel($coroutine)throws aCancelledExceptioninto it; every wait accepts a timeout and throws aTimeoutExceptionwhen it runs out. Without one, a wait waits for as long as it takes.
Every run() creates a frame (phasync\Frame): the coroutines started inside it, which it
waits for. A failure cancels nothing; once the rest have ended, run() throws the failure, or a
phasync\AggregateException of all of them when there are several. The frame can be cancelled,
asked to stop without an exception (requestCancellation(), read by isCancellationRequested()),
given a deadline, or given a handler for the failures nobody awaited:
phasync::run(function () {
phasync::frame()->setTimeout(2.0); // run() throws TimeoutException after 2 seconds
$a = phasync::go(fn () => fetch('https://www.php.net/'));
$b = phasync::go(fn () => fetch('https://getcomposer.org/'));
return [phasync::await($a), phasync::await($b)];
});Which request or job the code belongs to is a separate thing: the ambient \Scope from
fiber-interop/scope. A coroutine keeps
the scope it was started in across every wait, so per-request state lives in a WeakMap keyed
by Scope::getCurrent(), and phasync::onScopeChange() swaps global state as the loop moves
between requests.
Without the extension, a coroutine waits on a stream by asking phasync first:
stream_set_blocking($socket, false);
$data = fread(phasync::readable($socket), 65536); // waits until there is something to read
$wrote = fwrite(phasync::writable($socket), $response); // waits until the socket takes moreBoth work outside coroutines too, so a library written this way runs in any PHP program. One coroutine at a time may wait to read a given stream, and one to write to it.
For HTTP, phasync\Services\CurlAwaiter::await($ch) runs a curl handle cooperatively. For MySQL,
phasync\Services\MySQLiAwaiter does the same for mysqli's asynchronous queries.
The phasync extension is an optional PHP extension. Inside
phasync::run(), blocking I/O in code that knows nothing about phasync suspends the coroutine
instead of the process, and returns exactly what PHP would have returned, timeouts and warnings
included. That covers sockets and TLS, MySQL through mysqli or PDO, curl and Guzzle, pipes
and child processes, sleep(), DNS lookups, files and filesystem calls, PostgreSQL and SQLite,
and more. Clients with their own network code, such as phpredis, still block.
There is nothing extra to install: release packages of phasync/phasync carry prebuilt binaries
under ext/ (PHP 8.3 to 8.5; Linux x86-64 and ARM64 with glibc or musl, macOS). Load one:
- From the command line:
vendor/bin/phasync script.phpinstead ofphp script.php. It takes the same arguments asphp, and PHP processes the script starts load the extension too. - In a CLI script, as the first line:
phasync\ext\ensure_loaded();restarts the process once with the extension. - For every SAPI, PHP-FPM included:
pie install phasync/ext-phasyncbuilds it and enables it inphp.ini. phasync then uses that one, andvendor/bin/phasyncandensure_loaded()leave it be.
The extension is open source under the MIT licence, and
versioned with phasync: phasync 2.0.7 needs extension 2.0.7 or a later 2.0.x, and throws a
RuntimeException that says what to update when another version is loaded. It bundles
ext-scope for the Scope class, and uses an ext-scope
or a PHP that already has Scope instead.
The extension waits with select() and switches to epoll at 4096 watched descriptors, so a
process can watch any number of sockets: without it, PHP's stream_select()
cannot use file descriptors numbered 1024 and up.
phasync behaves the same with and without the extension. The extension only lets more code wait cooperatively and makes waiting cheaper.
Channels pass values between coroutines. A read waits for a writer, and a write into a full channel waits for a reader:
phasync::run(function () {
phasync::channel($reader, $writer, 10);
phasync::go(function () use ($writer) {
foreach (['a.txt', 'b.txt', 'c.txt'] as $file) {
$writer->write($file);
}
$writer->close();
});
foreach ($reader as $file) { // ends when the writer closes
echo "processing $file\n";
}
});WaitGroup waits for a set of coroutines to finish:
use phasync\Util\WaitGroup;
phasync::run(function () {
$group = new WaitGroup();
foreach (range(1, 5) as $i) {
$group->add();
phasync::go(function () use ($group, $i) {
try {
phasync::sleep(0.1 * $i);
} finally {
$group->done();
}
});
}
$group->await();
});Publishers deliver every message to every subscriber, in order:
phasync::run(function () {
phasync::publisher($subscribers, $publisher);
foreach (range(1, 3) as $i) {
$subscription = $subscribers->subscribe();
phasync::go(function () use ($subscription, $i) {
foreach ($subscription as $event) {
echo "subscriber $i got $event\n";
}
});
}
$publisher->write('deployed');
$publisher->close();
});Pool lends interchangeable resources, such as database connections, to one coroutine at a time, up to a limit:
use phasync\Util\Pool;
$db = new Pool(fn () => new PDO($dsn, $user, $password), 10);
$rows = $db->use(fn (PDO $pdo) => $pdo->query('SELECT 1')->fetchAll());For heavy instances, window shrinks the pool to the most instances that were lent at once within
that many seconds, and dispose closes what the pool lets go of. The check runs when the pool is
borrowed from or released to, not on a timer:
$apps = new Pool(fn () => bootApplication(), 8, window: 30, dispose: fn ($app) => $app->flush());Also in phasync\Util: RateLimiter, Synchronized (a lock per coroutine) and StringBuffer
(a fast byte buffer for protocol parsers).
- INTRO:
phasync::run()andphasync::go() - Using phasync in existing projects
- Asynchronous I/O
- Concurrent HTTP requests with CurlAwaiter
- WaitGroup Β· RateLimiter
- Write a basic web server
- API reference
- Semantics: how each part behaves, exactly
- Swoole and OpenSwoole are PHP extensions with their own server and runtime model; many of their features require it. phasync is a Composer library on standard PHP; the extension is optional, and an application written for phasync runs the same with or without it.
- ReactPHP and AMPHP are async-first ecosystems: code that waits uses their APIs (ReactPHP's promises; AMPHP's futures, which read sequentially on fibers) and their own libraries, such as an async HTTP client or MySQL driver in place of curl or PDO. phasync is closer to Go's model: plain functions, and with the extension the libraries you already use wait cooperatively.
Upgrading from 1.x: UPGRADING.md. docs/SEMANTICS.md describes
how every part is meant to behave, and the tests in tests/Characterization pin how it behaves
today. Tests, documentation and bug reports are welcome.
phasync and its extension are open-sourced software licensed under the MIT license.
