phasync / swerve-laminas
Run Laminas MVC applications on swerve: streamed responses, WebSockets and Server-Sent Events, no rewrite.
Requires
- php: ^8.2
- ext-session: *
- laminas/laminas-mvc: ^3.8
- phasync/phasync: ^2.0
- phasync/swerve: ^0.1
- phasync/swerve-psr15: ^0.1
- psr/http-server-handler: ^1.0
Requires (Dev)
- friendsofphp/php-cs-fixer: ^3.58
- pestphp/pest: ^3.0 || ^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-19 22:01:35 UTC
README
Your Laminas MVC application, kept loaded. swerve is a PHP
application server: long-running workers that serve HTTP/1.1 themselves, stream request and
response bodies, and hold WebSockets and Server-Sent Events. This package lets it run a Laminas
MVC application (the successor of Zend Framework MVC, laminas/laminas-mvc-skeleton) unchanged.
composer require phasync/swerve-laminas
vendor/bin/swerve --adapter=laminas --http=0.0.0.0:8080 --public=public
That's the whole setup. --adapter=laminas picks this adapter over swerve-psr15's, which comes
with it (or put "extra": {"swerve": {"adapter": "laminas"}} in your composer.json).
public/index.php stays as it is, so the same application still runs under PHP-FPM. The handler
is a PSR-15 request handler, served by swerve-psr15's
Bridge; a swerve.php run with --adapter=swerve may return
Swerve\Psr15\Bridge::wrap(new Swerve\Laminas\Handler(__DIR__)).
Concurrency. Laminas MVC requests run one at a time per worker, behind a lock: Laminas state
outside the application — $_SERVER, PHP's session and $_SESSION, laminas-session's default
session manager, and view-helper statics — is the worker's, not the request's (size --workers
accordingly). Mezzio has no adapter of its own (see Mezzio) and runs its requests
concurrently in a worker. Details and what you can do about either: docs/concurrency.md.
The framework running correctly on swerve does not mean your application does: state your own
code keeps in statics, globals or shared services, and connections shared between requests, need
the same care. 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
A controller action returns swerve-psr15's WebSocketResponse::from(), with the PSR-7 request
that the handler puts in the Laminas request's metadata; the callback gets swerve's WebSocket:
use Psr\Http\Message\ServerRequestInterface; use Swerve\Psr15\WebSocketResponse; use Swerve\WebSocket; public function chatAction() { return WebSocketResponse::from($this->getRequest()->getMetadata(ServerRequestInterface::class), static function (WebSocket $ws) { foreach ($ws as $message) { // ends when the client leaves $ws->isBinary() ? $ws->sendBinary($message) : $ws->send("echo: $message"); } }); }
The callback runs after the action has returned, in a coroutine of its own. An ordinary GET to
the route is answered 426 Upgrade Required.
Server push. A callback that only forwards a topic, and an ordinary action that publishes to it, in any worker:
use Swerve\Swerve; public function newsAction() { return WebSocketResponse::from($this->getRequest()->getMetadata(ServerRequestInterface::class), static function (WebSocket $ws) { foreach (Swerve::subscribe('news') as $message) { $ws->send($message); } }); } public function publishAction() { Swerve::publish('news', json_encode(['headline' => $this->params()->fromPost('headline')])); return new JsonModel(['published' => true]); }
The callback ends when its client leaves, with a close frame or without a word, and when the worker drains: clients then get a close frame with 1001.
The user. Take what the callback needs from the request before WebSocketResponse::from():
public function notificationsAction() { $user = $this->auth->getIdentity(); // laminas-authentication, from the session return WebSocketResponse::from($this->getRequest()->getMetadata(ServerRequestInterface::class), static function (WebSocket $ws) use ($user) { foreach (Swerve::subscribe("user:$user") as $message) { $ws->send($message); } }); }
The handler closes the session when the action returns, as PHP does at the end of a request.
getIdentity(), a Laminas\Session\Container or anything else that reads the session inside the
callback reads the session of whatever request the worker runs at that moment: none between
requests, another visitor's during one. The tests show both.
Make the callback static. A closure written in a controller method is bound to the
controller, which holds the application: the socket then keeps the whole application, about
300 KiB, until it closes. A static closure keeps what it uses. Measured with 200 sockets on
one worker: 84 KiB a socket (swerve's WebSocket, its coroutines and subscription) with a static
closure, 380 KiB with a bound one; both give everything back when the sockets close.
The worker keeps serving. Laminas requests run one at a time per worker, but only until the action returns: open sockets and their subscriptions don't hold that turn. With 200 sockets open on one worker, each receiving messages, its ordinary requests were answered within 15 ms.
Every client sees a topic's messages in the same order. Two messages published one right after the other through different workers may arrive in the other order (phasync/swerve#5).
What changes
Laminas MVC is built for one request per process, so every request still gets a new application (see below), and building it is most of a request's time. What swerve adds is what PHP-FPM can't do: streamed responses, WebSockets and Server-Sent Events from a controller action.
How it runs
- Once per worker: the application configuration is read as
config/container.phpreads it (config/application.config.php, merged withconfig/development.config.phpin development mode), and one application is booted, which loads the classes and writes the configuration cache. - Per request: a new
Laminas\Mvc\ApplicationfromApplication::init(), with its own ServiceManager, modules, Request, Response and MvcEvent, as under PHP-FPM. Laminas keeps request state in shared services (the layout view model, the head title and other placeholder helpers, the route match) and has no mechanism to reset them: an application reused with a new request and response serves the skeleton's page with its title once more for every earlier request. A new application costs 1.2 ms, 0.7 ms of it the module manager configuring the ServiceManager and its plugin managers; the skeleton's home page takes 0.45 ms more. - Concurrency: one request at a time per worker (
phasync\Util\Synchronized). Laminas reads the request from the superglobals, which the handler fills from the PSR-7 request as PHP-FPM would, and so doRemoteAddress, theServerUrlhelper and laminas-session; the session itself lives in PHP's session module and$_SESSION. All of that is per process: tests show each item leaking between overlapping requests without the lock (docs/concurrency.md). A worker's other connections (static files, keep-alive, WebSockets) go on while a request runs. - Sessions: laminas-session with PHP's native sessions, as configured (
session_config,session_manager, save handlers). After each request the handler writes and closes the session, sends the cookie and thesession.cache_limiterheaders that PHP-FPM sends, and makes PHP forget the session id.session_id('')alone would make PHP ignore the next visitor's cookie. - Responses: the Laminas response becomes a PSR-7 response. A
Laminas\Http\Response\Streamis sent as it is read, and its file deleted afterwards whensetCleanup()asks for it. A controller action may return a PSR-7 response, which is sent as it is (see WebSockets); the PSR-7 request is the Laminas request's metadataPsr\Http\Message\ServerRequestInterface.
Mezzio
Mezzio, Laminas' PSR-15 framework, needs no adapter of its own: its
Application is a request handler, so swerve-psr15
serves it from swerve.psr15.php (vendor/bin/swerve --adapter=psr15).
<?php // swerve.psr15.php, as public/index.php chdir(__DIR__); require 'vendor/autoload.php'; $container = require 'config/container.php'; $app = $container->get(Mezzio\Application::class); $factory = $container->get(Mezzio\MiddlewareFactory::class); (require 'config/pipeline.php')($app, $factory, $container); (require 'config/routes.php')($app, $factory, $container); return $app;
A WebSocket is a handler's response, and the user or session data come from the request's
attributes before WebSocketResponse::from():
use Mezzio\Authentication\UserInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\RequestHandlerInterface; use Swerve\Psr15\WebSocketResponse; use Swerve\Swerve; use Swerve\WebSocket; final class NewsHandler implements RequestHandlerInterface { public function handle(ServerRequestInterface $request): ResponseInterface { $user = $request->getAttribute(UserInterface::class)?->getIdentity(); return WebSocketResponse::from($request, static function (WebSocket $ws) use ($user) { foreach (Swerve::subscribe('news') as $message) { $ws->send($message); } }); } }
Requests run concurrently in a worker, so request state belongs in the request's attributes.
Mezzio's UrlHelper is a shared service holding the last route result: called without a route
name, it returns the URL of another request in flight.
Before you deploy
exitanddie()end the worker, and the requests it is serving with it.- A stop ends the requests in flight. On a shutdown or reload swerve stops a worker at once:
a request still waiting then ends, answered
503by swerve when nothing was sent yet (no error page); clients retry. - A request holds its worker until its action returns, as a PHP-FPM child does: size
--workersas you sizepm.max_children. A WebSocket's callback and a streamed response's body don't hold it. - A WebSocket callback reads no session and no identity of its own: take them in the action,
and make the callback
static(see WebSockets). - laminas-session's
SessionManagerregisters a shutdown function in its constructor, which keeps every manager until the worker exits. An application that gets theSessionManagerservice on every request, as laminas-session's documentation does inonBootstrap(), grows by 2 to 3 KiB a request. Set--max-requests(50,000 is up to 150 MiB), or amemory_limitso that--max-memoryrecycles workers: the CLI's defaultmemory_limitof -1 turns that off. $request->getFiles()holds PSR-7UploadedFileInterfaceobjects: PHP'sis_uploaded_file()andmove_uploaded_file()don't know swerve's uploads, so Laminas' upload validator would reject the arrays PHP-FPM gives. laminas-form's file inputs and validators take the objects;RenameUploadneeds itsstream_factoryandupload_file_factoryoptions (laminas-diactoros'StreamFactoryandUploadedFileFactory). Code that moves$_FILES[...]['tmp_name']withmove_uploaded_file()should callmoveTo()on the upload.header(),setcookie(),http_response_code()andechodon't reach the client: set headers and cookies on the Laminas response. Output goes to swerve's terminal. laminas-session'sdestroy()expires the session cookie withsetcookie(), so the browser keeps it; the next request gets an empty session under the old id, or a new id withsession.use_strict_mode=1.- A PSR-15 middleware route (laminas-mvc-middleware) converts its PSR-7 response through laminas-psr7bridge, which buffers it: return streamed responses and WebSockets from a controller action.
- A session save handler set with
session_set_save_handler()outside the application, inswerve.php, is gone after the first request with a session: set it through laminas-session'sSaveHandlerInterfaceservice, or inonBootstrap().
Compatibility
| laminas-mvc | PHP | ext-phasync |
|---|---|---|
| 3.8 | 8.2 – 8.4, as laminas-mvc 3.8 declares | optional; tested with and without |
License
MIT. See the Ennerd philosophy for why this stack is built to be owned.