Skip to content
polidogPublic

About

Resources

Stars

6 stars

Watchers

1 watching

Forks

Repository files navigation

Relayer

English · 日本語

Opinionated, batteries-included framework on top of polidog/rosanna and polidog/rosanna-defer. Bundles:

  • A Next.js App Router-style file-based router (src/Pages/page.psx, layout.psx, dynamic segments, error pages)
  • File-based JSON API routes (src/Pages/.../route.php) — a method-keyed map of autowired handlers, each returning a Response (Response::json() / text() / noContent() / redirect()); OPTIONS / HEAD synthesized like Next.js Route Handlers
  • Native route handlers in Go (src/Pages/.../route.go, func GET(w, r) and friends) — compiled by relayer native:build, run per request as a CGI process via the Go stdlib; the Next.js "native language support" prototype's Route Handlers, for CPU-heavy endpoints — and its Proxy: src/Pages/middleware.go with a func Proxy(w, r) as the root middleware, continuing via a Relayer-Next header
  • Per-page / per-layout external scripts ($ctx->js() / PageComponent::addJs() / LayoutComponent::addJs()) emitted at the end of <body> after the bundle, in declaration order
  • React islands (Island::mount()) — a rich-UI escape hatch: server-rendered shell, client React component, props from PHP, your own bundle
  • Optional root middleware (src/Pages/middleware.php) wrapping every dispatch, plus ready-made Cors, OriginGuard (reject requests that bypass your CDN) and TrustedProxy (real client IP behind a proxy) middlewares — the latter two wired from .env (ORIGIN_SECRET, TRUSTED_PROXIES) as a router guard that runs before everything
  • CSRF-protected server actions ($ctx->action() / PageComponent::action() dispatch form posts to in-page handlers)
  • Symfony DependencyInjection for service wiring (autowire, YAML/PHP config auto-load)
  • symfony/dotenv for .env loading with the standard .env / .env.local / .env.{APP_ENV} cascade
  • #[Cache] attribute for HTTP cache headers + If-None-Match 304 handling with pluggable EtagStore (file-based default, Redis-ready)
  • Session-based authentication: #[Auth] attribute / $ctx->requireAuth(), role checks, password hashing, pluggable UserProvider and SessionStorage
  • Bearer token auth: server-side Firebase / Cognito ID-token verification (JWKS, cached + rotation-aware) — stateless API or verify-then-session, same #[Auth] guard
  • Zod-style schema validation (Validator::object(), safeParse / parse, form-input coercion + per-field errors)
  • A dev-only request profiler (/_profiler view, no-op in production)

Exposes a single Relayer::boot() entrypoint so app code stays small.

Requirements

Installation

composer require polidog/relayer

Scaffold a new project

relayer init lays the project structure into the current directory. Run it from your project root after requiring the framework:

composer require polidog/relayer
vendor/bin/relayer init
composer install
php -S 127.0.0.1:8000 -t public

composer install (rather than dump-autoload) so the App\ autoload and the publish scripts init just added both apply — the latter emits public/rosanna-defer.js, which the default document references on every page.

It is idempotent and non-destructive:

  • existing files are never overwritten (they are reported as skipped), so it is safe to re-run;
  • your existing composer.json is patched additively — it adds the App\ PSR-4 autoload, the rosanna-defer asset-publish scripts, and an extra.relayer.structure_version marker, and leaves everything else untouched;
  • .env.local (APP_ENV=dev) is written only into a new project: one with no .env, no marker, and none of src/Pages, src/AppConfigurator.php, public/index.php or public/worker.php. An existing app keeps its environment, and init tells you how to add dev mode yourself.

The structure_version marker records which skeleton shape the project was generated against, so relayer upgrade (below) can migrate it forward.

init also scaffolds RELAYER.md — concise, authoritative coding conventions for agents/LLMs working in the project (file conventions, the route.php / middleware.php / Island contracts, the minimal-design philosophy, a "do not" list) — plus 2-line AGENTS.md and CLAUDE.md pointers to it (the filenames agent tools / Claude Code auto-read). All ship inside polidog/relayer, so they are co-versioned with the framework and cannot drift, and all are skip-if-exists, so a project's own AGENTS.md / CLAUDE.md is never overwritten. It additionally scaffolds Claude Code tooling under .claude/ — a relayer-routing skill (the routing / Response / CSRF contracts, trigger-scoped) and a relayer-reviewer subagent that reviews changes against RELAYER.md. Both defer to RELAYER.md as the single source of truth and are co-versioned + skip-if-exists for the same reason. Run vendor/bin/relayer routes for the project's actual route map.

Upgrading the project structure

When you bump polidog/relayer, newer framework versions may add files to the generated skeleton. relayer upgrade brings an existing project up to the installed framework's structure:

composer update polidog/relayer
vendor/bin/relayer upgrade
composer install

It reads the extra.relayer.structure_version marker, applies the deltas between it and the current one, then advances the marker (the one mutation init deliberately never makes). New files are skip-if-exists, so a file you have edited is kept and reported as skipped (=).

A version can also change the content of a file it shipped earlier — the Dockerfile gaining a production target, say. Those are reported separately and gated on the file being untouched: upgrade replaces your copy only when it is still byte-for-byte what some framework version wrote (~). If you have edited it at all, it is left exactly as it is and listed as kept (!), with a note that the template moved on; take the new one by saving your changes, deleting the file and running relayer init. So the command can update a stale template without ever overwriting your work.

The scope is exactly the structure deltas plus the marker — it does not touch composer scripts or autoload (re-run relayer init for those — it is additive and safe). It is idempotent: once at the current version it reports nothing to do. If the project has no marker it was not created by relayer init; run init first to stamp the current shape.

Project Layout

your-app/
  .env                 # loaded automatically if present
  composer.json
  config/
    services.yaml      # auto-loaded if present (also services.php / .yml)
  public/
    index.php
  src/
    Pages/             # AppRouter file-based routes live here
      layout.psx
      page.psx
      about/
        page.psx
    AppConfigurator.php # your service registrations (extends Polidog\Relayer\AppConfigurator)

Quick Start

public/index.php:

<?php
declare(strict_types=1);

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

use Polidog\Relayer\Relayer;

Relayer::boot(__DIR__ . '/..')->run();

That's the whole entrypoint. boot() will:

  1. Load .env from the project root (if present) into $_ENV / $_SERVER.
  2. Build a Symfony ContainerBuilder, auto-load config/services.{yaml,yml,php} if present, then let AppConfigurator register services on it.
  3. Compile the container and wrap it in a PSR-11 adapter for AppRouter.
  4. Enable autoCompilePsx automatically when APP_ENV=dev.

The returned AppRouter is fully configured. You can still customize before running:

$router = Relayer::boot(__DIR__ . '/..');
$router->setJsPath('/assets/app.js');
$router->addCssPath('/assets/style.css');
$router->run();

The default document loads rosanna-defer.js on every page. To load it only on pages that rendered a defer placeholder, opt in:

use Polidog\Relayer\Router\Document\HtmlDocument;

$router = Relayer::boot(__DIR__ . '/..');
$router->setDocument(HtmlDocument::create()->loadClientScriptOnlyWithDefer());
$router->run();

A page without a placeholder then has no window.Rosanna. A logout form on such a page cannot call clearDeferCache(), so a user-specific localCache fragment survives logout and a shared device can show it to the next user. Use the option only if no page without a placeholder needs window.Rosanna.

Environment Variables

Put a .env in the project root:

APP_ENV=dev
DATABASE_DSN=mysql:host=127.0.0.1;dbname=app;charset=utf8mb4
DATABASE_USER=app
DATABASE_PASSWORD=secret

# i18n (all optional — see Internationalization)
APP_LOCALE=en
APP_LOCALES=en,ja
LOCALE_COOKIE=locale
LOCALE_PATH_PREFIX=true

DATABASE_* is optional — the database layer is wired only when DATABASE_DSN is set (see Database). APP_LOCALE / APP_LOCALES / LOCALE_COOKIE / LOCALE_PATH_PREFIX are optional too — an app that sets none stays single-locale English at no cost (see Internationalization).

.env files are loaded through symfony/dotenv with the standard Symfony cascade:

  1. .env — committed defaults
  2. .env.local — local overrides (gitignored)
  3. .env.{APP_ENV} — per-environment defaults (committed)
  4. .env.{APP_ENV}.local — per-environment local overrides (gitignored)

Missing files are skipped silently. Variables already in $_ENV / $_SERVER / getenv() (real environment variables) win over every one of these files, .local included; among the files, the .local ones override the committed counterparts.

APP_ENV=dev (or development) enables PSX auto-compilation. Any other value treats the app as production, as does APP_ENV being set nowhere: neither in the real environment nor in any of these files. For a new project, relayer init writes APP_ENV=dev to the gitignored .env.local, so a deploy that forgets it runs as production; after a fresh clone, run echo APP_ENV=dev >> .env.local. In production, pre-compile with vendor/bin/rosanna-defer compile src/Pages during deploy.

Routing & Pages

The router scans src/Pages/ and maps the filesystem to URLs in the spirit of the Next.js App Router. The conventions:

File Role
page.psx Renders the route. One per directory.
layout.psx Wraps every nested page; layouts stack from root to leaf.
error.psx Error page for 404 / 405 / $ctx->abort() / auth 401+403 / uncaught exceptions (root only).
route.php JSON API route (no HTML). Method-keyed handler map. One per directory.
[param]/ Dynamic segment; captured into $this->getParam('param').
(group)/ Route group: organises files without adding a URL segment. May hold its own layout.psx.
_private/ Opts the folder and everything under it out of routing entirely.

.psx is the JSX-style source. The runtime executes the compiled *.psx.php sibling — produced automatically in dev (APP_ENV=dev) or by vendor/bin/rosanna-defer compile src/Pages at deploy time. Plain .php page files also work and skip the compile step.

Compiled routes (production). By default the router scans src/Pages/ on every request. Run vendor/bin/relayer routes:compile at deploy and it writes a readable, portable snapshot to var/cache/routes/routes.php; production then reads that one OPcache-warm file instead of walking the tree. It is presence-gated — no file means a live scan, so dev always reflects the current tree and never goes stale. Scan-time ambiguities (page/route.php clashes, route-group URL collisions) fail the compile at deploy rather than on the first request.

Compiled DI container (production). Otherwise Relayer::boot() builds and compile()s the Symfony DI container on every request — typically the largest avoidable per-request cost. vendor/bin/relayer container:compile dumps it to a plain PHP class at var/cache/container/CompiledContainer.php; production requires that instead of rebuilding. Same presence-gated, dev-excluded contract as compiled routes (no file ⇒ live build; dev always reflects current config). A bad service definition fails the dump at deploy, not on the first request.

Deploy checklist. Build the three artifacts and ship an OPcache-warm, scan-free image:

composer install --no-dev --classmap-authoritative
vendor/bin/relayer compile                # all three, in order, stop on first failure

relayer compile is shorthand for the three standalone steps, which you can still run individually (e.g. to skip container:compile — see "Runtime secrets" below):

vendor/bin/rosanna-defer compile src/Pages                  # .psx  -> compiled PHP (add src/Components if it exists)
vendor/bin/relayer routes:compile                     # route map -> PHP
vendor/bin/relayer container:compile                  # DI container -> PHP

Then set APP_ENV=prod, in .env.local or as a real environment variable, and run with OPcache validate_timestamps=0 (the scaffolded php.ini carries the production block). Make sure no .env.local with APP_ENV=dev reaches the server: it exposes /_profiler. Each step is presence-gated, so a missing artifact degrades to the live path rather than breaking.

relayer compile deliberately does not include vendor/bin/relayer native:build (Go route handlers, see Native route handlers (Go)): it needs a Go toolchain, which a PHP-only image has no reason to carry. Run it as its own step when the project has a route.go.

Read-only filesystem. The scaffolded Dockerfile runs all three steps in its prod target, so the image generates nothing at runtime and can be served with an immutable filesystem. compose.yaml ships that shape as a commented app-prod service: read_only: true plus a small tmpfs on each path still written — the ETag store (var/cache/etags), PHP sessions and upload temp files (both relocated under var/cache/ by the prod image), and Caddy's own /data + /config (the FrankenPHP image points XDG_DATA_HOME / XDG_CONFIG_HOME there and declares no VOLUME, so a read-only container cannot start without them). Do not mount over var/cache/ as a whole: the compiled routes, container and .psx are built into the image, and an empty tmpfs on top hides them — every request then silently falls back to the per-request scan and rebuild the artifacts exist to remove.

Runtime secrets vs container:compile. container:compile runs AppConfigurator::configure() once, at deploy, and bakes whatever value the configurator passes to setParameter() into the dumped class. Production boot only requires the dump — the configurator never re-runs. So code that reads env vars directly and hands the resolved string to the container will freeze whatever value the env had at compile time:

// trap: bakes "" if API_TOKEN is unset at compile time
$container->setParameter('app.api_token', $_ENV['API_TOKEN'] ?? '');

Under deployments that inject secrets only at boot (Fly secrets, Cloud Run env, sidecar injectors, …), the build environment legitimately has those vars unset, so the dump ships with empty strings — and downstream "is this configured?" checks silently take their fallback branch in production.

