Http context

PSR-7 and PSR-15 give an excellent interop protocol, but not an application model. When everything is “just a middleware”, you lose the notion of phase, of dependency and of already established context.

Kaly keeps PSR as the boundary and adds Kaly\Core\HttpContext as the request scoped state of the application: PSR for interop, HttpContext for richness, bands for order.

one request -> one context -> the whole cycle -> one response

What it holds

$ctx->request();    // the current PSR-7 request
$ctx->route();      // the matched route
$ctx->locale();     // the locale of the request
$ctx->url('shop:product', ['slug' => 'velo']); // an url for the request locale
$ctx->session();    // the session of this request
$ctx->cookies();    // the cookies of this request
$ctx->response();   // the final response, once the cycle is over
$ctx->requestId();  // a random id of this cycle, for logs and support
$ctx->profile();    // optional native timings, enabled with App::profiling()

Profiles belong to this cycle and are absent when profiling is disabled. The response-production duration is complete before terminate hooks; boot is tracked separately on App::bootProfile(). See the Server-Timing recipe for measured stages and export.

Instead of probing the request:

$route = $request->getAttribute('route');
$locale = $request->getAttribute('locale');
if ($router !== null) { ... }

you read established state directly:

$ctx->route()->module;
$ctx->locale();

Strict accessors, not nullables

The accessors are strict on purpose. Behind the routing step — that is, in the routed band, in the dispatcher and in every controller — route() and locale() are guaranteed. Calling them earlier is a programming error and throws a LogicException instead of returning a null that then contaminates every caller downstream.

The outgoing phase is the exception to that guarantee: it also receives responses produced before routing completed — an incoming short-circuit or a routing 404 — so route() and locale() are not guaranteed there. Guard with hasRoute() / hasLocale() before reading them.

That is exactly what removes the need for capability checks everywhere: you never ask “do we have a route?”, the band you registered in already answers it.

For genuinely optional cases — an error reporter that may run before routing, a debug toolbar — there is a matching has*():

$app->onError(function (Throwable $e, HttpContext $ctx): void {
    myTracker()->report($e, [
        'route' => $ctx->hasRoute() ? $ctx->route()->controller : null,
    ]);
});

An incoming middleware can still impose a locale before anything is routed, and a locale carried by the route still wins over it:

$ctx->useLocale('fr');

For an application-designed /{locale}/... topology, the explicit RouteLocale middleware (Core, routed band) projects the validated route parameter onto the context instead of repeating $ctx->useLocale($locale) in every controller — see Routing.

Getting it

The context travels as the one and only kaly request attribute, so third party PSR-15 middlewares simply ignore it and nothing new has to be implemented:

use Kaly\Core\HttpContext;

public function process(
    ServerRequestInterface $request,
    RequestHandlerInterface $handler,
): ResponseInterface {
    $ctx = HttpContext::from($request);

    // ...

    return $handler->handle($request);
}
  • HttpContext::from($request) throws when no context is attached.
  • HttpContext::tryFrom($request) returns null instead.

The Kernel creates the context for every cycle and binds it to the request; middlewares and controllers only ever retrieve it. There is no “create if absent” helper: a missing context is a broken pipeline, not a default.

In a controller extending Kaly\Core\AbstractController, use the ctx() helper:

public function index(): string
{
    return $this->ctx()->locale();
}

Request attributes are not meant to be used as a general application storage anymore. Keep a single kaly attribute, the context, and put the rest inside it.

Request and response

The PSR messages stay immutable, and there is exactly one way to change the request: hand a new one to the next handler. The pipeline tracks it, so $ctx->request() is always the current one.

return $handler->handle($request->withAttribute('foo', 'bar'));

Every step rebinds the context, so a middleware handing over a brand new request object never loses the cycle.

The response is not mirrored during the unwind: a middleware already owns the one returned by its own handler.

$response = $handler->handle($request);
return $response->withHeader('X-Foo', 'bar');

$ctx->response() has a narrower and sharper meaning: the final response of the request, available from finalization onwards, which in practice means in an onTerminate() hook. Before that, it throws (hasResponse() tells you).

Session and cookies

The context owns them, so the same instance is shared for the whole cycle:

$ctx->session()->set('user', $id);
$ctx->cookies()->set('theme', 'dark');

This matters more than it looks. Both objects snapshot what the request arrived with (NativePhpSession captures its data when started, Cookies its received cookies in the constructor) in order to know what changed. A per-request wrapper around the PSR request cannot hold them: every withHeader() / withAttribute() rebuilds the wrapper, so the snapshot would silently reset and the dirty tracking would lie. The context is the natural owner because it survives those mutations.

