phasync / swerve-cakephp
Run CakePHP applications on swerve: booted once per worker, WebSockets and Server-Sent Events, no rewrite.
Requires
- php: ^8.2
- cakephp/cakephp: ^5.1
- 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:32 UTC
README
Your CakePHP 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 CakePHP application unchanged.
A worker runs one CakePHP request at a time, behind a lock: Router's current request,
Configure, the global event manager, the table registry, the locale, the time zone, the
session and the output buffers are CakePHP's static, per-process state, so two requests of a
worker must not use them at once. A request that waits (a database query, a remote call) holds
its worker meanwhile, exactly as a PHP-FPM worker would: size --workers as pm.max_children.
A WebSocket callback or a stream fed from a coroutine runs outside that lock, beside the
worker's other requests; see WebSockets and 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.
composer require phasync/swerve-cakephp
vendor/bin/swerve --adapter=cakephp --http=0.0.0.0:8080 --public=webroot
That's the whole setup: the cakephp adapter serves App\Application. --adapter=cakephp picks
it over swerve-psr15's adapter, which comes with it (or put "extra": {"swerve": {"adapter": "cakephp"}} in your composer.json). webroot/index.php stays as it is, so the same application
still runs under PHP-FPM. Another application class, or other settings, take a swerve.php, run
with --adapter=swerve:
<?php // swerve.php, next to composer.json return Swerve\Psr15\Bridge::wrap(new Swerve\CakePHP\Handler(__DIR__, application: My\Application::class));
The handler is a PSR-15 request handler, which swerve-psr15's
Bridge serves.
WebSockets
A controller action answers a WebSocket with swerve-psr15's WebSocketResponse::from(), and its
callback gets swerve's WebSocket. Cake wants its own Response from an action;
CakeResponse::from() makes one of the 101, headers and body unchanged:
use Cake\Http\Response; use Swerve\CakePHP\CakeResponse; use Swerve\Psr15\WebSocketResponse; use Swerve\WebSocket; public function echo(): Response { return CakeResponse::from(WebSocketResponse::from($this->request, function (WebSocket $ws) { foreach ($ws as $message) { // ends when the client leaves $ws->send("echo: $message"); } })); }
An ordinary GET to the action is answered 426. The callback runs in a coroutine of its own after the action has returned, beside the worker's next Cake requests: an open WebSocket doesn't hold the worker's turn (the tests serve ordinary requests promptly with 250 open in one worker).
Server push. A callback that only forwards a topic ends when its client leaves; any route, in any worker, publishes:
public function news(): Response { return CakeResponse::from(WebSocketResponse::from($this->request, function (WebSocket $ws) { foreach (Swerve::subscribe('news') as $message) { $ws->send($message); } })); } public function post(): Response { Swerve::publish('news', json_encode($this->request->getData())); return $this->response->withStatus(204); }
The user. Take what the callback needs from the request before WebSocketResponse::from():
$user = $this->Authentication->getIdentity(); // or the session's: $room = $this->request->getSession()->read('room'); return CakeResponse::from(WebSocketResponse::from($this->request, function (WebSocket $ws) use ($user, $room) { // $user and $room are this connection's }));
Inside the callback the request is over. Router::getRequest() and Router::url() describe the
request the worker serves last, maybe another user's; the session throws a LogicException
(reading it there would read whichever session the worker has open then). Don't use Cake's
database connection in the callback either: it belongs to the worker's requests, which run
meanwhile. A shutdown or reload closes open WebSockets with 1001.
How it runs
- Once per worker: the application is bootstrapped with its plugins, as
Cake\Http\Serverdoes it, its routes connected, and the session configured. - Per request:
Configure, the router's current request and base URL, the global event manager, the application's container, the table registry, DebugKit's panels, the locale and the time zone are put back as they were after boot, as a fresh PHP-FPM process has them. The middleware queue is built asCake\Http\Server::run()builds it, with aRoutingMiddlewarethat doesn't connect the routes again; swerve's request becomes aCake\Http\ServerRequest, with its body and uploaded files, and runs through it. The response's cookies, which only Cake'sResponseEmitterwould send, becomeSet-Cookieheaders. - Concurrency: one Cake request at a time per worker, with
phasync\Util\Synchronized: size--workersas PHP-FPM'spm.max_children. Cake keeps the current request, Configure, the locale, the global event manager and the table registry in static properties, and$_SESSIONand the output buffers are the process's: overlapping requests would see each other's, so the lock keeps them apart. swerve's own work (connections, static files, WebSockets and streams after their response) goes on meanwhile. Details: docs/concurrency.md. - Sessions: PHP's session module and Cake's configured engine, as under PHP-FPM, shared by
the workers. The session id comes from the request's cookie, the
Set-Cookieand no-cache headers PHP would send go into the response, and the id is forgotten after each request, so a visitor without a cookie gets a new session.renew()(at login) gives a new id. Only the request's own code may use it: a coroutine that outlives the request, such as a WebSocket callback, gets aLogicException.
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 (Cake neither logs it nor renders an error page); clients retry. -
Configuration. Set
APP_FULL_BASE_URLandSECURITY_SALT(andDEBUG=false, sinceapp_local.phpdefaults to debug on), as for PHP-FPM: with debug off the skeleton'sHostHeaderMiddlewarerefuses every request whileApp.fullBaseUrlis empty. With debug on, full URLs come from each request'sHostheader. -
One request at a time per worker. A worker waiting for the database waits alone, as a PHP-FPM process does; size
--workersas you wouldpm.max_children. -
Routes are connected once per worker. Routes connected during a request, or depending on it, don't apply.
--watchand a reload (SIGHUP) pick up changed routes. -
Cake sees the CLI (
PHP_SAPIiscli): the skeleton'sconfig/bootstrap.phplogs tologs/cli-debug.logandlogs/cli-error.log, plugins markedonlyCli(Bake, Migrations) are loaded, and in debug mode PHP warnings and notices go to the worker's standard error instead of the page. -
Echoed streams hold the worker. A
CallbackStreamwhose callback echoes (Cake's streamed responses,JsonStreamResponse) is sent as it echoes, but runs while no other Cake request does: PHP's output buffers belong to the process. For Server-Sent Events and other long streams, fill a stream from a coroutine instead; the worker serves other requests meanwhile:$stream = new \phasync\Psr\UnbufferedStream(1, 60); \phasync::go(function () use ($stream) { foreach (\Swerve\Swerve::subscribe('prices') as $price) { $stream->append("data: $price\n\n"); } }); return $this->response->withType('text/event-stream')->withBody($stream);
Such a coroutine runs beside the worker's Cake requests: don't use Cake in it (the router, the session, the ORM's database connection).
-
WebSocket callbacks and other coroutines run after their request: see WebSockets for what they may take from Cake.
-
Server.terminatelisteners run after the response is handed to swerve, in turn with the worker's requests, with the session closed. -
Static state of your own (static properties, singletons) lives as long as the worker;
Configure::write()during a request is undone after it.
Compatibility
| CakePHP | PHP | ext-phasync |
|---|---|---|
| 5.1 – 5.4 | 8.2 – 8.5 | optional; tested with and without |
License
MIT. See the Ennerd philosophy for why this stack is built to be owned.