Use Symfony DI's %env(VAR)% placeholder instead. PhpDumper emits it as a getEnv('VAR') call in the dumped class, so the value resolves at load time against the live env:

// resolves at boot, not at compile — runtime-injected secrets work
$container->setParameter('app.api_token', '%env(API_TOKEN)%');

Typed / fallback resolution is available via %env(int:DB_PORT)%, %env(bool:FEATURE_FLAG)%, %env(default::API_TOKEN)%, etc. The same placeholders work in config/services.yaml (the bundled Polidog\Relayer\Auth\Token\TokenVerifier example already uses them).

routes:compile is unaffected — it only scans src/Pages/ and reads no env. Only container:compile carries the env coupling. If a deployment model can't accommodate the %env(...)% rewrite, dropping container:compile while keeping routes:compile and accepting the per-request ContainerBuilder::compile() cost is a valid fallback.

Class-style page

<?php
// src/Pages/users/[id]/page.psx
declare(strict_types=1);

namespace App\Pages\Users;

use App\Service\UserRepository;
use Polidog\Rosanna\Runtime\Element;
use Polidog\Relayer\Router\Component\PageComponent;

final class UserDetailPage extends PageComponent
{
    public function __construct(private readonly UserRepository $users) {}

    public function render(): Element
    {
        $user = $this->users->find($this->getParam('id'));
        return <h1>{$user->name}</h1>;
    }
}

Constructor injection runs through the DI container — see Injecting Services Into Pages.

Function-style page

You can return a closure instead of declaring a class. The factory closure is autowired the same way class-style page constructors are: declare any typed parameter and the framework will inject it.

<?php
// src/Pages/about/page.psx
return fn() => <main><h1>About</h1></main>;

Services from the container are resolved by type — PageContext is the per-request handle, every other typed parameter comes from the DI container:

<?php
// src/Pages/users/page.psx
declare(strict_types=1);

use App\Service\UserRepository;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx, UserRepository $users): Closure {
    $ctx->metadata(['title' => 'Users']);

    return function () use ($users): Element {
        $list = $users->all();
        return <ul>{\array_map(fn($u) => <li>{$u->name}</li>, $list)}</ul>;
    };
};

The factory closure runs once per request. The inner render closure runs only when the response is not a 304 — keep heavy work there (see Function-style pages: $ctx->cache()).

Layouts

Each layout.psx wraps every page beneath it. Layouts stack:

src/Pages/
  layout.psx          # outer shell
  dashboard/
    layout.psx        # dashboard frame
    page.psx          # /dashboard
    users/
      page.psx        # /dashboard/users — sees both layouts

Error pages

A root error.psx (extending ErrorPageComponent) renders error responses inside the root layout — an unmatched route (404), an unsupported method on a page (405), every $ctx->abort() status, the 401 / 403 from #[Auth] / $ctx->requireAuth(), and an uncaught exception (500). If the root layout itself fails an auth guard or aborts while rendering the error, error.psx renders without it; if error.psx fails the same way, the default document is used. The status is unchanged either way. It receives the status and message via ErrorPageComponent, so one error.psx can branch on getStatusCode() for a 404 vs. 403 vs. 500 page — don't hard-code "not found" wording in it. Without one, the framework emits a minimal default document.

Uncaught exceptions

Anything thrown by a page, layout, deferred component, route.php/route.go dispatch or middleware.php that nothing else handles becomes a 500. The control-flow exceptions (abort(), notFound(), redirect(), requireAuth()) are not affected.

APP_ENV=dev Otherwise
Page Built-in page with the stack trace (error.psx is not used) error.psx with the generic message, or the default document
route.php / route.go {"error": "...", "exception": {class, message, file, line, trace}} {"error": "Internal Server Error"}
  • The client never sees the exception's message or trace outside dev. A route.php handler that doesn't return a Response is answered the same way.
  • The exception goes to the PSR-3 logger at error (PHP's error_log when none is bound or it fails) and, in dev, onto the profiler timeline.
  • The response is buffered until it is complete, so output written before the failure is dropped, along with the failed response's Cache-Control, ETag and similar headers; the 500 is sent with Cache-Control: no-store. Buffers the failed code left open are closed, and run() returns normally, so a worker keeps serving. CORS headers and cookies already set are kept.
  • If the code already pushed the response out (flush() after closing the buffers), the status can't change: the client sees a truncated response and the failure is only logged (in dev the trace is appended).
  • If error.psx or the root layout it renders in throws too, both exceptions are logged and the default document answers. There is no second attempt, so an error.psx that fails can't recurse.
  • middleware.php wraps dispatch, so it can still catch exceptions from $next() itself, and such an exception is not reported by the framework. After an uncaught exception the code following $next() doesn't run, so set headers that error responses need (as Cors does) before calling it.

Control flow: redirect() / notFound() / abort()

A page factory or action handler signals an HTTP outcome by intent, never by touching http_response_code() / header(). Each throws and unwinds — code after the call does not run — and the router turns it into the right response:

Call Result
$ctx->redirect($to, $status = 303) Location response. Default 303 See Other — correct after a POST (Post/Redirect/Get).
$ctx->notFound() 404 → error.psx / fallback. Alias for abort(404).
$ctx->abort($status) Any 4xx/5xx → error.psx with that status / fallback. Non-error codes throw InvalidArgumentException.
return function (PageContext $ctx) use ($posts): Closure {
    $post = $posts->find($ctx->params['id']) ?? $ctx->notFound();
    if ($post->isDraft && null === $ctx->user()) {
        $ctx->abort(403);
    }

    return fn (): Element => <article>{$post->title}</article>;
};

From a route.php handler these stay on the JSON surface: notFound() / abort() become a JSON error with that status (never the HTML error page), while redirect() still produces a content-type-neutral Location response. http_response_code() stays a framework internal — the one place you still set a status by hand is middleware.php, which has no PageContext (it runs before route dispatch).

API Routes

A route.php file is a JSON endpoint instead of a rendered page. It returns a map keyed by HTTP method; each handler is autowired exactly like a function-style page factory (PageContext, Request, Identity, and container services inject by type) and returns a Response — no layout or HTML pipeline runs. This mirrors Next.js Route Handlers (method-keyed handlers + a Response object), adapted to PHP: the map stays the declaration-free contract (a route.php is required fresh per request, so top-level function GET() is not an option), while autowiring is kept because it is consistent with how pages resolve their arguments.

<?php
// src/Pages/api/users/route.php
declare(strict_types=1);

use App\Service\UserRepository;
use Polidog\Relayer\Http\Request;
use Polidog\Relayer\Http\Response;

return [
    'GET'  => fn (UserRepository $users): Response => Response::json(['users' => $users->all()]),
    'POST' => function (Request $req, UserRepository $users): Response {
        $users->create($req->allPost());
        return Response::json(['ok' => true], 201);
    },
];
  • Lives in src/Pages/ alongside pages, with the same [param] dynamic segments — read them via $ctx->params['id']. Params are percent-decoded once (a%20b → a b, a%2Fb → a/b, still one segment), so treat them as untrusted text. A directory is a page or a route, not both (the scanner errors if it finds both).

  • A handler must return a Response. Build it with Response::json($data, $status, $headers) (encodes + sets Content-Type: application/json, slashes/unicode unescaped), Response::text(), Response::noContent() (a bodyless 204 by default), Response::redirect($location, $status), or Response::make() for a raw body. Status and headers are always explicit — there is no raw-data return path; returning anything else is a hard server error.

  • Set cookies with ->withCookie($name, $value, $options) and delete them with ->withoutCookie($name, ['path' => …]). They are emitted with setcookie(), which appends — so a session cookie PHP already queued (e.g. by Authenticator::login() regenerating the session id) survives:

    return Response::redirect('/admin')
        ->withCookie('staff', '1', ['expires' => new DateTimeImmutable('+1 year')]);

    Defaults are path /, httponly true, samesite Lax, secure false and expires 0 (a browser-session cookie); override any of them (plus domain, a bare host name — no scheme or port) via $options. __Secure- / __Host- prefixed names default to secure true. A name / path / domain the browser would reject, SameSite=None without secure, or a broken prefix rule (__Secure- / __Host- without secure; __Host- with a domain or a path other than /) throws InvalidArgumentException when the response is built. Don't use withHeader('Set-Cookie', …) for cookies — it holds only one and skips that validation (it no longer replaces other Set-Cookie headers, though).

  • OPTIONS and HEAD are synthesized to match Next.js: an undeclared OPTIONS → 204 + Allow, an undeclared HEAD runs the GET handler and drops the body. An explicit handler for either always wins. A method with no handler (and not synthesizable) → 405 + Allow (JSON body).

  • route.php must only return the map (no class/function declarations); it is re-evaluated every request.

  • Auth uses the same $ctx->requireAuth() / Identity mechanism as pages, but a failure is a JSON 401 (anonymous) or 403 (wrong role) — not the HTML-login 302 pages emit. $ctx->notFound() / $ctx->abort() likewise become a JSON error with that status (never the HTML error page); $ctx->redirect() still produces a content-type-neutral Location response (a deliberate handler action, not an error gate).

  • An uncaught exception, or a handler that doesn't return a Response, is a JSON 500 — see Uncaught exceptions.

Native route handlers (Go)

A route.go is an API route written in Go — the same URL mapping as route.php, with the handler compiled to a native binary. This mirrors the Next.js "native language support" prototype (Route Handlers in Go, for CPU-heavy work beside the app): declare func GET / POST / PUT / PATCH / DELETE / HEAD / OPTIONS with the stdlib's http.HandlerFunc signature, nothing else — no main(), no framework import.

// src/Pages/api/hello/route.go
package main

import (
    "encoding/json"
    "net/http"
)

func GET(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]string{"message": "Hello from Go!"})
}
vendor/bin/relayer native:build     # once per change to a route.go
curl -i http://localhost:8080/api/hello

