Runtime independence

Kaly is an HTTP framework, not an execution runtime.

The same booted application may be hosted by:

  • a traditional request-per-process SAPI (PHP-FPM, PHP built-in server);
  • a sequential long-lived worker (FrankenPHP, RoadRunner);
  • a runtime executing several requests concurrently (Fibers, event loop).

Kaly therefore follows these rules:

  1. Kernel and application services must not store request state.
  2. Request state belongs to HttpContext or objects created for that request.
  3. The application container is application-scoped: what it shares must be safe to share.
  4. Kaly does not own an event loop, Fiber scheduler or async API.
  5. Blocking vs suspendable I/O is a property of application/runtime adapters, not of the Kaly kernel.
  6. Native PHP process-global facilities may have stricter runtime limitations — see below.
  7. Kaly never requires a cache, and never writes one that changes the behavior of the application — see below.

Consequences

Middleware objects resolved from the container are application-scoped. They may be reused by sequential or concurrent requests and must be safe to re-enter: request-specific state belongs in local variables, the PSR-7 messages or HttpContext — never in a middleware property. Shared mutable state is application state and is legitimate (a metrics counter, a shared rate limiter); per-request state in a property (a current user) is a bug.

WorkerTest locks the sequential contract (one cycle finishes, the next starts clean); ConcurrentRequestTest locks the concurrent one (two interleaved cycles on one booted app, each keeps its route, locale, cookies, session, middleware trace and response — through the same shared middleware instance).

Each request band snapshots its ordered middleware entries when entered. A registry change affects later band entries, including later requests, but cannot change the remaining steps of a band already running or suspended in a Fiber. Configure middleware before serving requests so the whole application pipeline stays consistent across cycles. Conditions still evaluate against the current request context at each step.

No cache, a worker instead

A persistent cache of routes, modules or definitions is a second source of truth: it must be invalidated, and a stale one means a route that silently stops working. Kaly does not have one. What it keeps is memoization in memory: route tables are compiled on their first use, controller reflection is kept per class. It dies with the process, and the code cannot change under a running process, so there is nothing to invalidate.

The real optimization is to boot once. Measured with composer bench (see Benchmarks):

  per request
PHP-FPM, one process per request autoload ~3 ms + boot ~7 ms + request ~3 ms
worker, after the first request ~0.02 to 0.1 ms

Boot does not grow with the size of the application: a url only compiles the routes of the module it reaches.

Worker mode

A booted app handles any number of requests. Nothing but the request changes between two cycles, and a failing cycle does not affect the next one.

FrankenPHP refreshes the superglobals for each request, so run() works as is:

<?php
// public/index.php
require __DIR__ . '/../vendor/autoload.php';

$app = Kaly\Core\App::create(dirname(__DIR__))->boot();

$handler = static fn() => $app->run();
while (frankenphp_handle_request($handler)) {
    gc_collect_cycles();
}

RoadRunner hands PSR-7 requests: use handle() and give the response back.

<?php
use Spiral\RoadRunner;

require __DIR__ . '/../vendor/autoload.php';

$factory = new Nyholm\Psr7\Factory\Psr17Factory();
$worker = new RoadRunner\Http\PSR7Worker(RoadRunner\Worker::create(), $factory, $factory, $factory);

$app = Kaly\Core\App::create(dirname(__DIR__))->boot();

while ($request = $worker->waitRequest()) {
    try {
        $worker->respond($app->handle($request));
    } catch (Throwable $e) {
        $worker->getWorker()->error((string) $e);
    }
}

SapiWorkerTest simulates the FrankenPHP loop (superglobals refreshed, run() called again and again) and WorkerTest the PSR-7 one.

Cookies

Cookies are the model every request-scoped state should follow:

Kaly never emits cookies through PHP globals. Cookies are read from the PSR-7 request and emitted as Set-Cookie headers on the PSR-7 response.

Cookies holds no shared state, so concurrent cycles are isolated by construction. The application baseline (path, domain, secure, httponly, samesite…) lives in the CookiePolicy service bound per App (immutable, never a process-global default), shared by Cookies and the session cookie; cookie values stay request-scoped. SetCookieHeader is the stateless primitive underneath: name + value + params in, header string out.

Native PHP sessions

NativePhpSession wraps the process-global $_SESSION. It is safe for sequential execution: each active native session belongs to exactly one instance. Other instances cannot adopt or close it. Kaly resets the native id and data on close, and the kernel closes storage in a finally block after terminate hooks, including when a hook reopens committed storage or error recovery fails. Two requests running concurrently in the same process must not share native storage. Outside the kernel, callers must close their session in a finally block. destroy() also invalidates persisted storage before the first read or after close().

NativePhpSessionProvider (App's default)
    sequential worker    ✓
    concurrent Fibers    ✗

a provider returning request-scoped storage
    sequential worker    ✓
    concurrent Fibers    ✓

Concurrent runtimes bind a SessionProviderInterface returning request-scoped storage. The provider owns transport (session id lookup, Set-Cookie emission) while SessionInterface stays a backend-agnostic applicative contract (get/set/has/remove/clear/pull/all + regenerateId/destroy). Cookie- or server-backed session policies are a decision of the application or of a dedicated package, not of the Kaly core.

Session IDs rotate only through explicit regenerateId() calls, including login and logout. There is no periodic rotation and _expiry is ordinary application data. Native explicit rotation deletes the old storage immediately to invalidate the old credential. Applications that need periodic rotation with concurrent requests must use a backend with an explicit, bounded ID transition policy; merely retaining the old PHP session file is insufficient. See the PHP rotation warning.

Cookie duration is an explicit provider option (lifetime) or CookiePolicy setting and remains the same when a subsequent request rotates its ID. The provider does not inspect POST _remember. A persistent cookie alone is not a remember-me authentication system: applications must implement token expiry, revocation and renewal in their authentication policy or a dedicated provider. The former regen_interval, expiry_key, remember_lifetime and remember_key options have been removed and are rejected; remove them from provider configuration. Existing _expiry session entries may be cleared by the application and otherwise have no effect.

ArraySession is concurrency-safe but persists nothing between requests: tests and isolated cycles only, never a production backend.


Kaly PHP framework — MIT

This site uses Just the Docs, a documentation theme for Jekyll.