phasync / swerve-symfony
Run Symfony applications on swerve: booted once per worker, WebSockets and Server-Sent Events, no rewrite.
Requires
- php: ^8.2
- phasync/net: ^2.0
- phasync/phasync: ^2.0
- phasync/swerve: ^0.1
- psr/http-message: ^1.0 || ^2.0
- symfony/http-foundation: ^6.4 || ^7.0 || ^8.0
- symfony/http-kernel: ^6.4 || ^7.0 || ^8.0
- symfony/runtime: ^6.4 || ^7.0 || ^8.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 23:05:23 UTC
README
Your Symfony 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 Symfony application unchanged, and lets its controllers hold WebSockets.
composer require phasync/swerve-symfony
vendor/bin/swerve --http=0.0.0.0:8080 --public=public
That's the whole setup, apart from sessions. public/index.php stays as it is, so
the same application still runs under PHP-FPM: swerve finds this package's entry point from its
own composer.json (extra.swerve), with no swerve.php of your own.
WebSockets
A controller returns Swerve\Symfony\WebSocketResponse::from(), an ordinary Symfony Response,
with the callback that runs on the connection:
use Swerve\Symfony\WebSocketResponse; use Swerve\WebSocket; #[Route('/chat')] public function chat(Request $request): Response { return WebSocketResponse::from($request, static function (WebSocket $ws) { foreach ($ws as $message) { // text or binary, until the client leaves $ws->isBinary() ? $ws->sendBinary($message) : $ws->send("echo: $message"); } }); }
An ordinary GET to the route is answered 426 Upgrade Required. A connection holds no kernel:
the kernel goes back to the pool with the 101, so a worker holds hundreds of sockets and
serves requests beside them with as few kernels as those requests need. Outbound frames are
echoed and flush()ed inside a Symfony StreamedResponse callback ($response->getCallback(),
setCallback()), so a kernel.response listener may wrap it, or do its own ob_start(), to see
every one; a listener that changes the response's status (a 403, say) sends that response, with
an empty body, and the handler never runs.
Server push. A callback that only forwards a topic ends when its client leaves, and any route, in any worker, publishes to it:
#[Route('/news', methods: ['GET'])] public function news(Request $request): Response { return WebSocketResponse::from($request, static function (WebSocket $ws) { foreach (Swerve::subscribe('news') as $message) { $ws->send($message); } }); } #[Route('/news', methods: ['POST'])] public function publish(Request $request): Response { Swerve::publish('news', $request->getContent()); return new Response('published'); }
The user and the session: take them first. The callback runs after the kernel went back to
the pool, when its services (the security token storage, the RequestStack, the session, your
own) serve whichever request comes next: read inside the callback, $this->getUser() may give
another user. Take what the callback needs before WebSocketResponse::from(), and pass it in:
#[Route('/me')] public function me(Request $request): Response { $user = $this->getUser()?->getUserIdentifier(); $cart = $request->getSession()->get('cart', []); return WebSocketResponse::from($request, static function (WebSocket $ws) use ($user, $cart) { foreach ($ws as $message) { $ws->send("$user: $message"); } }); }
A static closure keeps $this (the controller and its container) out of it. On a shutdown or
reload, open sockets are closed with 1001, and the worker exits once their callbacks end.
Server-Sent Events
No class of this package's own: a StreamedResponse (or Symfony 7.3's native
EventStreamResponse) with text/event-stream streams like any other, through the same path as
Streaming below. flush() after each echo sends it at once:
#[Route('/ticks')] public function ticks(): StreamedResponse { return new StreamedResponse(static function () { foreach (Swerve::subscribe('ticks') as $tick) { echo "data: $tick\n\n"; \flush(); } }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache']); }
How it runs
- Once per worker: the front controller returns its closure (Symfony Runtime's
autoload_runtime.phpreturns early when the autoloader is already loaded), andSymfony\Component\Runtime\SymfonyRuntimeresolves it as under PHP-FPM:.envfiles,APP_ENV,APP_DEBUG,extra.runtimeoptions in composer.json. One kernel is booted.APP_RUNTIME_MODEisweb=1&worker=1, as Symfony's FrankenPHP runner sets it. - Per request: swerve's
ClientRequestis converted straight to an HttpFoundationRequest(the body read, uploads asUploadedFile, swerve's ownSwerve\Psr\FormBodydoing the parsing) with no PSR-7 in between; the request borrows a kernel of its own from a pool (phasync\Util\Pool), andhandle()runs. TheResponseis sent straight back to theClientRequestthe same way;kernel.terminateruns once swerve has it (as afterfastcgi_finish_request()), and the kernel returns to the pool. Symfony resets the kernel's services (services_resetter, everykernel.resetservice) when it starts its next request, as with Symfony Runtime's FrankenPHP and Swoole runners. - Concurrency: a kernel serves one request at a time, so its
RequestStack, security token, session and stateful services belong to that request alone. The pool has no cap: it makes a kernel whenever every existing one is busy, so with ext-phasync, a request waiting for I/O (MySQL, curl,sleep()) lets the worker serve others on one of its own; idle kernels beyond the peak used in the last 30 seconds are let go. Each kernel after the first costs about 120 KiB in the test application (security, Twig, sessions), since the classes and the compiled container are shared. Without ext-phasync requests rarely overlap, and the pool stays at one. - Streaming: a
StreamedResponseis sent as its callback echoes; aBinaryFileResponseas the client reads it, withRangesupport. Both hold their kernel until they end. When the client leaves, the callback's nextechothrows, ending it as PHP-FPM ends a script. - WebSockets from controllers: a controller may return a
WebSocketResponse(above), which swerve sends as a101and then runs, the kernel already back in the pool.
The framework running correctly on swerve does not mean your application does: static properties
of your own code are not reset by the kernel pool, and need the same care (see Process-wide
state is shared by the kernels of a worker, below). 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.
Sessions
PHP's session module keeps one session per process, so Symfony's default session storage
fails in swerve. Swerve\Symfony\SessionStorageFactory gives each request a storage of its own,
loaded and saved through the session handler you configure (PdoSessionHandler, Redis,
Memcached), without the session module. It also works under PHP-FPM.
# config/packages/framework.yaml framework: session: storage_factory_id: Swerve\Symfony\SessionStorageFactory handler_id: '%env(DATABASE_URL)%' # or redis://..., or a handler service's id
# config/services.yaml, under services: (autowired) Swerve\Symfony\SessionStorageFactory: ~
An id the handler doesn't know gets a new one (as session.use_strict_mode), a session with
nothing in it is deleted, and garbage collection runs on gc_probability / gc_divisor of the
saves, as in PHP.
Before you deploy
exitanddd()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 (the kernel neither logs it nor renders an error page); clients retry. - Sessions need the configuration above. PHP's own handlers (files, the default) need the
session module and are refused. The data is stored with
serialize(), not PHP's session format, so sessions from before the switch are not read: users log in once more. With SQLite, setlock_mode: 0onPdoSessionHandler: its default lock holds a transaction for the whole request, which blocks the other workers. - Process-wide state is shared by the kernels of a worker.
setlocale()and static properties of your own code change for every request of the worker.\Locale::setDefault()(whichRequest::setLocale()calls) anddate_default_timezone_set()do not have this problem: swerve runs every request in a\Scopeof its own, andfiber-interop/scope0.3.0 — which phasync 2.0.2 requires — swaps the defaultintllocale and timezone for each scope (the nativeext-scopeextension does the same when it is loaded instead). - Output buffering is one stack per process. Each
StreamedResponse's output reaches its own response, but a callback that doesob_start(), waits, thenob_get_clean()may catch another request's output: don't wait inside your own output buffer.echoin a controller (not in aStreamedResponse) goes to swerve's log, not the response. - A
StreamedResponseholds a kernel until it ends, so each open Server-Sent Events stream takes one; the pool has no cap, so this costs memory (a kernel each), not a queue. Code that runs after the controller returned (aWebSocketcallback) must not use the kernel's services, which serve the next request by then: take the user and session data first, as in WebSockets. - A WebSocket client whose connection is reset (not closed) is logged as an error,
Unhandled exception in a coroutine: ... Connection reset by the client(phasync/swerve#7). Nothing else goes wrong: its callback ends, as for any client that leaves. - A
StreamedResponseis not slowed down by a slow client: what it echoes faster than the client reads is kept in memory. Send files withBinaryFileResponse, which is read as the client takes it. - A raw request body (a JSON or
PUTbody, not a form) is read into memory before the controller runs, asRequest::getContent()needs it; swerve's--max-bodycaps it (8 MiB by default). Form uploads are streamed to temporary files. - Without ext-phasync a blocking call (PDO, curl,
sleep()) blocks the whole worker, and more kernels don't help. Load the extension in production.
Compatibility
| Symfony | PHP | ext-phasync |
|---|---|---|
| 6.4, 7.x, 8.x (tested: 6.4, 7.4, 8.1) | 8.2 – 8.5 | optional; tested with and without |
License
MIT. See the Ennerd philosophy for why this stack is built to be owned.