Build. vendor/bin/relayer native:build compiles every route.go under src/Pages (and a root middleware.go, see Writing it in Go). For each one it generates the entrypoint (a main() that serves one request through Go's net/http/cgi and switches on the method), copies both into var/cache/native/build/<slug>/ with a stdlib-only go.mod (a go.mod / go.sum you place beside the route.go is used instead, so third-party modules work), and runs go build -o var/cache/native/bin/<slug>. <slug> is the URL pattern with / → __ (/api/users/[id] → api__users__[id]); two routes that end up with one slug (/a/b and /a__b) fail the build. Only route.go and go.mod / go.sum are copied, so a sibling helper.go is not compiled: keep a route in one file, or import shared code as a module. It needs go on PATH — or GO_BIN=/path/to/go — and is a separate command from relayer compile for that reason. A route.go that declares no handler, or is not package main, fails the build with a message naming the file; a compile error prints go build's output verbatim. Output is one line per route:

built  /api/hello  -> var/cache/native/bin/api__hello

Nothing is rebuilt at request time, ever. An unbuilt route answers a JSON 500 whose body says to run native:build; in dev (APP_ENV=dev) a binary older than its route.go gets the same answer with (route.go is newer than the binary) appended, so an edit you forgot to rebuild cannot masquerade as a bug. Production serves whatever the deploy built. var/cache/native/ is covered by the scaffolded .gitignore (/var/).

The scaffolded Dockerfile has no Go and does not run native:build, and .dockerignore excludes /var/, so a production image built from it serves every route.go as the unbuilt 500. Add a build stage with Go that runs native:build, then copy var/cache/native/bin into the final image.

How a request runs. There is no resident Go server: PHP starts the binary per request as a CGI child (proc_open), passes the request as CGI meta-variables, streams the body to its stdin and reads a CGI response (Status: / headers / blank line / body) back from stdout. A static Go binary starts in about a millisecond, so the overhead is in the low single-digit milliseconds per call — fine for an endpoint that then does real work, not a replacement for a hot PHP route that returns in 200 µs. Specifically, the handler sees:

  • r.Method, r.URL (path + query string, from REQUEST_URI), r.Header (every request header, as HTTP_*), r.Host, r.RemoteAddr and r.Body — all filled in by net/http/cgi from the standard variables (REQUEST_METHOD, PATH_INFO, QUERY_STRING, CONTENT_TYPE, CONTENT_LENGTH, REMOTE_ADDR, HTTPS, …). A client Proxy: header is never forwarded (httpoxy).

  • Dynamic segments as RELAYER_ROUTE_PARAMS, a JSON object ({"id":"42"} for /api/users/[id]), and the pattern itself as RELAYER_ROUTE_PATTERN (/api/users/[id]):

    var params map[string]string
    json.Unmarshal([]byte(os.Getenv("RELAYER_ROUTE_PARAMS")), &params)
    id := params["id"]
  • The PHP process's real environment variables underneath. Values from .env files are not included: Symfony Dotenv only fills $_ENV / $_SERVER, so a setting Go needs must be a real environment variable (set by the shell, container or process manager). Framework variables always win over anything a client could inject.

  • A multipart/form-data body, rebuilt from what PHP parsed (PHP consumes the original), so r.ParseMultipartForm / r.FormFile work. Fields come before files, PHP's upload_max_filesize / post_max_size apply first, and an upload PHP rejected is a JSON 500 rather than a missing part.

  • r.RemoteAddr carries the client IP with port 0; CGI passes no port.

Methods. The generated entrypoint applies the route.php rules: an undeclared OPTIONS → 204 + Allow; an undeclared HEAD with a GET → runs GET and drops the body; any other undeclared method → 405 + Allow with a JSON body. Declaring HEAD / OPTIONS yourself always wins. relayer routes lists the declared methods (a text scan of the route.go, so it works before anything is built) in a native row.

Failures are JSON. A non-zero exit (a Go panic included), output that is not a CGI response, or a run longer than the timeout answers a JSON 500 ({"error": "Native route /api/hello failed: …"}), never the HTML error page — like route.php, a native route is an endpoint. What the process wrote to stderr is logged at error through the PSR-3 logger when one is configured (and at warning after a successful run — that is where Go's log package writes). The timeout defaults to 30 seconds; NATIVE_ROUTE_TIMEOUT=10 (seconds) in .env changes it. A PHP with proc_open in disable_functions cannot run native routes and says so at dispatch.

What does not apply. A route.go gets no autowiring, no PageContext, no Request object and no #[Auth] / requireAuth() — the Go side only ever sees CGI variables. Protect it from the root middleware — src/Pages/middleware.php, or its Go form middleware.go — which wraps native routes exactly like pages and route.php (a middleware that does not call $next means the Go process is never started). _private folders exclude a route.go as they do everything else, and a directory holds one handler: route.go beside route.php or a page file is a scan error, like page.psx beside page.php. Go is the only language today; the route kind is deliberately language-neutral so a route.rs could join without touching the router.

Per-page scripts ($ctx->js() / addJs())

A page (or any layout above it) can declare its own external scripts instead of everything riding the one global bundle. Function-style:

return function (PageContext $ctx): Closure {
    $ctx->js('/assets/chart.js', defer: true);

    return fn (): Element => <canvas id="chart"></canvas>;
};

Class-style pages and layouts get the same via $this->addJs(...):

final class Dashboard extends LayoutComponent
{
    public function render(): Element
    {
        $this->addJs('/assets/dashboard.js', module: true);
        return <div>{$this->getChildren()}</div>;
    }
}
  • Emitted at the end of <body>, after the main client script, in declaration order. Layout scripts come before the page's; an outer (root) layout before an inner one.
  • src-only by design. Flags: defer, async, module (type="module"). For inline JS use $document->addHeadHtml() — the same hook the Island loader rides on (below).
  • No deduplication — a layout and a page both declaring the same src produce two tags. Declared, not reconciled (mirrors metadata()).

React Islands (rich-UI escape hatch)

When a page genuinely needs a rich client UI the server-rendered defer/partial model can't express, mount a real React component as an island: the server still owns the page, one node is handed to React with initial props from PHP.

<?php
// src/Pages/dashboard/page.psx
declare(strict_types=1);

use Polidog\Relayer\React\Island;
use Polidog\Relayer\Router\Component\PageContext;

return fn (PageContext $ctx) => (
    <section>
        <h1>Dashboard</h1>
        {Island::mount('Chart', ['points' => $ctx->params])}
    </section>
);

Island::mount() renders <div data-react-island="Chart" data-react-props='…'></div>. Add the framework's tiny, React-agnostic loader once via the document, then your bundle:

$document->addHeadHtml(Island::loaderScript());
$document->addHeadHtml('<script type="module" src="/islands.js"></script>');

You own islands.js — build it with your own toolchain (vite / esbuild), with React bundled in. The contract is one call:

import { createRoot } from 'react-dom/client';
import Chart from './islands/Chart';

window.relayerIslands.register('Chart', (el, props) => {
    createRoot(el).render(<Chart {...props} />);
});
  • The framework provides only the PHP primitive and the loader — it stays Node-free. React, JSX, and bundling are yours. The loader finds islands (including ones swapped in by a Rosanna defer, via a MutationObserver), parses props, and calls your registered mount fn; registration and DOM order are interchangeable.
  • Props flow one way (PHP → initial props). For anything the island needs from the server afterwards, fetch your JSON API routes (route.php) — there is no separate island↔server channel.
  • Names must be plain identifiers; non-encodable props raise a clear error.
  • One intentional residual: there is no SSR (client render only — the mount node is empty until hydration; render a loading state inside your component). loaderScript() is an inline <script>; under a strict script-src CSP pass loaderScript($nonce) and it is emitted as <script nonce="…"> (the window.relayerIslands.register contract is unchanged).

Middleware

An optional root src/Pages/middleware.php wraps every page/route dispatch. It returns a single closure fn(Request $request, Closure $next); call $next($request) to continue to the matched route, or don't call it to short-circuit (CORS preflight, rate-limit, maintenance mode, …):

<?php
// src/Pages/middleware.php
declare(strict_types=1);

use Polidog\Relayer\Http\Request;

return function (Request $request, Closure $next): void {
    if (null === $request->header('x-api-key')) {
        \http_response_code(401);
        echo '{"error":"missing api key"}';
        return; // route never runs
    }
    $next($request);
};
  • One closure, no chain runner (by design). To run several things, compose by hand: fn ($r, $next) => $a($r, fn ($r) => $b($r, $next)).
  • required fresh each request (declaration-free, like route.php); a non-closure return is a clear error. The framework defer/profiler endpoints deliberately run outside it — something that must cover those too (an origin guard) goes in AppRouter::setGuard(), which takes the same closure and runs before everything (see below).
  • $request->path has one spelling per route: /%61dmin and /admin/ arrive as /admin. It stays percent-encoded, so compare a non-ASCII path through $request->decodedPath(): str_starts_with($request->decodedPath(), '/管理'). A path with a dot-segment (.., %2e%2e, ..%2Fx), a control character (%00, %0A), invalid UTF-8 or a malformed escape matches no route (404).

CORS ships as a ready-made middleware — the one provided implementation, not a parallel system:

<?php
// src/Pages/middleware.php
use Polidog\Relayer\Http\Cors;

return Cors::middleware([
    'origins' => ['https://app.example.com'], // or ['*']
    // methods / headers / credentials / maxAge are optional
]);

It answers OPTIONS preflights with 204 itself and adds Access-Control-Allow-Origin to actual requests. credentials: true with origins: ['*'] reflects the request Origin (a literal * is invalid with credentials per spec).

Writing it in Go (middleware.go)

The root middleware can be Go instead of PHP — the "Proxy in Go" half of the Next.js native-language prototype. src/Pages/middleware.go is package main with a single func Proxy(w http.ResponseWriter, r *http.Request) (named like Next.js's proxy(); no main(), no framework import), built by the same vendor/bin/relayer native:build as route.go to var/cache/native/bin/middleware and started per request before route matching, like middleware.php.

CGI has no "continue to the next handler", so the Go side says what happens through response headers:

// src/Pages/middleware.go
package main

import "net/http"

func Proxy(w http.ResponseWriter, r *http.Request) {
    // Short-circuit: no Relayer-Next → this IS the response.
    if r.Header.Get("X-Api-Key") == "" && len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(401)
        w.Write([]byte(`{"error":"missing api key"}`))
        return
    }

    // Continue: Relayer-Next → the matched page / route runs.
    w.Header().Set("Relayer-Next", "1")
    w.Header().Set("Relayer-Request-Header-X-User", "42") // request header the route sees
    w.Header().Set("X-Proxied-By", "go")                  // response header, merged into the final response
}
  • Relayer-Next: 1 present → continue. The body is ignored. Two rewrite instructions apply to the Request handed on: Relayer-Rewrite-Path: /other routes on that path instead (one; a route.go sees the new path in os.Getenv("PATH_INFO"), while its r.URL.Path stays what the client requested), and Relayer-Request-Header-<Name>: value adds or replaces request header <Name> (any number) — $request->header('x-user') in the route, r.Header is unchanged for the proxy itself. Every other response header (Set-Cookie, X-*, …) is merged into the final response, emitted before the page / route writes its own (the NextResponse.next({ headers }) shape). Status:, Content-Type and Content-Length are not applied: they describe the ignored body.
  • No Relayer-Next → short-circuit: Status: (default 200), headers and body are sent as-is; nothing else runs — not the page, not route.php, not a route.go.
  • Relayer-* headers never reach the client in either case.
  • The proxy gets the same CGI environment as a route.go, minus the route variables (it runs before matching) plus RELAYER_MATCHED_PATTERN: the pattern the path would match (/api/users/[id]), empty for a 404. There is no matcher config; branch on r.URL.Path yourself.
  • One middleware per app: middleware.php and middleware.go both present is a RuntimeException naming both files, on every request. relayer routes ends with a middleware src/Pages/middleware.go (native) line (or (php)) so you can see which is live.
  • Failures are a JSON 500 even for a page request — unbuilt (with the native:build hint; in dev also when middleware.go is newer than the binary), a panic, non-CGI output, or the NATIVE_ROUTE_TIMEOUT — because the proxy fails before the page pipeline could render an error page. stderr goes to the PSR-3 logger at error.
  • Same process model as route.go: a Go process per request, so this costs a few milliseconds on every page and route. The framework's /_defer/* and /_profiler endpoints run outside it, exactly as with middleware.php; a check that must cover those too belongs in AppRouter::setGuard() (below). No middleware.go ships in example/ (it would make every request there need Go) — the snippet above is the complete example.

Origin guard and trusted proxy (running behind a CDN)

Two more provided middlewares cover the pair of chores every app behind Cloudflare / a reverse proxy otherwise hand-writes: refuse requests that bypassed the CDN and recover the real client IP. Both are plain fn(Request, $next) closures like Cors.

The framework wires them from the environment. Like DATABASE_DSN auto-wires the Db layer, Relayer::boot() reads these variables and installs the result as the router guard — the hook that runs before everything (the deferred-component endpoint /_defer/{name}, the dev profiler and middleware.php). Nothing set, nothing installed:

ORIGIN_SECRET=<long random value>     # turns OriginGuard on
ORIGIN_SECRET_PREVIOUS=<old value>    # also accepted while rotating
ORIGIN_GUARD_HEADER=X-Origin-Secret   # default
ORIGIN_GUARD_EXCEPT=/healthz,/_health/*   # exact paths, or `/prefix/*`
ORIGIN_GUARD_MODE=enforce             # or report

TRUSTED_PROXIES=fdaa::/16             # turns TrustedProxy on (comma-separated CIDRs)
TRUSTED_PROXY_PRESET=fly              # fly | cloudflare | xff (default)
CLOUDFLARE_IP_RANGES=173.245.48.0/20,…  # adds a Cloudflare layer behind the first (see chain)

Don't put an origin guard in middleware.php: that wraps route dispatch only and is bypassed by the defer / profiler endpoints, so it could be sidestepped by requesting /_defer/{name} directly. To compose your own, AppRouter::setGuard() takes the same fn(Request, $next) closure and replaces what the environment produced:

<?php
// public/index.php
use Polidog\Relayer\Http\OriginGuard;
use Polidog\Relayer\Relayer;
use Psr\Log\LoggerInterface;

Relayer::boot(__DIR__ . '/..')
    ->setGuard(OriginGuard::middleware([
        'header'         => 'X-Origin-Secret',                 // default
        'secretsFromEnv' => ['ORIGIN_SECRET', 'ORIGIN_SECRET_PREVIOUS'],
        'except'         => ['/healthz'],                      // exact path, or `/prefix/*`
        'mode'           => 'enforce',                         // or 'report'
        'logger'         => Relayer::container()->get(LoggerInterface::class), // optional
    ]))
    ->run();

OriginGuard checks a secret header the CDN stamps on every forwarded request and answers 403 when it is missing or wrong:

  • Off by default — with no non-blank secret every request passes, so a local php -S or CI never turns into a wall of 403s.
  • secretsFromEnv names the environment variables holding the secrets, read the way the framework reads its own config ($_ENV, then $_SERVER, then getenv(), trimmed — a .env.local and a real fly secrets variable both work whatever variables_order says, and a trailing newline from fly secrets import does not lock everyone out). Blank ones are skipped. secrets takes literal values instead, if you already have them in hand.
  • Either list may hold several entries: during a rotation name the new and the old variable and both are accepted. Every candidate is compared with hash_equals.
  • except skips the check for paths that legitimately arrive without the header (platform health checks).
  • mode: 'report' logs what would be rejected and lets the request through — run it for a while before switching to enforce.
  • Log lines (one PSR-3 warning template for both modes, outcome = rejected / reported in the context; via the DI logger you pass, else the SAPI error log) carry method / path / client IP only — never a configured secret nor the value the client sent. The IP is clientIp(), so with TrustedProxy running outside the guard (how boot() wires them) a bypass attempt is logged with its real source.
  • Pass secrets in from the environment (fly secrets set, a gitignored .env.local); don't write them into a committed config file.

TrustedProxy resolves the client IP from a forwarding header, but only when the immediate hop (REMOTE_ADDR) is a proxy you trust. The result is $request->clientIp(); $request->ip() is unchanged and keeps returning REMOTE_ADDR, and clientIp() falls back to ip() when nothing was resolved, so pages can call it unconditionally.

use Polidog\Relayer\Http\TrustedProxy;

// Cloudflare in front: CF-Connecting-IP, honored only from Cloudflare's ranges.
// The ranges are not bundled (they change) — fetch https://www.cloudflare.com/ips-v4
// and /ips-v6 and pass them in.
TrustedProxy::middleware(['preset' => 'cloudflare', 'proxies' => $cloudflareRanges]);

// Fly.io: Fly-Client-IP, honored only from Fly's private network (6PN).
TrustedProxy::middleware(['preset' => 'fly', 'proxies' => ['fdaa::/16']]);

// A proxy of your own: X-Forwarded-For walked from the right past your proxies.
TrustedProxy::middleware(['preset' => 'xff', 'proxies' => ['10.0.0.0/8']]);

// Any other single-IP header (`preset` and `header` are mutually exclusive).
TrustedProxy::middleware(['header' => 'X-Real-IP', 'proxies' => ['192.0.2.1']]);
  • proxies is required: entries are plain addresses or CIDR ranges, IPv4 and IPv6 (an IPv4-mapped ::ffff:a.b.c.d, what a [::]-listening server reports for IPv4 peers, matches IPv4 ranges). A malformed range never matches — a typo cannot become "trust everyone".
  • A header from an untrusted hop, or one that is not a valid IP, is ignored.
  • The only way to skip the hop check is the explicit 'trustAnyHop' => true, for a process that is provably reachable through one proxy only. Never set it on a server that can be reached directly: any caller could then send Fly-Client-IP: 127.0.0.1 and pass an IP allow-list.

Several proxies in a row (chain). Behind Cloudflare → Fly the hop is Fly, Fly-Client-IP is Cloudflare's address (per Fly's request headers), and the user is in CF-Connecting-IP. A single preset would hand you Cloudflare's IP — per-user rate limits would collapse onto Cloudflare's edge addresses. List the layers outermost-last; each applies only if the address resolved so far is one of that layer's proxies, and the last address that could be verified is the client:

TrustedProxy::middleware(['chain' => [
    ['preset' => 'fly',        'proxies' => ['fdaa::/16']],
    ['preset' => 'cloudflare', 'proxies' => $cloudflareRanges],
]]);

A request straight to xxx.fly.dev resolves to the caller's own address (the Fly layer applies, the Cloudflare layer does not), and a forged CF-Connecting-IP from a non-Cloudflare source is ignored.

Recipe: Cloudflare in front of Fly.io.

  1. Cloudflare → Rules → Transform Rules → Modify Request Header: set X-Origin-Secret to a long random value for your zone.
  2. Hand the app the secret and the trust chain — no PHP involved:
fly secrets set \
  ORIGIN_SECRET=<that value> \
  ORIGIN_GUARD_EXCEPT=/healthz \
  TRUSTED_PROXIES=fdaa::/16 \
  TRUSTED_PROXY_PRESET=fly \
  CLOUDFLARE_IP_RANGES="$(curl -s https://www.cloudflare.com/ips-v4 https://www.cloudflare.com/ips-v6 | paste -sd,)"

Rotate by setting the new ORIGIN_SECRET plus ORIGIN_SECRET_PREVIOUS=<old>, then dropping the latter. Relayer::boot() installs the fly → cloudflare TrustedProxy chain outside OriginGuard as the router guard — so /_defer/* and the profiler are covered too, and a rejected direct hit is logged with its real source IP rather than the Fly proxy's address.

  1. Verify: curl -I https://<app>.fly.dev/ is 403, the custom domain (through Cloudflare) is 200, and /healthz is 200 either way.

Cors stays in src/Pages/middleware.php as before. The example/ app runs unguarded until you set these in its .env.local.

Inspecting routes

vendor/bin/relayer routes prints every route Relayer discovers under src/Pages — pages and route.php endpoints with their methods — using the same scanner the router uses:

METHODS    PATH            TYPE    FILE
GET,POST   /               page    src/Pages/page.psx
GET        /api/hello      native  src/Pages/api/hello/route.go
GET,POST   /api/users      api     src/Pages/api/users/route.php
GET,POST   /users/[id]     page    src/Pages/users/[id]/page.psx

middleware  src/Pages/middleware.php  (php)

Pages report GET,POST (POST is how server actions reach a page). HEAD and OPTIONS are not listed, as for route.php: a page answers HEAD like GET without a body, and OPTIONS with 204 + Allow: GET, HEAD, OPTIONS, POST without running page code. Any other method gets 405 + Allow: GET, HEAD, POST, rendered by error.psx. Both are decided after the root middleware, so a CORS preflight is still answered by Cors::middleware(). API routes list their declared methods, and native routes (route.go) the func GET & co. found by a text scan — no build needed. A route.php that fails to load, or a route.go that declares no handler, is shown as ? with a warning line, not silently hidden. The trailing line names the root middleware when there is one — (php) for middleware.php, (native) for middleware.go.

Server Actions (form / CSRF-protected)

Dispatch a form submission to a server-side handler bound to the page (equivalent to Next.js Server Actions). The token is CSRF-protected and the handler runs before render(). Available in both class- and function-style pages.

Class-style: PageComponent::action()

PageComponent::action([$this, 'handler']) returns a CSRF-bound token for a form's hidden field. Submitting the form invokes the matching method on the page before render():

public function render(): Element
{
    return (
        <form method="post">
            <input type="hidden" name="_rosanna_action" value={$this->action([$this, 'save'])} />
            <input name="title" />
        </form>
    );
}

public function save(array $form): void
{
    // ... handle $form['title']
    header('Location: /dashboard', true, 303); // PRG
    exit;
}

Invalid CSRF tokens return a 403.

Only methods the page class itself declares can be actions: passing an inherited framework method (or anything other than [$this, 'method']) to action() throws, and the dispatcher only runs a method named by a token the server signed (see Binding arguments) — a hand-crafted token naming some other public/protected method is ignored.

Function-style: PageContext::action()

Function-style pages declare server actions through PageContext::action(). The factory closure runs on every request — including the POST that submits the form — so the action table is rebuilt before dispatch and the token only needs to carry (pageId, name):

<?php
// src/Pages/users/page.psx
declare(strict_types=1);

use App\Service\UserRepository;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx, UserRepository $users): Closure {
    $save = $ctx->action('save', function (array $form) use ($users, $ctx): void {
        $users->create($form['name']);
        $ctx->redirect('/users'); // 303 Post/Redirect/Get; unwinds here
    });

    return function () use ($save, $users): Element {
        return (
            <main>
                <ul>{\array_map(fn($u) => <li>{$u->name}</li>, $users->all())}</ul>
                <form action={$save}>
                    <input name="name" />
                    <button>save</button>
                </form>
            </main>
        );
    };
};

The handler receives the POST body as its first argument (with _rosanna_action / _rosanna_csrf stripped). Action names must be unique per page — registering the same name twice throws.

Actions can also be registered outside the factory, e.g. from a Renderable child's render() via PageContext::current()->action() — a child of the page or of a layout. On a POST that submits such an action, the render closure, the layouts around it and every Renderable child in the tree (inside arrays, nested elements and defer fallbacks too) render once before dispatch so the action is registered, and again after dispatch for the response. A GET, or a POST to an action the factory registered, renders once. Metadata and scripts the factory declared are kept for the response; ones declared while rendering are declared again by the second render, not twice.

The response render runs the render closure again, so its children are new objects built with the props of that render (a list the action added to shows the new item). State the action kept on the child's $this is carried over: every property the action changed on a child of the first render is copied onto the child of the same class at the same place in the new tree before it renders. Readonly properties count as props and are never copied, and a property the action did not change keeps the new child's value. So a form child can hold its own validation errors:

final class ContactForm implements Renderable
{
    private array $errors = [];

    public function __construct(private readonly string $title) {}

    public function render(): Element
    {
        $send = PageContext::current()->action('send', function (array $form): void {
            $result = Validator::object([/* ... */])->safeParse($form);
            if (!$result->success) {
                $this->errors = $result->errors; // shown in the response
            }
        });

        return <form action={$send}>{/* $this->title, $this->errors ... */}</form>;
    }
}