There is nothing to wire to get them on the response. Once the request pipeline produced a response, error responses included, and the outgoing phase ran, the kernel commits the context: the session is saved and its cookie added when needed, cookie changes become Set-Cookie headers. A login that writes the session and then throws a RedirectException keeps its session, and a session written by an outgoing middleware is committed like any other. A session or cookie jar that was never touched costs nothing.

... -> dispatcher -> outgoing -> [commit session + cookies] -> terminate

HttpContext does not choose a session backend. The application injects a provider, or the caller may impose an externally managed session with useSession(). App binds NativePhpSessionProvider, which wraps the process-global $_SESSION: fine for sequential workers, unusable for concurrent ones. Such runtimes bind a SessionProviderInterface returning request-scoped storage (or impose one per cycle with useSession()). A context built without a provider cannot create a session: session() refuses to guess and throws “no session provider is configured”. A session imposed with useSession() is never persisted unless a provider is also present. The provider also owns persistence: it reads the session id from the request and writes the Set-Cookie header on commit, so SessionInterface itself never sees PSR-7.

Session storage is lazy even after calling $ctx->session(). Without a session id or active storage, get() returns its default, has() returns false, and all() returns []: no storage is opened and no session cookie is emitted. remove(), clear() and pull() also leave missing storage unopened. A stored null is still returned by get() and pull(); has() reports only non-null values. set() and regenerateId() create a session when needed.

With a session cookie, reading loads the session normally. Native PHP strict mode still replaces unknown or expired ids with fresh ids when storage is opened. Closing storage keeps its id, so later operations on the same instance can reopen it. Destroying storage clears the id: subsequent reads stay empty and commit expires the incoming cookie; a later write creates a new session. ArraySession and the test memory backend share these lazy read semantics.

Cookies need no such backend choice: they are emitted as Set-Cookie headers on the PSR-7 response, never through PHP globals. Both share the CookiePolicy service bound per App (historical baseline: browser-session lifetime, httponly, Lax). See Runtime.

Plain PSR requests keep their helpers as static functions in Kaly\Http\RequestUtils:

use Kaly\Http\RequestUtils;

RequestUtils::getPreferredLanguage($request, ['en', 'fr']);
RequestUtils::isXhr($request);

Which middlewares ran

The pipeline marks every middleware that really entered, so nothing has to register itself:

$ctx->middlewares();
// [
//     RequestIdMiddleware::class, // example name — provide your own PSR-15 middleware
//     SessionMiddleware::class,
//     AuthMiddleware::class,
// ]

$ctx->hasMiddleware(AuthMiddleware::class); // example class name

This is the list of middlewares that were executed, not the ones that were merely registered: a middleware whose condition returned false is absent.

It is extremely useful for a debug toolbar, diagnostics, exceptions, tests and understanding why a context holds a given piece of data. It is not a dependency API though: to get the current user, read the accessor, never

// don't
if ($ctx->hasMiddleware(AuthMiddleware::class)) { // example class name

The pipeline

The context is created by the Kernel and travels through the whole cycle:

App
 +- Kernel
      +- creates and binds HttpContext
      |
      +- INCOMING middleware
          |
          +- trusted proxy / request id / static files / global limits...
          |
          +- RoutingHandler            <- fixed kaly step
               +- match Route
               +- resolve locale
               |
               +- ROUTED middleware
                    |
                    +- auth / authorization / CSRF / route rate limit
                    |
                     +- ROUTE middlewares (route table, module)
                    |
                    +- RequestDispatcher
                         +- controller -> response

     <- exception -> response        <- kernel, whatever the origin
     +- OUTGOING middleware          <- Response -> Response, attempted once
     |    (on failure: error response, then `always` middlewares only)
     +- commit session + cookies     <- final; a failed commit also recovers on `always`
     +- complete($response)
     +- onTerminate($ctx)
     +- response

RoutingHandler is not configurable: it is what guarantees that anything registered as routed already knows its route. RequestDispatcher is a plain RequestHandlerInterface that no longer routes, it only goes from a resolved route to a response — and because the route comes from the context, it can be tested with no router at all:

$ctx = new HttpContext($request);
$ctx->useRoute($route);
$ctx->useLocale('en');

$response = $dispatcher->handle($ctx->request());

See middlewares for how to register into the three phases.


Kaly PHP framework — MIT

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