phasync / swerve-yii
Run Yii 3 applications on swerve: booted once per worker, WebSockets and Server-Sent Events, no rewrite.
Requires
- php: ^8.2
- phasync/phasync: ^2.0
- phasync/swerve: ^0.1
- phasync/swerve-psr15: ^0.1
- psr/http-server-handler: ^1.0
- yiisoft/di: ^1.2
- yiisoft/error-handler: ^4.0
- yiisoft/yii-http: ^1.0
- yiisoft/yii-runner: ^2.2
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:30 UTC
README
Your Yii 3 application, booted once and kept warm. 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 Yii 3 application (yiisoft/app) unchanged. Yii 2 is not covered.
One request at a time per worker. Yii's container keeps a request's state in shared
services, and PHP's session is one process-wide $_SESSION, so the adapter runs requests one
at a time per worker; a request that waits for a database or an HTTP API holds its worker
meanwhile. Run more workers for more concurrent requests. Why, and what to do about
it.
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.
composer require phasync/swerve-yii
vendor/bin/swerve --adapter=yii --http=0.0.0.0:8080 --public=public
That's the whole setup: the yii adapter runs the skeleton's src/bootstrap.php (the autoloader
and .env) and serves the application. --adapter=yii picks it over swerve-psr15's adapter, which
comes with it (or put "extra": {"swerve": {"adapter": "yii"}} in your composer.json).
public/index.php stays as it is, so the same application still runs under PHP-FPM. APP_ENV
and APP_DEBUG are read from the environment, as the skeleton reads them; a swerve.php run with
--adapter=swerve sets them instead:
<?php // swerve.php, next to composer.json require __DIR__ . '/src/bootstrap.php'; // yiisoft/app's: the autoloader and .env return Swerve\Psr15\Bridge::wrap(new Swerve\Yii\Handler(__DIR__, debug: false, environment: 'prod'));
The handler is a PSR-15 request handler, served by swerve-psr15's
Bridge.
WebSockets
A Yii action returns swerve-psr15's WebSocketResponse::from(); the callback gets swerve's
WebSocket and runs on the connection after the 101. An ordinary GET to the same route is answered 426.
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Swerve\Psr15\WebSocketResponse; use Swerve\WebSocket; use Swerve\Swerve; use Yiisoft\User\CurrentUser; final readonly class NewsAction // Route::get('/news')->action(NewsAction::class) { public function __invoke(ServerRequestInterface $request, CurrentUser $user): ResponseInterface { $userId = $user->getId(); // now: the callback runs after the request's services are reset return WebSocketResponse::from($request, static function (WebSocket $ws) use ($userId) { foreach (Swerve::subscribe("user:$userId") as $message) { $ws->send($message); } }); } }
Any other action, on any worker, pushes to it:
Swerve::publish("user:$userId", json_encode(['text' => 'Your report is ready']));
- Both ways:
foreach ($ws as $message)receives until the client closes;send(),sendBinary()andisBinary()do the rest. See swerve's realtime guide. - The worker's turn: the action returns the 101 and its turn ends; the callback runs beside the requests that follow. The tests hold 250 WebSockets open on one worker and its pages are still answered promptly.
- The user, the session, route arguments: read them in the action, before
WebSocketResponse::from(), and pass the values in.CurrentUser,SessionInterface,CurrentRouteand the like belong to whichever request the worker serves at that moment: read inside the callback,CurrentUsergives the guest, or the user of another visitor's request in progress (a test shows it). - Leaving and stopping: when the client leaves, with or without a close frame, the callback ends, also one that only forwards a subscription. On a shutdown or reload, open sockets are closed with 1001 (going away) and the worker exits cleanly.
How it runs
PHP-FPM builds Yii's configuration and container for every request; swerve builds them once per worker.
- Once per worker: the configuration and the container are built, the container's
ErrorHandleris registered, thebootstrap-webgroup runs andApplication::start()dispatchesApplicationStartup, as yiisoft/yii-runner-roadrunner does, with the configuration groups ofpublic/index.php's runner. - Per request:
Application::handle()runs the middleware stack; an exception escaping it goes toErrorCatcher. ThenAfterEmitis dispatched,StateResetterruns theresetcallbacks of the container's definitions (CurrentRoute,RequestProvider,Session,CurrentUser,WebView, ...), and PHP'ssession_id()and$_SESSIONare cleared. yiisoft/session'sFlashhas no reset and keeps the last session id it saw, so its messages would not expire (yiisoft/session#2); the adapter clears that id too. - Concurrency: one request at a time per worker (
phasync\Util\Synchronized). Yii keeps a request's state in shared services of the container, and its session is PHP's native session, one per process: a container per request in flight would still share it. Streamed bodies and WebSockets go on after the request's turn, so they don't hold up the worker. Why, and what to do about it. - Sessions: yiisoft/session through PHP's session module, with the save handler the application configures (files, Redis, a database).
Before you deploy
- A request that waits (a database, an HTTP API) keeps its worker's other requests waiting, also with ext-phasync. Run at least as many workers as you would run PHP-FPM children.
- A service of your own that keeps request state needs a
resetcallback in its container definition, as Yii's own services have: without one, the next request sees that state. - A streamed body, an SSE producer or a WebSocket callback runs after the reset: take what it needs (the user id, route arguments, session values) before returning the response. Read inside it, those services show another request's state, another visitor's user included.
AfterEmitis dispatched when the application returns the response, before swerve sends it, not after as under PHP-FPM.- The application's
ErrorHandleris registered for the whole worker: a PHP warning becomes anErrorException(a 500 page in a request, as under PHP-FPM), also in a coroutine that outlives its request, such as a stream's producer. exit,dieanddd()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, not theErrorCatcher's page (config/events-web.phpof this package listens for it); clients retry.
Compatibility
| Yii | PHP | ext-phasync |
|---|---|---|
| 3 (yiisoft/app 1.x) | 8.2 – 8.5 | optional; tested with and without |
License
MIT. See the Ennerd philosophy for why this stack is built to be owned.