"The same place" is the position in the tree, as React keeps state by type and position: if the action changes which children come before the form (say, a new list item rendered as its own child ahead of it), the state lands on whichever child of that class is now there. Assign the property ($this->errors = ..., $this->errors[] = ...) rather than mutating an object it holds — a change inside such an object is not detected. State the page itself needs, or state that must survive such a reshuffle, belongs in the factory, captured by reference (see Re-rendering after a failed submit).

Binding arguments

A third $args argument binds values into the handler. They are passed after the form body:

// list → positional:   handler($form, 42)
$delete = $ctx->action('delete', function (array $form, int $id) use ($repo): void {
    $repo->delete($id);
}, [$user->id]);

// assoc → named args:   handler(formData: $form, id: 42)
$ctx->action('delete', fn (array $formData, int $id) => $repo->delete($id), ['id' => $user->id]);

The action token (target page/class + action name/method + $args) is HMAC-signed with a random per-session key kept in $_SESSION (next to the CSRF token) and verified before dispatch. A token whose target or $args were edited client-side — or one minted for another session — is rejected with a 403 and the handler never runs. POSTed fields only ever reach the first (form data) argument, so a form field named like a bound argument cannot override it. $args is signed, not encrypted — it is readable in the page source, so don't bind secrets; and still authorize inside the handler (a valid token proves the server bound the value, not that the current user may act on it).

Re-rendering after a failed submit

A function-style page's factory closure re-runs on every request and the action handler runs after the renderer is built. To re-render the same page on a validation error, capture state by reference (&$errors) and read the post-dispatch value in the renderer (the typical pairing with Validation's safeParse; full example in example/src/Pages/signup/page.psx):

return function (PageContext $ctx) use ($schema): Closure {
    $errors = [];
    $save = $ctx->action('save', function (array $form) use ($schema, &$errors, $ctx): void {
        $result = $schema->safeParse($form);
        if (!$result->success) { $errors = $result->errors; return; }
        // ... on success: $ctx->redirect('/users') (303 Post/Redirect/Get)
    });

    // $errors is mutated after the action runs → capture by reference
    return function () use ($save, &$errors): Element { /* render $errors */ };
};

Service Registration

You have two complementary ways to register services. Both can be used in the same project — YAML/PHP files load first, then AppConfigurator runs and can override anything.

Option A — config/services.yaml (auto-loaded)

Drop a config/services.yaml next to composer.json and the framework picks it up at boot time. This is the idiomatic Symfony style:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: true

  App\Service\PdoUserRepository: ~

  App\Service\UserRepository:
    alias: App\Service\PdoUserRepository

config/services.php (returning a ContainerConfigurator closure) and config/services.yml are also accepted.

Per-environment overrides

Two complementary mechanisms let you vary config by env — both resolve <env> to dev (when APP_ENV=dev or APP_ENV=development) or prod (everything else).

when@<env> blocks inside services.yaml — Symfony's standard in-file env override:

# config/services.yaml
services:
  App\Service\Mailer: ~

when@dev:
  services:
    App\Service\LoggingMailer: ~   # replaces Mailer only on dev

Sibling services.{env}.yaml / .yml — a dedicated per-env file loaded after the base; useful when the env-specific section would otherwise dominate the file:

# config/services.dev.yaml  (or services.dev.yml)
services:
  App\Service\LoggingMailer: ~

Both forms also work for services.php / services.{env}.php. Precedence (lowest → highest): framework defaults → services.{yaml,yml,php} (incl. when@<env>) → services.{env}.{yaml,yml,php} → AppConfigurator.

Option B — AppConfigurator (PHP)

Subclass AppConfigurator and register services on the ContainerBuilder. The framework applies autowire + public visibility by default, so a bare register() call is usually enough:

<?php
// src/PagesConfigurator.php
declare(strict_types=1);

namespace App;

use App\Service\UserRepository;
use App\Service\PdoUserRepository;
use Polidog\Relayer\AppConfigurator as BaseConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;

final class AppConfigurator extends BaseConfigurator
{
    public function configure(ContainerBuilder $container): void
    {
        $container->register(PdoUserRepository::class);
        $container->setAlias(UserRepository::class, PdoUserRepository::class)
            ->setPublic(true);
    }
}

Then pass it to boot():

Relayer::boot(__DIR__ . '/..', new App\AppConfigurator(__DIR__ . '/..'))->run();

PSX components — accessing services

PSX component files (.psx) are plain closures, so constructor injection is unavailable. Use Relayer::container() to pull a service from the DI container directly inside a component:

<?php
// src/Components/TodoList.psx
declare(strict_types=1);

namespace App\Components;

use Polidog\Relayer\Db\Database;
use Polidog\Relayer\Relayer;
use Polidog\Rosanna\Runtime\Element;

return function (array $props): Element {
    $db = Relayer::container()->get(Database::class);
    $todos = $db->fetchAll('SELECT id, title, done FROM todos ORDER BY id');

    return (
        <ul>
            {array_map(fn($t) => <li>{$t['title']}</li>, $todos)}
        </ul>
    );
};

