phasync
Frameworks / CakePHP

Frameworks

CakePHP

Run a CakePHP 5 application on Swerve: booted once per worker, one request at a time, like PHP-FPM.

Concurrency: serialized, one request at a time per worker. Swerve\CakePHP\Handler takes Synchronized around every request: a worker runs one Cake request to completion before starting the next, as a PHP-FPM child does. Size --workers as you size pm.max_children. Swerve's own work — accepting connections, serving static files, and anything already answered that's now a WebSocket or an SSE stream — goes on meanwhile. See Frameworks.

Install and run#

composer require phasync/swerve-cakephp
vendor/bin/swerve --adapter=cakephp --http=0.0.0.0:8080 --public=webroot

--adapter=cakephp serves App\Application with no swerve.php: webroot/index.php keeps running the same application under PHP-FPM. For another application class, or other settings, write swerve.php and run with --adapter=swerve instead:

<?php // swerve.php, next to composer.json

return Swerve\Psr15\Bridge::wrap(new Swerve\CakePHP\Handler(__DIR__, application: My\Application::class));

Set APP_FULL_BASE_URL, SECURITY_SALT and DEBUG=false, as for PHP-FPM: with debug off, the skeleton's HostHeaderMiddleware refuses every request while App.fullBaseUrl is empty.

What boots once, and what runs per request#

Handler's constructor does what Cake\Http\Server does, once: it calls $app->bootstrap() and pluginBootstrap(), connects the routes with a route builder, and configures the session. It then snapshots Configure::read(), the event manager's listeners, the locale, the time zone and Router's request context, and keeps a closure that restores them.

Each request, under the lock:

The session#

Swerve\CakePHP\Session extends Cake's own, using PHP's session module and your configured engine, not the fake 'cli' session Cake's Session falls back to when PHP_SAPI isn't 'cgi'/'fpm-fcgi'. The session id comes from the request's cookie (PHP's own cookie lookup would read the worker's $_COOKIE, which belongs to no particular request); begin() takes it at the start of a request, finish() writes and closes it, clears $_SESSION, and returns the Set-Cookie and no-cache headers header() would otherwise have sent. renew() (Cake's getSession()->renew(), used at login) gives a new id without a header() call.

Every read reaches started() first, which checks the session against the Scope of the request that opened it:

public function started(): bool
{
    if (null === $this->scope || \Scope::getCurrent() !== $this->scope) {
        throw new \LogicException('The session belongs to its request, which is over: ...');
    }
    return parent::started();
}

A coroutine that outlives the request — a WebSocket callback, a phasync::go() closure started from the action — throws LogicException if it reads $this->request->getSession(). Take what you need before starting it:

$user = $this->Authentication->getIdentity();
$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, fixed at the time the request answered
}));

Since only one request runs at a time per worker, $_SESSION being process-wide isn't a race here the way it would be in a concurrent adapter — it's begin()/finish() forgetting the session between requests that keeps one visitor's session from leaking into the next.

Uploads, streamed responses and WebSockets#

Uploaded files arrive as Cake's own UploadedFile objects ($this->request->getData('file'), with getClientFilename(), getSize(), getStream()), built by RequestFactory from Swerve's request the same way ServerRequestFactory::fromGlobals() builds them from $_FILES.

Cake's streamed responses (CallbackStream, JsonStreamResponse) still hold the worker: PHP's output buffers belong to the process, so Handler runs the callback under an ob_start() handler and the same Synchronized lock, feeding what it echoes to the client as it comes — no other Cake request runs until the stream ends.

public function stream(): Response
{
    return $this->response->withType('text/plain')->withBody(new CallbackStream(function () {
        echo "first\n";
        $this->wait(0.5);   // the worker's other Cake requests wait too
        echo "last\n";
    }));
}

For Server-Sent Events and other long-lived streams, fill a phasync\Psr\UnbufferedStream from a coroutine instead. It holds no lock, so the worker serves other requests while it's open:

public function events(): Response
{
    $stream = new UnbufferedStream(1, 10);
    phasync::go(function () use ($stream) {
        for ($i = 1; $i <= 3; $i++) {
            $stream->append("data: event $i\n\n");
            phasync::sleep(0.2);
        }
        $stream->end();
    });

    return $this->response->withType('text/event-stream')->withBody($stream);
}

Don't use the router, the session or the ORM's connection inside that coroutine: by the time it runs, the worker may already be serving another request. WebSockets work the same way: a controller action returns CakeResponse::from(WebSocketResponse::from($this->request, function (WebSocket $ws) { ... })), and the callback runs in its own coroutine after the action returns, beside the worker's later requests. Swerve::publish()/Swerve::subscribe() give it server push from any route in any worker. Inside the callback, Router::getRequest() and Router::url() describe whichever request the worker serves then — maybe someone else's — and the session throws, as above.

Dangerous under concurrency#

DebugKit#

DebugKit's plugin builds one panel registry at boot; Handler's reset calls registry()->reset() on it before each request, so the toolbar reflects that request alone, and /debug-kit/toolbar/<id> keeps working. It costs nothing extra to leave DebugKit loaded between requests — it isn't rebuilt — but it still does its usual per-request work (profiling the request, the SQL log), so treat DEBUG=true workers the way you'd treat a debug PHP-FPM pool: fine for staging, not for a worker also carrying production traffic.

Your application#

The adapter runs one request at a time, but the worker outlives every request: a static or a table's state set by one request is still there for the next, and WebSocket callbacks and streams fed by phasync::go() run outside the lock, overlapping with other requests. The framework running correctly on Swerve does not make your own code safe: see Making your application safe for concurrent requests for the checklist and a fix for each item.

Two routes would let the adapter (or your application) drop the lock for the state above. The simple one: save and restore that state in a phasync::onScopeChange() hook, which runs whenever the worker switches from one request to another. It is phasync's own API (an application relying on it runs on phasync only), it runs on every switch so it should swap only a few references, and it covers only the state you swap. The proper one: key the state on the request's \Scope (Scope::getCurrent(), the Scope RFC), so nothing needs swapping. $_GET, $_POST, $_COOKIE, $_FILES, the default time zone and Locale::setDefault() already follow the Scope; $_SESSION and setlocale() do not.

Debugging#

See also#

  • SynchronizedA mutex for coroutines, identified by a string or an object: only one coroutine at a time runs a closure for the same token.
  • Swerve::logThe log of this process: swerve's own PSR-3 logger, in its format (the time, this worker's slot).