phasync / swerve-psr15
Run any PSR-15 request handler on swerve: Slim, Mezzio or your own, booted once per worker.
Requires
- php: ^8.2
- phasync/net: ^2.0
- phasync/phasync: ^2.0
- phasync/swerve: ^0.1
- psr/http-message: ^1.0 || ^2.0
- psr/http-server-handler: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.58
- pestphp/pest: ^3.0 || ^4.0
- slim/slim: ^4.14
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 22:01:18 UTC
README
Any PSR-15 request handler, booted once per worker. swerve
is a PHP application server: long-running workers that serve HTTP/1.1 themselves and stream request
and response bodies. This package runs a Psr\Http\Server\RequestHandlerInterface on it: Slim,
Mezzio, or your own.
composer require phasync/swerve-psr15
Create swerve.psr15.php next to composer.json. It returns the handler:
<?php // swerve.psr15.php use Slim\Factory\AppFactory; $app = AppFactory::create(); $app->get('/hello/{name}', function ($request, $response, array $args) { $response->getBody()->write("Hello, {$args['name']}"); return $response; }); return $app;
vendor/bin/swerve --http=0.0.0.0:8080
The file is required once in each worker, after vendor/autoload.php, and the handler it returns
serves every request that worker receives. Anything else than a RequestHandlerInterface stops swerve
at start with exit code 2 and says what was returned.
How requests and responses cross
- The request is a
phasync\Psr\ServerRequest. Its body is a lazy stream on the connection: nothing is read until the application reads, so a large upload costs no memory, andExpect: 100-continueis answered by that first read. swerve's--max-body(8 MiB by default) limits it. - A POST of
application/x-www-form-urlencodedormultipart/form-datais parsed as PHP parses it (includingmax_input_vars,post_max_size,upload_max_filesizeandmax_file_uploads) when the application asks forgetParsedBody()orgetUploadedFiles(). Any other body stays raw. - The response body is read in chunks and written as it is read, so a body larger than memory is fine
if its stream produces it piecemeal. It has a
Content-Lengthwhen the application set one or the stream knows its size, and is otherwise sent chunked. - A handler that waits (
phasync::sleep(), a database or HTTP client built on phasync) lets the same worker serve other requests meanwhile. - An exception from the handler is logged, and answered with a 500 if the head was not sent yet; after that, the connection is aborted.
Running correctly on swerve does not mean your application is safe for it. Slim and other
PSR-15 stacks run requests concurrently in one worker: a middleware or handler that keeps request
state in an instance property, or a container definition that hands out one shared instance (a
PDO connection, say) instead of a new one per request, is exactly what breaks under that
concurrency. See Making your application safe for concurrent
requests.
phasync/doctor lists the candidates in your code:
composer require --dev phasync/doctor, then vendor/bin/phasync-doctor.
WebSockets and Server-Sent Events
A handler asks for a WebSocket or an event stream by returning a response. swerve itself speaks the
protocols (see its docs/websocket.md: close codes, limits, what a drain does); this package only
lets a PSR-15 route or middleware stack ask for one.
use Swerve\Psr15\EventStreamResponse; use Swerve\Psr15\WebSocketResponse; use Swerve\ServerSentEvents; use Swerve\Swerve; use Swerve\WebSocket; $app->get('/chat', function ($request, $response) { $user = $request->getAttribute('user'); // read the request now, see below return WebSocketResponse::from($request, function (WebSocket $ws) use ($user) { foreach ($ws as $message) { // ends when the client leaves $ws->send("$user: $message"); } }, subprotocols: ['chat.v1'], origins: ['https://example.com']); }); $app->get('/news', function ($request, $response) { return EventStreamResponse::stream(function (ServerSentEvents $sse) { foreach (Swerve::subscribe('news', heartbeat: 15) as $message) { null === $message ? $sse->comment('keep-alive') : $sse->send($message, event: 'news'); } }); }); $app->post('/news', function ($request, $response) { Swerve::publish('news', (string) $request->getBody()); // reaches every open socket and stream, on every worker return $response->withStatus(202); });
WebSocketResponse::from($request, $handler, $subprotocols = [], $origins = null, $maxMessage = WebSocket::MAX_MESSAGE)andEventStreamResponse::stream($callback, $headers = [])return ordinary PSR-7 responses. Middleware maywith*()them: the clones keep the handler and the options.- The handler is swerve's
WebSocket, the same class in every framework adapter, so what swerve'sdocs/websocket.mdshows works here unchanged. - The handler runs after the route returned. Take the user, the session or anything else from the
PSR request before returning the response, and close over it with
use. Do not keep the request itself for the handler. - The handshake is checked in
from(), by swerve's own decision, and the answer is a PSR response. A request that is not a handshake gets 426, an invalid one 400 (naming version 13) and an origin that$originsdoes not allow 403: ordinary responses, built as swerve itself would answer, so middleware decorates them like any other. A handshake gets a real101response withUpgrade,Connection,Sec-WebSocket-Acceptand the chosenSec-WebSocket-Protocol; middleware may add headers to it (theAccess-Control-*orSet-Cookieof the application's middleware are on the101the client sees), and the clones keep the handler and the limit. - The
101's body is the outbound WebSocket frames; the request's body is read for the inbound ones. A middleware thatwithBody()s the response sees every frame the client is sent, and one thatwithBody()s the request before the route sees every frame the client sent, like any other body. Nothing of the handler runs, and no coroutine is touched, before the bridge reads that body as a101: a middleware that changes the status of the101response (a403, say) has refused the upgrade, and it is sent as any response — the handler never starts. - The event stream has the headers of the response that reaches the adapter, so the
Access-Control-*orVarythat middleware added are sent. The status is always 200, andContent-Type,Cache-ControlandX-Accel-Bufferingare swerve's: the response's own are ignored.Last-Event-IDis on the PSR request and onServerSentEvents::lastEventId(). - A
HEADrequest to an event stream gets the head and no body; the callback does not run. - The stream ends when the callback returns; a callback that throws is logged and the connection aborted.
A client that left makes the next
send()throwphasync\IOException: let it leave the callback.
Other ways to run an application on swerve
swerve picks the adapter from what is installed, so --adapter= is only needed when several are.
- The default adapter:
swerve.phpreturns aSwerve\RequestHandler, written against swerve's ownClientRequest. Use it for anything that is not a PSR-15 application. phasync/swerve-symfonyruns Symfony's kernel.
Swerve\Psr15\Bridge::wrap($handler) returns the Swerve\RequestHandler this package builds, for a
swerve.php that wants a PSR-15 handler beside something of its own.
Development
composer install vendor/bin/pest
The tests start a real vendor/bin/swerve and speak HTTP to it over sockets. To run them with the
phasync extension, put it in an ini file that PHP scans, as swerve's processes inherit it:
PHP_INI_SCAN_DIR=:/dir/with/phasync.ini vendor/bin/pest.
MIT licensed.