Relayer::container() returns the same PSR-11 container boot() built. It throws \LogicException if called before boot(). For regular services (page handlers, route classes) prefer constructor injection via AppConfigurator — this accessor is an escape hatch specifically for the PSX component context where closures make injection impractical.

A deferred component that throws still answers its /_defer/{name} fetch with a bare 500, but the failure now also goes to the container's LoggerInterface (and the dev profiler), not only to PHP's error log.

Components get their arguments from the container, so RosannaDefer::setArgumentResolver() does not apply to them. Code that calls PsxComponentRegistrar::configure() without a container can pass its own resolver as argumentResolver:.

Autowire defaults

The framework iterates every Definition you register and:

  • enables autowired if you didn't pass explicit constructor arguments
  • forces public = true so PSR-11 get($id) can fetch it

If you need a private service or fully-manual wiring, configure the Definition explicitly — your settings win.

Injecting Services Into Pages

Class-based pages get constructor injection automatically. Page classes do not need to be registered in the container — the PSR-11 adapter falls back to reflection-based autowiring for unregistered classes, resolving each typed dependency from the Symfony container:

<?php
// src/Pages/users/page.psx
declare(strict_types=1);

namespace App\Pages\Users;

use App\Service\UserRepository;
use Polidog\Rosanna\Runtime\Element;
use Polidog\Relayer\Router\Component\PageComponent;

final class UsersPage extends PageComponent
{
    public function __construct(private readonly UserRepository $users)
    {
    }

    public function render(): Element
    {
        $users = $this->users->all();
        // ...
    }
}

You only need to register a Page in AppConfigurator if you want non-default behavior (e.g. service tags, decorators, factory construction).

A registered page, layout or error page is still built anew for every request: the container makes a non-shared service of each class declared in a page, layout or error file under src/Pages (including one that only implements Renderable) and of each class that extends PageComponent or ErrorPageComponent, or implements LayoutInterface, even if you call setShared(true) on it, and the compiled container (relayer container:compile) does the same. Other Renderable services, such as components, stay shared. So an object the page creates in its constructor (a form, a DTO) never carries one visitor's state to the next on a long-running worker, while the services injected into it are still the container's shared ones. The router renders such a new object as it is, so a page or layout does not have to be cloneable.

An object handed to the container with set() cannot be rebuilt; the router renders a shallow clone of it, so objects it holds are shared between requests, and a class that cannot be cloned (a private __clone()) fails the request with a RuntimeException. Don't register pages that way.

Accessing the HTTP Request

Declare a Polidog\Relayer\Http\Request parameter on a page (function-style factory or class constructor) and the framework will inject an immutable snapshot of the current request — pages never need to touch $_GET, $_POST, or $_SERVER directly.

<?php
// src/Pages/signup/page.psx
declare(strict_types=1);

use Polidog\Relayer\Http\Request;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx, Request $req): Closure {
    $errors = [];

    if ($req->isPost()) {
        $email = $req->post('email') ?? '';
        if (!\filter_var($email, \FILTER_VALIDATE_EMAIL)) {
            $errors['email'] = 'Invalid email';
        }
        if ([] === $errors) {
            $ctx->redirect('/thanks'); // 303 Post/Redirect/Get; unwinds here
        }
    }

    return function () use ($errors, $req): Element {
        // ... render form, echoing $req->post('email') back into the input
    };
};

Request API (all immutable):

Method Returns
$req->method uppercase HTTP method
$req->path request path (no query string), percent-encoded in one canonical spelling
$req->decodedPath() $req->path decoded, for comparing (e.g. /日本語)
$req->uri() verbatim request URI (query string included)
$req->isGet() / isPost() bool
$req->isMethod('PUT') bool
$req->post($key) ?string (null if missing / non-string)
$req->query($key) ?string
$req->header($name) ?string (case-insensitive)
$req->allPost() array<string, mixed> (raw body)
$req->allQuery() array<string, mixed>
$req->allHeaders() array<string, string> (lowercased keys)
$req->body() raw request body (php://input, read lazily)
$req->json() ?array — body as JSON, null if absent/invalid
$req->file($name) ?UploadedFile (null for a name[] field)
$req->files() array<string, UploadedFile|list<UploadedFile>>
$req->ip() ?string — REMOTE_ADDR as reported
$req->clientIp() ?string — resolved by TrustedProxy, else ip()
$req->host() / scheme() ?string / 'http'|'https'
$req->url() ?string — absolute URL of this request

Tests use new Request(method: 'POST', path: '/signup', post: [...]) directly — no superglobal manipulation needed.

You never need a superglobal. $_GET / $_POST / $_SERVER / $_FILES / php://input are all reachable through the injected Request, including from a <X defer /> fragment — deferred components are autowired from the container exactly like pages, so they take Request, Authenticator, or any bound service by type:

// src/Components/UserHeader.psx — no session_start(), no $_SESSION
return function (array $props, Authenticator $auth): Element {
    $name = $auth->user()?->displayName;
    …
};

A JSON API route and an upload, end to end:

// src/Pages/api/todos/route.php
return [
    'POST' => function (Request $req, TodoRepository $todos): Response {
        $payload = $req->json();
        if (null === $payload) {
            return Response::json(['error' => 'invalid JSON body'], 400);
        }

        return Response::json($todos->create($payload), 201);
    },
];

// an upload — $_FILES normalized, `name[]` included
$avatar = $req->file('avatar');
if (null !== $avatar && $avatar->isValid()) {
    $avatar->moveTo(__DIR__ . '/../var/uploads/' . \bin2hex(\random_bytes(8)));
}

UploadedFile carries clientName, clientMimeType, size, tmpName, error, plus isValid(), contents(), and moveTo() (which refuses anything that isn't a real upload). ip() reports REMOTE_ADDR only — X-Forwarded-For is client-supplied. Behind a proxy you trust, wire TrustedProxy (see the Middleware section) and read clientIp(), which falls back to ip() when nothing was resolved.

Authentication

Session-based authentication ships in the box. You provide a UserProvider (your user lookup) and the framework wires the rest: password hashing, the session-stored principal, and a request-time guard that protects pages.

1. Implement a UserProvider

The provider takes a user-supplied identifier (typically email) and returns Credentials — the Identity that will live in the session, plus the password hash to verify against. Return null when the identifier is unknown.

<?php
declare(strict_types=1);

namespace App\Auth;

use Polidog\Relayer\Auth\Credentials;
use Polidog\Relayer\Auth\Identity;
use Polidog\Relayer\Auth\UserProvider;

final class PdoUserProvider implements UserProvider
{
    public function __construct(private readonly \PDO $pdo) {}

    public function findByIdentifier(string $identifier): ?Credentials
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, name, password_hash, roles FROM users WHERE email = ?'
        );
        $stmt->execute([\strtolower(\trim($identifier))]);
        $row = $stmt->fetch(\PDO::FETCH_ASSOC);
        if (false === $row) {
            return null;
        }

        return new Credentials(
            identity: new Identity(
                id: (int) $row['id'],
                displayName: (string) $row['name'],
                roles: \json_decode((string) $row['roles'], true) ?: [],
            ),
            passwordHash: (string) $row['password_hash'],
        );
    }
}

2. Bind the provider

The framework registers Authenticator, PasswordHasher (NativePasswordHasher with PASSWORD_DEFAULT), and SessionStorage (NativeSession) by default. Adding the UserProvider binding is all that's required to opt in:

# config/services.yaml
services:
  _defaults:
    autowire: true
    autoconfigure: true
    public: true

  App\Auth\PdoUserProvider: ~

  Polidog\Relayer\Auth\UserProvider:
    alias: App\Auth\PdoUserProvider

Apps that don't bind UserProvider pay nothing — Authenticator is only registered when the interface is bound, so unrelated projects keep booting unchanged.

3. Log users in

Inject Authenticator into the login page and call attempt() with the submitted credentials. Successful authentication rotates the session id (defends against session fixation) and stores the Identity snapshot.

<?php
// src/Pages/login/page.psx
declare(strict_types=1);

use Polidog\Relayer\Auth\Authenticator;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx, Authenticator $auth): Closure {
    $error = null;

    $login = $ctx->action('login', function (array $form) use ($auth, &$error, $ctx): void {
        $identity = $auth->attempt(
            (string) ($form['email']    ?? ''),
            (string) ($form['password'] ?? ''),
        );

        if (null === $identity) {
            $error = 'Invalid email or password.';

            return;
        }

        $ctx->redirect('/dashboard'); // 303 Post/Redirect/Get; unwinds here
    });

    return function () use ($login, $error): Element {
        // ... render form, surface $error as a single generic message
    };
};

Authenticator API:

Method Returns Notes
attempt($id, $password) ?Identity Verify via UserProvider + hasher; on success: log in.
login(Identity $identity) void Promote an already-resolved principal (SSO, signup).
logout() void Drop the principal, rotate the session id.
user() ?Identity Currently-logged-in principal, or null.
check() bool Shorthand for user() !== null.
hasRole($role) / hasAnyRole bool Role probes.

attempt() runs the password hasher even when the identifier is unknown so an attacker can't enumerate accounts by response time. A failure always returns null; the caller should render a single generic error rather than disclose which field rejected the input.

4. Protect pages

Class-style: #[Auth]

Attach Polidog\Relayer\Auth\Auth to a PageComponent subclass. The guard runs in InjectorContainer before the page is instantiated — so an anonymous request never builds the page or its dependencies.

<?php
namespace App\Pages;

use Polidog\Relayer\Auth\Auth;
use Polidog\Relayer\Router\Component\PageComponent;

#[Auth] // any authenticated user
final class DashboardPage extends PageComponent { /* ... */ }

#[Auth(roles: ['admin'])] // role-gated; non-admin gets 403
final class AdminPage extends PageComponent { /* ... */ }

#[Auth(redirectTo: '')] // empty redirect -> 401 instead of 302 (JSON / API)
final class ApiEndpoint extends PageComponent { /* ... */ }
Parameter Default Effect
roles [] One of these roles must be present (empty = any user).
redirectTo '/login' Where anonymous requests go. Empty string → 401.

Anonymous requests get a 302 Location: /login?next=<original-path-and-query> (URL-encoded, same-origin only): GET /boards/1?x=1&y=2 redirects to /login?next=%2Fboards%2F1%3Fx%3D1%26y%3D2. A request under a /{locale} prefix keeps it on both ends: GET /ja/boards redirects to /ja/login?next=%2Fja%2Fboards. Authenticated users lacking the required role get 403 Forbidden. Both 401 and 403 render error.psx (or the default error document).

#[Auth] is evaluated before #[Cache], so unauthorized requests never produce a cacheable 304 that could leak to anonymous viewers through a shared cache. Combining #[Auth] + #[Cache] is fine — just prefer Cache-Control: private for per-user gated pages.

Function-style: $ctx->requireAuth() / Identity injection

Function-style factories use a declarative guard on PageContext:

<?php
// src/Pages/dashboard/page.psx
declare(strict_types=1);

use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx): Closure {
    $user = $ctx->requireAuth(); // throws AuthorizationException on failure

    return fn(): Element => <h1>Welcome, {$user->displayName}</h1>;
};

requireAuth($roles = [], $redirectTo = '/login') returns the Identity so you can use it inline. AppRouter catches the exception and produces the same 302 / 401 / 403 response as #[Auth].

For pages that adapt to the authentication state instead of requiring it, declare ?Identity on the factory and the framework injects the current principal (null when no one is logged in):

return function (PageContext $ctx, ?Identity $user): Closure {
    $ctx->metadata(['title' => $user?->displayName ?? 'Welcome']);

    return fn(): Element => null !== $user
        ? <p>Hi, {$user->displayName}</p>
        : <a href="/login">Sign in</a>;
};

A non-nullable Identity parameter is treated as "auth required" and triggers the same redirect path as requireAuth() when anonymous — equivalent to #[Auth] for class-style pages.

5. Pluggable parts

The defaults are sensible but swappable. Bind a different implementation in services.yaml (or AppConfigurator) to override:

Interface Default Override when…
Polidog\Relayer\Auth\UserProvider (unbound, app-supplied) Always — this is your user lookup.
Polidog\Relayer\Auth\PasswordHasher NativePasswordHasher You want a specific algorithm or pepper.
Polidog\Relayer\Auth\SessionStorage NativeSession You want Redis / database-backed sessions.

NativePasswordHasher uses PASSWORD_DEFAULT so it tracks whatever PHP considers strongest on the current build (bcrypt today). Force argon2id when libargon2 is available:

$container->register(NativePasswordHasher::class)
    ->setArguments([\PASSWORD_ARGON2ID]);

NativeSession starts the session lazily, so just resolving the service through DI does not eagerly emit Set-Cookie. Only a write creates a session: on a request without a session cookie, reads see an empty store and start nothing, so an anonymous visitor to a page that merely checks $auth->user() gets no Set-Cookie and stays CDN-cacheable. It shares $_SESSION with the existing CSRF token machinery — no duplicate session starts.

