A PHP application server for connections that stay open.
<?php // swerve.php
while (Swerve\Swerve::accept('public/', function () {
echo "Hello, World\n";
})) {
}Swerve is a PHP application server, built on phasync
coroutines. A master process accepts the connections and hands each request to a worker; the
worker runs your swerve.php, which takes requests with Swerve::accept(). A request is PHP
as you know it: $_GET, $_POST, $_SESSION, header(), echo, php://input, each its own,
while the worker serves other requests at once. Your swerve.php chooses how: every request as it
comes, one at a time with the application kept loaded, or one per run, starting over clean as
under php-fpm.
Swerve is at 0.x: options and APIs may still change before 1.0.
Documentation · Examples: a chat room over Server-Sent Events and one over WebSockets.
- Connections that stay open. Server-Sent Events, WebSockets and long polling from an ordinary
request, raw connections after a
101, and publish/subscribe between workers, so live features need no separate Node or Go service. - Waiting is free. While one request waits for the database or an API, the same worker serves others. Your code stays ordinary sequential PHP: no promises, no callbacks. ext-phasync makes the blocking calls of libraries you did not write (PDO, mysqli, curl, file and DNS functions) wait as a coroutine.
- Isolated requests. Each request runs in a
Scopeof its own, with its own superglobals, headers and output, which the coroutines it starts share. - Boot once, or start clean. A
swerve.phpthat loops keeps the framework, routes and connection pools between requests; one that accepts once per run gives each request a fresh process state, as php-fpm does, and still shares opcache and APCu with every worker. - Supervised. A worker that dies is replaced. A reload starts new workers before the old ones stop, and the master's listener stays open throughout: no connection is refused.
- Yours to own. MIT, with no third-party dependencies beyond phasync and the PSR interfaces. See the Ennerd philosophy.
Requirements: PHP 8.2 or later on Linux, with the pcntl, posix and sockets extensions, and apcu for
the shared cache, publish/subscribe and the container.
ext-phasync ships inside phasync, and swerve loads it by itself.
composer require phasync/swerve:^0.2@RC # 0.2 is a release candidate; without @RC Composer installs 0.1
vendor/bin/swerve -S public # serve a directory as php -S does: WordPress, Laravel's public/
vendor/bin/swerve # run ./swerve.php
vendor/bin/swerve --watch # during development: reload when a PHP file changesswerve.php decides how many requests a worker serves at once:
// every request as it comes, concurrently, with the application kept loaded
while (Swerve::accept('public/', $app->handle(...))) {
}
// one at a time, keeping the application between requests
while ($request = Swerve::accept('public/', $app->handle(...))) {
phasync::await($request);
}
// one request per run of swerve.php, which starts over clean for the next (as under php-fpm)
phasync::await(Swerve::accept('wordpress/', 'wordpress/index.php'));The first argument is a directory of static files, answered without the application. The
handler is a script's path (run as a fresh main script, its PHP files too), a closure without
parameters (superglobals in, header() and echo out), or a closure taking a PSR-7
ServerRequestInterface and returning a ResponseInterface. Getting started
goes through it step by step.
- Stay open.
Swerve::upgrade()takes the connection off the request for a handler that keeps it as long as it likes, in the request: aSwerve\WebSocket, aSwerve\ServerSentEventsstream, or the rawSwerve\ClientRequest. Aswerve.phpthat loops serves other requests meanwhile; given a bootstrap file, a WebSocket moves to WebSocket workers that loaded it. A PSR-7 handler's101response stays open too. See Realtime. - Finish early. The response is complete at its
Content-Length, or atSwerve::finishRequest(), whichfastcgi_finish_request()calls: the connection takes its next request while the request's code runs on. - Wait cheaply.
phasync::sleep(),phasync::go()and every phasync primitive work in a request; the worker serves others while it waits.
Swerve::publish('chat', $message);
foreach (Swerve::subscribe('chat') as $message) {
// every message published to 'chat', in any worker, from the moment subscribe() returned
}Every message of every publisher has its place in one order, in a log the workers share in APCu.
Delivery is at most once, to the subscriptions that exist when it is published; a subscriber too
far behind gets a SubscriberLagException. Publish and subscribe
has the rest.
Swerve::cache() is a PSR-16 cache every worker shares, held in APCu.
$cache = Swerve::cache();
$user = $cache->get("user.$id") ?? load_user($id);
$cache->set("user.$id", $user, 60);A get, set or delete is one APCu call: a write is seen by every worker at once. Values last as
long as the master; a reload keeps them. It shares apc.shm_size (256M by default, taking memory
only as it fills) with publish/subscribe, and when that is full APCu empties itself.
Swerve::container() is a PSR-11 container that caps how many instances of a service exist at
once in all workers together: MySQL accepts 151 connections by default, however many workers
there are.
// swerve.php, before accept(): every worker adds the same services
Swerve::container()->add(\PDO::class, fn () => new \PDO($dsn, $user, $password), limit: 151);
$db = Swerve::container()->get(\PDO::class); // this request's; waits while 151 are out
$db = Swerve::container()->tryGet(\PDO::class, $ok); // null, $ok false, instead of waitingA Lifetime::Scoped instance (the default) is shared by a request and its coroutines and given
back when the request has ended; Transient is one per get(); Singleton one per worker. An
instance holds its slot until it is garbage, and a worker that dies gives its slots back.
A job that must not run twice takes a lock with flock() on a file of its own; the kernel
releases it when the handle closes or the worker dies.
$lock = fopen(sys_get_temp_dir() . '/myapp-nightly-report.lock', 'c');
if (flock($lock, LOCK_EX | LOCK_NB)) {
work();
}Superglobals, headers, output and the session are each request's own. Process-wide PHP state is
not: setlocale(), a static of your own. A phasync::onScopeChange() hook keeps such state as
each request's, so application code can go on using the plain functions:
$locales = new WeakMap(); // per Scope
phasync::onScopeChange(function (Scope $new, ?Scope $old) use ($locales) {
if (null !== $old) {
$locales[$old] = setlocale(LC_ALL, 0);
}
setlocale(LC_ALL, $locales[$new] ?? 'C');
});Register it once, before accept(). It runs before a coroutine of another scope than the last one
runs: never between a request's own coroutines. It runs in the event loop and must not wait.
| Signal to the master | |
|---|---|
SIGTERM, SIGINT (Ctrl+C) |
Stop: the listener closes, the workers finish the requests in flight, then their WebSockets and event streams get a WorkerStoppingException (a WebSocket closes with 1001). What still runs after --grace (10 s) is killed. A second signal kills at once. |
SIGHUP |
Reload: each slot starts a worker on the code as it is now, and its old worker stops as above once the new one asks for its first request (a WebSocket closes with 1012: reconnect). A new worker that fails to start leaves the old one serving. |
- The master never loads your application. Workers are forked from it, so they share its opcache and APCu memory.
- swerve runs PHP as a long-running server:
memory_limitis a quarter of the machine's memory (at most 4 GiB) per worker,max_execution_timeis off, and opcache never checks timestamps: changed code arrives with a reload.php -d name=value vendor/bin/swervesets anything else. - It runs in the foreground, as systemd, Docker and supervisord expect, and logs to stderr (or
--log=<file>): notices and up, such as reloads and workers that exit.
Command line has every option, and Production sizing, systemd, Docker, nginx and TLS.