The session cookie gets HttpOnly, SameSite=Lax, and Secure when $_SERVER['HTTPS'] says the request came over TLS. Behind a proxy that terminates TLS, set session.cookie_secure=1 or pass HTTPS=on to PHP; X-Forwarded-Proto is not consulted. Only attributes left at PHP's default are filled in, so your own values win: anything session_set_cookie_params() set before the session starts (SameSite=Strict, lifetime, path, domain), and php.ini / -d values including an explicit session.cookie_httponly=0 (spell it 0: PHP parses Off as blank, the same as a stock php.ini). A runtime httponly => false looks like the default, so to turn the cookie defaults off altogether call this before run():

Polidog\Relayer\Http\SessionStarter::leaveCookieParamsToApp();

Notes

  • The auth guard runs in InjectorContainer (class-style pages) and in the factory-arg resolver (Identity injection / requireAuth). Layouts are not guarded — they're resolved separately. If you need layout-level auth state, inject ?Authenticator into the layout constructor and read $auth?->user() from render().
  • ?next=<path> on the login redirect is same-origin only, but it comes from the URL, so anyone can craft it. Validate it on the login page with AuthGuard::safeNext($request->query('next'), '/dashboard'): paths with a query string pass, while //evil.com, /\evil.com, absolute URLs and values containing control characters fall back to the default.
  • The locale prefix is only added to a same-origin redirectTo that has no locale prefix yet. An absolute redirectTo is left as written, and still receives next, query string included. /ja/login routes to the same page as /login, so a login page outside Relayer's router (served by the web server, say) must be given as an absolute URL, or /ja/login is a 404.
  • Sessions are rotated on both login and logout. A pre-login session id captured by an attacker stops working the moment the user authenticates.

6. Token authentication (Firebase / Cognito)

For apps where a client SDK already mints a signed ID token — the Firebase JS SDK, AWS Amplify, the Cognito Hosted UI — the framework verifies that token instead of running a password handshake. It owns no OAuth redirect / code-exchange flow: the client holds the token and sends it as Authorization: Bearer <jwt>; Relayer validates the signature against the IdP's published JWKS and the registered claims (iss, aud, exp/nbf/iat, and token_use for Cognito).

Bind a TokenVerifier

TokenVerifier is the token-based counterpart of UserProvider. Build one with the Firebase / Cognito factory. Symfony's service factory syntax keeps it to config — no glue class:

# config/services.yaml
services:
  _defaults: { autowire: true, autoconfigure: true, public: true }

  # Firebase
  Polidog\Relayer\Auth\Token\TokenVerifier:
    factory: ['Polidog\Relayer\Auth\Token\Firebase', 'verifier']
    arguments:
      $http: '@Polidog\Relayer\Http\Client\HttpClient'
      $projectId: '%env(FIREBASE_PROJECT_ID)%'
      $cacheDir: '%app.project_root%/var/cache/jwks'

  # …or Cognito
  # Polidog\Relayer\Auth\Token\TokenVerifier:
  #   factory: ['Polidog\Relayer\Auth\Token\Cognito', 'verifier']
  #   arguments:
  #     $http: '@Polidog\Relayer\Http\Client\HttpClient'
  #     $region: '%env(COGNITO_REGION)%'
  #     $userPoolId: '%env(COGNITO_USER_POOL_ID)%'
  #     $appClientId: '%env(COGNITO_APP_CLIENT_ID)%'
  #     $cacheDir: '%app.project_root%/var/cache/jwks'
Env var Used by Example
FIREBASE_PROJECT_ID Firebase my-app
COGNITO_REGION Cognito ap-northeast-1
COGNITO_USER_POOL_ID Cognito ap-northeast-1_AbCdEf
COGNITO_APP_CLIENT_ID Cognito 7f3k… (app client id)

The JWKS is fetched through the framework's own HttpClient (so it lands in the dev profiler like any other egress) and cached on disk per URL, honouring the response's Cache-Control: max-age. Key rotation is automatic: a token whose kid is missing from the cached set triggers exactly one refresh (rate-limited, so forged kids can't be amplified into a JWKS-fetch flood). An unreachable JWKS endpoint is an operational fault — it surfaces as a server error, it does not silently log everyone out.

Mode A — stateless API (bearer per request)

Bind only a TokenVerifier (no UserProvider). AuthenticatorInterface then resolves to the stateless TokenAuthenticator, so the same #[Auth] / requireAuth() machinery works unchanged — every request re-derives the principal from the bearer header, nothing is persisted:

#[Auth(redirectTo: '')]            // 401 for an absent/bad token (no redirect)
final class ApiEndpoint extends PageComponent { /* ... */ }

#[Auth(roles: ['admin'])]          // roles read from the token's claims
final class AdminApi extends PageComponent { /* ... */ }

Mode B — session login (verify once, then a cookie session)

Verify the token in a login route and hand the resulting Identity to Authenticator::login(). The session Authenticator no longer needs a UserProvider, so a Firebase/Cognito app with no local password store still gets a normal cookie session and all the session-based #[Auth] behaviour after the first request:

<?php
// src/Pages/auth/token/route.php — POST { } with Authorization: Bearer <jwt>
declare(strict_types=1);

use Polidog\Relayer\Auth\Authenticator;
use Polidog\Relayer\Auth\Token\BearerToken;
use Polidog\Relayer\Auth\Token\TokenVerifier;
use Polidog\Relayer\Auth\Token\AuthorizationHeader;
use Polidog\Relayer\Http\Response;

return [
    'POST' => static function (
        TokenVerifier $verifier,
        Authenticator $auth,
        AuthorizationHeader $header,
    ): Response {
        $identity = $verifier->verify(
            BearerToken::parse($header->value()) ?? '',
        );
        if (null === $identity) {
            return Response::json(['error' => 'invalid token'], 401);
        }

        $auth->login($identity);             // rotates the session id

        return Response::json(['user' => $identity->toArray()]);
    },
];

Precedence (one rule, no hybrid)

Bound services AuthenticatorInterface is… Notes
UserProvider session Authenticator Password app (today's behaviour).
TokenVerifier only TokenAuthenticator Token-first API; #[Auth] enforces the bearer token.
both session Authenticator Session-first; TokenAuthenticator is still injectable by type on specific API routes.

Notes

  • Bearer only. The token is read from Authorization: Bearer …. On Apache/CGI the Authorization header is often stripped unless an .htaccess/vhost rule re-injects it — Relayer also reads the documented REDIRECT_HTTP_AUTHORIZATION fallback, but the rewrite rule must still be present on those hosts.
  • Pluggable claim mapping. Both factories take an optional identityMapper closure (fn(\stdClass $claims): ?Identity) to map custom claims / role sources; the defaults use sub as the id, fall back name → email → sub for the display name (Cognito also tries cognito:username), and read roles from cognito:groups (Cognito) or a custom roles array (Firebase).
  • attempt() / login() / logout() on TokenAuthenticator throw LogicException — a stateless bearer authenticator has no password handshake and no session; failing loudly beats a misleading no-op.

HTTP Cache Headers via #[Cache]

Attach Polidog\Relayer\Http\Cache to a Page class to control Cache-Control / Vary / ETag headers. The framework reads the attribute when AppRouter resolves the page through the container and emits the headers before the body is written.

<?php
// src/Pages/page.psx
declare(strict_types=1);

namespace App\Pages;

use Polidog\Relayer\Router\Component\PageComponent;
use Polidog\Relayer\Http\Cache;
use Polidog\Rosanna\Runtime\Element;

#[Cache(
    maxAge: 3600,
    sMaxAge: 86400,
    public: true,
    vary: ['Accept-Language'],
    etag: 'home-v1',
)]
final class HomePage extends PageComponent
{
    public function render(): Element { /* ... */ }
}

Supported parameters:

Parameter Effect
maxAge Cache-Control: max-age=<n>
sMaxAge Cache-Control: s-maxage=<n> (CDN)
public Cache-Control: public
private Cache-Control: private
noStore Cache-Control: no-store
noCache Cache-Control: no-cache
mustRevalidate Cache-Control: must-revalidate
immutable Cache-Control: immutable
vary Vary: <comma-joined values>
etag ETag: "<value>" (auto-quoted if raw)
etagWeak Emit ETag as a weak validator W/"…"
lastModified Last-Modified: <RFC 7231 GMT date> (any strtotime()-parseable string; UTC recommended)
etagKey Logical key looked up in the configured EtagStore (see below). Static etag wins when both are set.

Conditional GET / 304 Not Modified

When etag or lastModified is set, the framework also evaluates the request's If-None-Match / If-Modified-Since headers on safe methods (GET, HEAD). If the client already has a fresh copy, the response is short-circuited:

  1. cache validation headers (ETag, Last-Modified, Cache-Control, Vary) are emitted
  2. status is set to 304 Not Modified
  3. the request terminates before any body is rendered

ETag comparison follows the weak comparison rules of RFC 7232 §2.3.2, so W/"v1" and "v1" match each other and * matches any tag.

A request with a session cookie or an Authorization header always renders and never gets a 304, and so does one that has started a session by the time the policy is evaluated (a middleware.php that logged the visitor in from a remember-me cookie), even if it closed the session again with session_write_close() before calling $next(). A validator says nothing about who is asking, so a browser that cached the page anonymously would otherwise keep that copy after logging in.

A response to a request carrying an Authorization header is never left storable by a shared cache either: any policy other than private or noStore goes out as Cache-Control: private, no-store, on class and function pages alike. This also covers a header a middleware added with $request->withHeader() and the REDIRECT_HTTP_AUTHORIZATION copy Apache/CGI hands over, which Request reads as authorization when the real header is missing. A session cookie on its own does not count: a page that never touches the session keeps its declared policy (see the session note below).

Example

#[Cache(
    maxAge: 3600,
    public: true,
    vary: ['Accept-Language'],
    etag: 'home-v1',
    etagWeak: true,
    lastModified: '2025-01-15 10:00:00 UTC',
)]
final class HomePage extends PageComponent { /* ... */ }

Function-style pages: $ctx->cache()

PHP attributes only attach to classes, so function-style page.psx files declare their cache policy through PageContext instead:

<?php
// src/Pages/feed/page.psx
declare(strict_types=1);

use Polidog\Relayer\Http\Cache;
use Polidog\Relayer\Router\Component\PageContext;
use Polidog\Rosanna\Runtime\Element;

return function (PageContext $ctx): Closure {
    // Lightweight setup: declare cache, read params. NO DB queries here.
    $ctx->cache(new Cache(maxAge: 60, public: true, etagKey: 'feed'));

    return function () use ($ctx): Element {
        // Heavy work goes here — only runs on cache miss.
        // ... query DB, build the page
    };
};

The factory closure runs once per request (lightweight); the inner render closure runs only when the response is not a 304. So the 304 short-circuit saves the inner closure's body — keep DB/expensive work there to get the same "never touch the database" benefit class-style pages get.

All #[Cache] parameters are available on the Cache constructor.

Dynamic ETag via EtagStore

A static etag: 'home-v1' works for content that only changes on deploy. For data-driven pages, declare etagKey: and let an EtagStore resolve the current value at request time:

#[Cache(maxAge: 60, public: true, etagKey: 'user-list')]
final class UsersPage extends PageComponent { /* ... */ }

The framework looks up the key in the registered EtagStore before constructing the page. If the client's If-None-Match already matches, the request is short-circuited with 304 and no page or repository code runs — the database is never touched.

Producers (repositories, command handlers) update the stored value when their data changes:

final class UserRepository
{
    public function __construct(private readonly EtagStore $etags) {}

    public function save(User $user): void
    {
        // ... persist
        $this->etags->set('user-list', \sha1((string) \microtime(true)));
    }
}

Default backend: FileEtagStore

Out of the box the framework registers FileEtagStore writing to $projectRoot/var/cache/etags/ (one file per sha1(key), atomic write-then-rename). Zero configuration needed.

Custom backend (e.g. Redis)

Implement Polidog\Relayer\Http\EtagStore and register your class as the EtagStore alias. For example, with phpredis:

final class RedisEtagStore implements EtagStore
{
    public function __construct(
        private readonly \Redis $redis,
        private readonly string $prefix = 'etag:',
    ) {}

    public function get(string $key): ?string
    {
        $value = $this->redis->get($this->prefix . $key);
        return \is_string($value) && $value !== '' ? $value : null;
    }

    public function set(string $key, string $etag): void
    {
        $this->redis->set($this->prefix . $key, $etag);
    }

    public function forget(string $key): void
    {
        $this->redis->del($this->prefix . $key);
    }
}

Then wire it through services.yaml:

services:
  _defaults:
    autowire: true
    public: true

  Redis:
    factory: ['App\Factory\RedisFactory', 'connect']

  App\Infrastructure\RedisEtagStore: ~

  Polidog\Relayer\Http\EtagStore:
    alias: App\Infrastructure\RedisEtagStore

…or in AppConfigurator::configure():

$container->register(RedisEtagStore::class);
$container->setAlias(EtagStore::class, RedisEtagStore::class)->setPublic(true);

Notes / caveats

  • The attribute is honored only on PageComponent subclasses. Layouts and ordinary services with #[Cache] are ignored to avoid surprising header writes when fetched through the container.
  • All header writes are skipped once headers_sent() is true.
  • A declared policy is not overwritten by session_start(): the framework sets session_cache_limiter('') once one applies. Pages without a policy keep PHP's no-store when they use a session.
  • A response that used a session is never left storable by a shared cache. If the request opened a session at any point, by session_start() from anywhere (started or resumed from the visitor's cookie, and even if it was closed again with session_write_close() before the page ran), any policy other than private or noStore is replaced with Cache-Control: private, no-store, so a logged-in visitor gets an uncached copy of a #[Cache(public: true)] page whose layout reads $auth->user(), while anonymous visitors still get the declared policy. A page that wants browser caching for logged-in users declares private: true. This uses header_register_callback(), which holds one callback per request, so a callback your app registers later replaces it (and it replaces one registered earlier).
  • The 304 short-circuit issues exit; from the PSR-11 adapter. It runs before the page is instantiated, so neither the page constructor nor its injected dependencies execute on a cache hit.
  • If you need conditional cache policy (per-request, per-user), set headers manually inside render() instead.

Database

A thin PDO wrapper: raw SQL in, plain arrays out. No query builder, no SQL-file loader — pass SQL with named (:id) or positional (?) placeholders directly. It exists to give you four things you'd otherwise wire by hand: profiler visibility, explicit timeouts, one error type, and per-request read memoization.

Enable it

The DB layer is registered only when DATABASE_DSN is set — apps that don't use a database pay nothing and don't need to configure anything.

DATABASE_DSN=mysql:host=127.0.0.1;dbname=app;charset=utf8mb4
DATABASE_USER=app
DATABASE_PASSWORD=secret
DATABASE_TIMEOUT=5            # connect timeout, seconds (PDO::ATTR_TIMEOUT)
DATABASE_READ_TIMEOUT=10      # MySQL read timeout, seconds (optional)

DATABASE_DSN is a standard PDO DSN, so SQLite (sqlite:/path/app.db), PostgreSQL (pgsql:host=...), etc. all work. DATABASE_READ_TIMEOUT is applied only for mysql: DSNs.

Use it

Take a Database dependency in a page or component constructor:

use Polidog\Relayer\Db\Database;

final class UserPage extends PageComponent
{
    public function __construct(private readonly Database $db) {}

    public function render(): string
    {
        $user = $this->db->fetchOne(
            'SELECT id, name FROM users WHERE id = :id',
            ['id' => 42],
        );
        // ...
    }
}
Method Returns
fetchAll($sql, $params) list<array<string,mixed>>
fetchOne($sql, $params) array<string,mixed> or null
fetchValue($sql, $params) first column of first row, or null
perform($sql, $params) affected row count (int)
lastInsertId($name = null) last insert id (string)
transactional($callback) callback's return value
$db->transactional(function (Database $tx): void {
    $tx->perform('INSERT INTO orders (user_id) VALUES (?)', [$userId]);
    $tx->perform('UPDATE users SET order_count = order_count + 1 WHERE id = ?', [$userId]);
});

The callback runs inside a transaction — commit on return, rollback + rethrow on any exception. Use the $tx argument it receives so the calls stay traced and cached.

What you get for free

  • Errors — every driver failure is thrown as a single Polidog\Relayer\Db\DatabaseException; the original PDOException is kept as the previous exception.
  • Timeouts — a stuck DB surfaces as a DatabaseException within the configured timeout instead of hanging the worker.
  • Request-scoped cache — identical reads (fetchAll / fetchOne / fetchValue with the same SQL + params) hit an in-process cache for the rest of the request, so a page assembled from several components that each need the same lookup makes one round-trip, not N. Any perform or transactional flushes the cache. It is request-scoped only — no TTL, no cross-request sharing.
  • Profiler (dev) — every real query is recorded in the request profile as a timed db.query / db.mutate / db.transaction span with the SQL and bound params; cache hits show as db.cache_hit markers so you can see exactly how many round-trips memoization saved. In prod the profiler is a no-op, so there's no overhead.

Fetching external APIs (HTTP client)

A thin ext-curl wrapper for calling external Web APIs. Same decorator stack as the Database layer — contract → concrete → dev tracing → request-scoped cache: pass a method and URL, get an HttpResponse back. No middleware stack, no PSR-18 indirection.

Enable it

HttpClient is always registered. Unlike the DB it needs no required config, so (like the EtagStore) any page or component can take an HttpClient dependency with zero setup. Timeouts come from optional env vars:

HTTP_CLIENT_TIMEOUT=10           # whole-transfer timeout, seconds (CURLOPT_TIMEOUT)
HTTP_CLIENT_CONNECT_TIMEOUT=3    # connect-only timeout, seconds (CURLOPT_CONNECTTIMEOUT)

Left unset, cURL's defaults (no limit) apply.

Use it

Take an HttpClient dependency in a page or component constructor:

use Polidog\Relayer\Http\Client\HttpClient;

final class WeatherPage extends PageComponent
{
    public function __construct(private readonly HttpClient $http) {}

    public function render(): string
    {
        $res = $this->http->get('https://api.example.com/weather?city=tokyo', [
            'Accept' => 'application/json',
        ]);

        if (!$res->ok()) {
            // 4xx/5xx is not an exception — a normal HttpResponse to branch on
            return 'Could not fetch (' . $res->status . ')';
        }

        $data = $res->json();
        // ...
    }
}
Method Returns
get($url, $headers = []) HttpResponse
request($method, $url, $headers = [], $body = null) HttpResponse

HttpResponse exposes status / headers / body (public properties) plus ok() (2xx check), json() (decode the JSON body to a PHP value, objects as associative arrays; throws HttpClientException on non-JSON), and header($name) (case-insensitive single-header lookup).

$res = $this->http->request(
    'POST',
    'https://api.example.com/orders',
    ['Content-Type' => 'application/json'],
    json_encode(['item' => 'book']),
);

What you get for free

  • Errors — every transport failure (DNS, connect, TLS, timeout, truncated body) is thrown as a single Polidog\Relayer\Http\Client\HttpClientException. A 4xx/5xx is not an exception — it's a normal HttpResponse you inspect via status (the same way a SELECT returning zero rows is not a DatabaseException).
  • Timeouts — a stuck endpoint surfaces as an HttpClientException within the configured timeout instead of hanging the worker.
  • Request-scoped cache — identical safe requests (GET / HEAD with the same method + URL + headers) hit an in-process cache for the rest of the request. Non-safe methods (POST / PUT / PATCH / DELETE …) are never cached and flush the whole cache first — the same simple, safe choice CachingDatabase makes for perform(). Request-scoped only — no TTL, no cross-request sharing. Redirects are not followed (the 3xx is returned as-is).
  • Profiler (dev) — every real round-trip is recorded as a timed http.request span with method, URL, status and byte count; cache hits show as http.cache_hit markers. Request headers and bodies are not recorded, so an Authorization header or secret-bearing body never lands in the profile (the same stance TraceableDatabase takes on bound values). In prod the profiler is a no-op, so there's no overhead.

Validation

Polidog\Relayer\Validation is a schema validator inspired by Zod (TypeScript). It coerces and validates input (form fields always arrive as strings) and returns per-field error messages in a single pass. No extra dependency.

Declaring a schema

Build schemas through the Validator facade:

use Polidog\Relayer\Validation\Validator;

$schema = Validator::object([
    'email' => Validator::string()->trim()->email(),
    'name'  => Validator::string()->trim()->required('Name is required.'),
    'age'   => Validator::int()->min(0)->optional(),
    'role'  => Validator::enum(['admin', 'member'])->default('member'),
]);
Factory Schema
Validator::string() String. min/max/length/regex/email/url/trim/lower/upper
Validator::int() Integer; coerces numeric strings. min/max/positive/nonNegative
Validator::float() Float; coerces numeric strings
Validator::bool() Boolean
Validator::enum([...]) One of the allowed values; literal() for a single one
Validator::object([...]) Assoc array; unknown keys stripped by default, passthrough() keeps them
Validator::array($element) Validates every element against $element
Validator::email() / url() Shortcuts for string()->trim()->email() / url()

Modifiers available on every schema (immutable — each returns a clone, so a base schema is reusable as a building block):

Modifier Meaning
optional() Absent input becomes null; no further checks
nullable() Allows null (the key itself is still required)
default($value) Value used when input is absent
required(?$message) Force required + override the "absent" message
refine($predicate, $msg) Arbitrary extra validation predicate
transform($fn) Final transform after a value validates
satisfies($judge, $cond, $msg, $t) Semantic check via a Judge (see below)

For StringSchema / IntSchema / EnumSchema an empty string counts as "not provided", so optional / required / default behave intuitively with form inputs.

Parsing

$result = $schema->safeParse($_POST);

if ($result->success) {
    $data = $result->data;          // coerced values
} else {
    $errors = $result->errors;      // ['email' => '...', 'address.zip' => '...']
}
  • safeParse($input): ParseResult — never throws on validation errors; branch on success.
  • parse($input): mixed — throws ParseError (carrying $errors) on failure.
  • Nested object errors use dot paths (address.zip).

With form actions

The typical use is alongside $ctx->action() (example/src/Pages/signup/page.psx):

$schema = Validator::object([
    'name'     => Validator::string()->trim()->required('Name is required.'),
    'email'    => Validator::string()->trim()->email(),
    'password' => Validator::string()->min(8, 'Password must be at least 8 characters.'),
]);

$signup = $ctx->action('signup', function (array $form) use ($schema, &$errors): void {
    $result = $schema->safeParse($form);
    if (!$result->success) {
        $errors = $result->errors;   // hand field errors to the view
        return;
    }
    // $result->data is coerced
});

Semantic checks (TypeSafe Jev)

satisfies($judge, $condition, $message, $threshold = 0.5) validates what code can't — "is this spam?", "is this a real support question?". The value is sent to a Judge as state {"value": …}; the field passes when the condition holds with probability >= $threshold.

Set TYPESAFE_API_KEY (optionally TYPESAFE_MODEL, default jev-latest) and Polidog\Relayer\Validation\Judge is bound to JevJudge, which asks TypeSafe's Jev a noul question via the HttpClient. Without the key Judge is not registered.

use Polidog\Relayer\Validation\Judge;

$schema = Validator::object([
    'body' => Validator::string()->trim()->min(10)
        ->satisfies($judge, '`value` is a genuine support question, not spam or ads', 'Looks like spam.', 0.7),
]);
  • Put cheap checks (min, regex, …) before satisfies() — a failing earlier check skips the remote call.
  • Every call is a network round-trip that costs tokens; tune the threshold on your own data.
  • An API failure throws JudgeException out of safeParse()/parse(); catch it and decide whether an outage blocks the form.
  • Any other backend: implement Judge::probability(string $condition, mixed $state): float.

Internationalization (i18n)

A dependency-free translator with file-based catalogs, automatic locale resolution, and localized framework messages. Opt-in by configuration: an app that sets no i18n env var stays single-locale English and pays nothing — every framework string is byte-identical to the pre-i18n output.

Configure

APP_LOCALE=en            # default / active locale (default: en)
APP_LOCALES=en,ja        # supported locales (default: just APP_LOCALE)
LOCALE_COOKIE=locale     # cookie name for the cookie source (default: locale)
LOCALE_PATH_PREFIX=true  # opt out of /{locale}/... routing (default: true)

Translator and LocaleResolver are always registered in the container (autowired, public), but locale switching — cookie / Accept-Language / path-prefix resolution — only does anything once APP_LOCALES lists 2+ locales. With none (or a single locale) every request resolves to APP_LOCALE and no path rewriting happens: a route under /en/* keeps working exactly as before i18n existed. LOCALE_PATH_PREFIX=false only opts a multi-locale app out of /{locale}/... routing (cookie / Accept-Language still apply); it cannot turn prefix routing on for a single-locale app — there would be nothing to disambiguate.

APP_LOCALE is the default active locale, not the framework's fallback: the built-in relayer.* messages always fall back to English (the guaranteed-complete shipped catalog), so a missing translation never surfaces a raw key for a framework string.

Locale resolution order

For each request LocaleResolver picks the locale from, highest priority first:

  1. URL path prefix — /{locale}/... when the first segment is a supported locale. This is also the only source that rewrites the path the router matches on, so /ja/about and /about hit the same src/Pages/about/page.psx.
  2. Session — read only when a session is already active. Starting a session purely to detect a locale would emit a per-request Set-Cookie and break CDN caching of anonymous pages, so the resolver never does that; logged-in flows that already have a session get their stored _locale honored.
  3. Cookie — LOCALE_COOKIE (CDN-safe; no session). Set it from a route.php handler with Response::withCookie(), e.g. ->withCookie('locale', 'ja', ['expires' => new DateTimeImmutable('+1 year')]).
  4. Accept-Language — q-value negotiated against the supported list.
  5. Default — APP_LOCALE.

Matching is on the primary subtag (ja-JP matches a supported ja); the resolved value is the canonical spelling from APP_LOCALES. The chosen locale is also written to <html lang="…"> and exposed as $request->locale().

Deferred fragments and path-prefix routing. A <X defer /> sub-request is fetched from a root-absolute /_defer/{name} URL with no /{locale} segment (Rosanna roots it), so it never carries the parent page's path prefix. Its locale therefore resolves from the cookie / Accept-Language / default — not from the URL path. If you localize purely via /{locale}/… prefixes and want deferred fragments in the same language, also set the LOCALE_COOKIE (or rely on Accept-Language); the cookie is CDN-safe and is the intended carrier for this case.

Translating your own content

Drop PHP catalogs in <projectRoot>/translations/{locale}.php — flat or nested, merged over (and overriding) the framework catalogs:

// translations/ja.php
return [
    'home.title'   => 'ようこそ',
    'cart.items'   => '{count}点|{count}点', // pipe = plural forms
    'user'         => ['greeting' => 'こんにちは、{name}さん'],
];

Inject the Translator into any page, layout, or service:

use Polidog\Relayer\I18n\Translator;

return static fn (Translator $t) => h('h1', [], $t->trans('home.title'));
// placeholders:  $t->trans('user.greeting', ['name' => $name])
// plural:        $t->transChoice('cart.items', $count)

transChoice() selects a form from a one|other pipe message via a simplified CLDR rule (English-like one/other; single-form for Japanese, Chinese, Korean, …). A missing key degrades to the key itself (after placeholder substitution) — visible, never fatal.

Localized framework messages

Validation messages and HTTP error reason phrases (the HTML error page and the JSON {"error": …} body for API routes) are resolved through the same catalogs under the relayer.* namespace, with en and ja shipped. The Validation schemas are built outside the container, so they reach the active translator through a process-wide ambient holder (Polidog\Relayer\I18n\Translators) that AppRouter sets per request; a custom refine() / required('…') message is always passed through verbatim. CLI output (relayer …) is intentionally English-only for now.

Logger

A PSR-3 logger, backed by Monolog. Apps depend on the standard Psr\Log\LoggerInterface, so the same binding is shared with any third-party library that logs through PSR-3.

Enable it

The logger is always registered. Like the HttpClient it needs no required config, so any page or component can take a Psr\Log\LoggerInterface dependency with zero setup. Two optional env vars tune it:

LOG_LEVEL=info     # threshold; one of the 8 PSR-3 levels. Default: dev=debug, prod=info
LOG_FILE=/var/log/app.log   # sink path. Default: php://stderr

Left unset, log lines go to STDERR (12-factor: docker logs, journald, or a platform log drain collects them). Set LOG_FILE only for deploys that want a file — directory creation, .gitignore and rotation are then yours to manage.

Use it

Take a Psr\Log\LoggerInterface dependency in a page or component constructor:

use Psr\Log\LoggerInterface;

final class CheckoutPage extends PageComponent
{
    public function __construct(private readonly LoggerInterface $log) {}

    public function render(): string
    {
        $this->log->info('checkout started for {user}', ['user' => $userId]);
        // ...
    }
}

{placeholder} interpolation (PSR-3 §1.2) is applied to the sink output. The canonical ['exception' => $e] context key is formatted by Monolog.

What you get for free

  • Profiler (dev) — every entry is mirrored onto the profiler timeline as a log event whose label is the level, carrying the interpolated message and a redacted copy of the context: values under a pass/pwd/secret/token/api_key/auth key are masked and a Throwable is reduced to Class: message (profiles are plain JSON under var/cache/profiler/ — the same stance TraceableDatabase takes on bound values). The redaction is profiler-only: the real Monolog sink still receives the original context the app chose to log. In prod the alias points straight at Monolog with no decorator, so there is no overhead.

Profiler

A dev-only request profiler. Each request is recorded as a Profile (URL, method, status, event timeline) and inspectable through the /_profiler web view. Zero cost in production — user code can take a Profiler dependency without caring about the environment.

How it works

Profiler::class is always bound in DI:

  • prod (APP_ENV not dev/development) → NullProfiler. Every method is a no-op, callable without an if profiler enabled branch.
  • dev (APP_ENV=dev) → RecordingProfiler. Events accumulate on the Profile and are persisted by FileProfilerStorage to <projectRoot>/var/cache/profiler as JSON at end of request.

In dev, the Traceable decorators wrap AppRouter / Database / EtagStore / SessionStorage / Authenticator and feed spans like db.query, cache.etag_*, and session.* into the profile automatically. <X defer /> sub-requests are linked to their parent via parentToken.

Web view

AppRouter intercepts /_profiler before normal dispatch (when a ProfilerStorage has been wired in via setProfiler() — dev only) — so the profiler never profiles itself:

URL Content
/_profiler Recent requests (defer sub-requests folded into parent)
/_profiler/<token> One request in detail (event timeline + sub-requests)

Pure HTML — no JS, no external CSS — so it works offline.

Instrumenting from code

Take a Profiler in any page/service constructor:

use Polidog\Relayer\Profiler\Profiler;

public function __construct(private readonly Profiler $profiler) {}

// one-shot event
$this->profiler->collect('app', 'cache warmed', ['keys' => 12]);

// timed span (finalized by stop())
$span = $this->profiler->start('app', 'heavy compute');
$result = $this->compute();
$span->stop(['rows' => \count($result)]);

// timed span around a call, finalized for you (returns the call's value,
// records an `error` payload and rethrows if it throws)
$user = $this->profiler->measure('lib', 'sdk.fetchUser',
    fn () => $this->thirdPartySdk->fetchUser($id));

The same calls are no-ops under NullProfiler, so no environment branching is needed.

Tracing your own libraries

To see a third-party SDK or an internal service on the profiler timeline, wrap the call site with measure() — that is the whole feature. It records only the collector/label/duration, never the call's arguments or return value: a generic wrapper can't know which argument is a password or token, so it records nothing it wasn't explicitly given. If you want a payload, use start() + stop() and pass only what is safe.

When you call the same dependency from many places and want every call traced without repeating measure(), write a thin decorator — the same pattern the framework's own Traceable* classes use — and swap it in for dev only from your AppConfigurator:

final class TraceableWeatherApi implements WeatherApi
{
    public function __construct(
        private readonly WeatherApi $inner,
        private readonly Profiler $profiler,
    ) {}

    public function forecast(string $city): Forecast
    {
        // Choose the label/payload deliberately — record the city, not an
        // API key the real client might also take.
        return $this->profiler->measure('lib', 'weather.forecast',
            fn () => $this->inner->forecast($city));
    }
}

// config: AppConfigurator::configure()
if ($_ENV['APP_ENV'] === 'dev') {
    $container->register(TraceableWeatherApi::class)
        ->setArguments([new Reference(HttpWeatherApi::class), new Reference(Profiler::class)])
        ->setPublic(true);
    $container->setAlias(WeatherApi::class, TraceableWeatherApi::class)->setPublic(true);
}

Prod keeps the plain alias, so the decorator and its profiler cost exist only in dev — exactly how TraceableDatabase / TraceableHttpClient are wired. Each decorator stays responsible for its own redaction (log the city, not the key); there is deliberately no generic "trace every service" proxy, because it would strip that per-contract judgement and leak secrets into the dev profile JSON.

Clearing stored profiles

vendor/bin/relayer profiler:clear deletes the JSON profiles under var/cache/profiler so /_profiler starts fresh. It only removes the *.json the storage writes (the directory is recreated on the next dev request); a missing cache is reported and treated as success, so re-running is always safe.

Source Layout

Namespace Purpose
Polidog\Relayer\Relayer Boot entrypoint (env load + DI build + router wire-up).
Polidog\Relayer\AppConfigurator Extension point for service registrations.
Polidog\Relayer\InjectorContainer PSR-11 adapter with reflection autowire + 304 short-circuit.
Polidog\Relayer\Router\AppRouter File-based router for src/Pages/ (PSR-11 container–driven).
Polidog\Relayer\Router\Component\* PageComponent, ErrorPageComponent, FunctionPage, PageContext.
Polidog\Relayer\Router\Layout\* LayoutComponent + nested layout rendering.
Polidog\Relayer\Router\Document\* HTML document wrapper / metadata.
Polidog\Relayer\Router\Form\* CSRF tokens + form action dispatcher.
Polidog\Relayer\Router\Routing\* Page scanner, route table, matcher.
Polidog\Relayer\Db\Database Minimal SQL contract (default: PdoDatabase, cached, dev-traced).
Polidog\Relayer\Db\DatabaseException The single error type the DB layer raises.
Polidog\Relayer\Http\Client\HttpClient Minimal HTTP contract (default: CurlHttpClient, cached, dev-traced).
Polidog\Relayer\Http\Client\HttpResponse HTTP client result (status / headers / body / json()).
Polidog\Relayer\Http\Client\HttpClientException The single error type the HTTP client layer raises.
Polidog\Relayer\Http\Cache #[Cache] attribute.
Polidog\Relayer\Http\CachePolicy Header emission + conditional GET evaluation.
Polidog\Relayer\Http\EtagStore Pluggable ETag storage interface.
Polidog\Relayer\Http\FileEtagStore Default file-backed EtagStore implementation.
Polidog\Relayer\Auth\Auth #[Auth] attribute.
Polidog\Relayer\Auth\Authenticator Session-based authentication orchestrator.
Polidog\Relayer\Auth\Identity / Credentials Principal + login-handshake value objects.
Polidog\Relayer\Auth\UserProvider App-supplied user lookup interface.
Polidog\Relayer\Auth\PasswordHasher Hashing interface (default: NativePasswordHasher).
Polidog\Relayer\Auth\SessionStorage Session storage interface (default: NativeSession).
Polidog\Relayer\Validation\Validator Zod-style schema builder facade (safeParse / parse).
Polidog\Relayer\Validation\Schema Schema base + types (string/int/float/bool/enum/array/object).
Polidog\Relayer\Profiler\Profiler Request-tracing facade (dev: recording / prod: no-op).
Polidog\Relayer\Profiler\ProfilerWebView /_profiler dev view (index + detail).

The component runtime is polidog/rosanna (JSX-style rendering and the PSX compiler) and polidog/rosanna-defer (deferred fragments). DI, dotenv, and Symfony YAML config are all wired by Relayer::boot() — there is no other package to install.

Migrating from polidog/use-php

Relayer now runs on polidog/rosanna and polidog/rosanna-defer.

useState and everything built on it are gone: useState, useFormAction, fc(), onClick / onChange / onInput / onSubmit, wire:*, Snapshot storage and partial page updates. Rendering an event prop now throws an exception. Use a server action ($ctx->action()) or a React island instead. USEPHP_SNAPSHOT_SECRET is no longer read, and AppRouter::setUsePhp() is now setRosanna().

  1. Run vendor/bin/relayer upgrade. It replaces the asset publisher in your composer.json scripts, and updates scaffolded files you have not edited.

  2. In your .psx and PHP files, change Polidog\UsePhp\Html\H, Polidog\UsePhp\Runtime\Element and the like to Polidog\Rosanna\….

  3. Replace fc(fn, defer: new Defer(...)) with deferred(fn, new Defer(...)). Defer is now Polidog\Rosanna\Defer\Defer and the function is Polidog\Rosanna\Defer\deferred.

  4. Replace window.usePHP with window.Rosanna, the X-UsePHP-Defer header with X-Rosanna-Defer, and data-usephp-* with data-rosanna-* in your own JS and templates. If you read the form fields directly, _usephp_action and _usephp_csrf are now _rosanna_action and _rosanna_csrf.

  5. Precompile with vendor/bin/relayer compile. vendor/bin/usephp compile no longer exists, and vendor/bin/rosanna compile does not write the deferred manifest Relayer needs.

  6. The committed .env no longer sets APP_ENV=dev, and a .env without APP_ENV now means production. When relayer upgrade rewrites an untouched .env, it moves APP_ENV=dev to the gitignored .env.local: it creates the file, appends the line if the file does not set APP_ENV, and leaves a file that does set it alone. An edited .env stays as it is and no .env.local is written, whatever it says, so check APP_ENV yourself. relayer init writes .env.local only in a new project. Other checkouts need echo APP_ENV=dev >> .env.local. Both commands first make sure .gitignore excludes .env.local, and write nothing if they cannot. If Git tracks .env.local, .gitignore does not help: upgrade then leaves .env and .env.local as they are and fails (init skips the file). Run git rm --cached .env.local, commit, and re-run relayer upgrade.

  7. The <div data-rosanna="…"> that wrapped the page is gone. Fix any CSS or JS that relies on it.

  8. The scaffolded Caddyfile now really sends requests to public/worker.php (the old worker block was never reached), so relayer upgrade turns worker mode on if you have not edited the file. It is for production: the single binary or frankenphp run --config Caddyfile. Develop with php -S 127.0.0.1:8000 -t public or docker compose up --build, which serve each request from scratch and do not read it. In a worker, container services live across requests, so your own services must not keep per-request state in properties. Clear what they do hold after each request with RequestLifecycle::onReset() (one callback per name; the same name replaces it):

    use Polidog\Rosanna\Runtime\RequestLifecycle;
    
    RequestLifecycle::onReset(self::class, function (): void {
        $this->items = [];
    });

    To stay in classic (per-request) mode, remove the index and worker lines from the Caddyfile.

Forms rendered by the previous version stop working when you deploy, because the field names and the session keys changed. Purge your CDN at deploy time so it does not serve old HTML or the old usephp.js.

Running Tests

vendor/bin/phpunit

License

MIT

About

Resources

Stars

6 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages