AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Opinionated, batteries-included framework on top of polidog/use-php. Bundles:
src/Pages/page.psx,
layout.psx, dynamic segments, error pages)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$ctx->js() /
PageComponent::addJs() / LayoutComponent::addJs()) emitted at the end
of <body> after the bundle, in declaration orderIsland::mount()) — a rich-UI escape hatch: server-rendered
shell, client React component, props from PHP, your own bundlesrc/Pages/middleware.php) wrapping every
dispatch, plus a ready-made Cors middleware$ctx->action() /
PageComponent::action() dispatch form posts to in-page handlers).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)#[Auth] attribute / $ctx->requireAuth(),
role checks, password hashing, pluggable UserProvider and SessionStorage#[Auth] guardValidator::object(),
safeParse / parse, form-input coercion + per-field errors)/_profiler view, no-op in production)Exposes a single Relayer::boot() entrypoint so app code stays small.
composer require polidog/relayer
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/usephp.js, which the default document references.
It is idempotent and non-destructive:
composer.json is patched additively — it adds the App\
PSR-4 autoload, the usePHP asset-publish scripts, and an
extra.relayer.structure_version marker, and leaves everything else
untouched.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.
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.
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)
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:
.env from the project root (if present) into $_ENV / $_SERVER.ContainerBuilder, auto-load config/services.{yaml,yml,php} if present, then let AppConfigurator register services on it.AppRouter.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();
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:
.env — committed defaults.env.local — local overrides (gitignored).env.{APP_ENV} — per-environment defaults (committed).env.{APP_ENV}.local — per-environment local overrides (gitignored)Missing files are skipped silently. Variables already in $_ENV /
$_SERVER / getenv() win over the base .env; the .local files
override the committed counterparts.
APP_ENV=dev (or development) enables PSX auto-compilation. Any other value
(including unset) treats the app as production: pre-compile with
vendor/bin/usephp compile src/Pages during deploy.
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 / $ctx->abort() statuses (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/usephp 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/usephp compile src/Pages src/Components # .psx -> compiled PHP
vendor/bin/relayer routes:compile # route map -> PHP
vendor/bin/relayer container:compile # DI container -> PHP
Then run with APP_ENV unset and OPcache validate_timestamps=0 (the
scaffolded php.ini carries the production block). Each step is
presence-gated, so a missing artifact degrades to the live path rather
than breaking.
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.
<?php
// src/Pages/users/[id]/page.psx
declare(strict_types=1);
namespace App\Pages\Users;
use App\Service\UserRepository;
use Polidog\UsePhp\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.
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\UsePhp\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()).
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
A root error.psx (extending ErrorPageComponent) renders error responses
inside the root layout — an unmatched route (404) and every $ctx->abort()
status. It receives the status and message via ErrorPageComponent, so one
error.psx can branch on getStatusCode() for a 404 vs. 403 vs. 500 page.
Without one, the framework emits a minimal default document.
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).
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']. 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 runs the handler
and drops the body. An explicit handler for either always wins. A method
with no handler (and not synthesizable) → + (JSON body).
$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>;
}
}
<body>, after the main usePHP bundle, in
declaration order. Layout scripts come before the page's; an outer
(root) layout before an inner one.defer, async, module
(type="module"). For inline JS use $document->addHeadHtml() — the
same hook the Island loader rides on (below).metadata()).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} />);
});
MutationObserver), parses props, and calls your registered mount fn;
registration and DOM order are interchangeable.fetch your JSON API routes
(route.php) — there is no separate island↔server channel.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).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);
};
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.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).
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,POST /api/users api src/Pages/api/users/route.php
GET,POST /users/[id] page src/Pages/users/[id]/page.psx
Pages report GET,POST (POST is how server actions / useState reach a
page); API routes list their declared methods. A route.php that fails to
load is shown as ? with a warning line, not silently hidden.
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.
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="_usephp_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.
useState updates (onClick={fn() => $setX(...)}) are protected the same
way: the rendered setState form carries the session-bound _usephp_csrf
field automatically (usePHP's client sends it on the partial
X-UsePHP-Partial update too), and a state POST with a missing or invalid
token is answered with 403 without changing any state. Hand-written
clients that POST _usephp_action / _usephp_component must include it.
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.
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\UsePhp\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
_usephp_action / _usephp_csrf stripped). Action names must be unique
per page — registering the same name twice throws.
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).
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 */ };
};
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.
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.
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.
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 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\UsePhp\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.
The framework iterates every Definition you register and:
autowired if you didn't pass explicit constructor argumentspublic = true so PSR-11 get($id) can fetch itIf you need a private service or fully-manual wiring, configure the
Definition explicitly — your settings win.
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\UsePhp\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).
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\UsePhp\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) |
$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->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, so read it yourself (via
$req->header()) once you know you're behind a proxy you trust.
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.
UserProviderThe 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'],
);
}
}
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.
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\UsePhp\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.
#[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=<requested-path>
(URL-encoded, same-origin only). Authenticated users lacking the
required role get 403 Forbidden.
#[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.
$ctx->requireAuth() / Identity injectionFunction-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\UsePhp\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.
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 calls session_start() lazily on first read/write,
so just resolving the service through DI does not eagerly emit
Set-Cookie. It shares $_SESSION with the existing CSRF token
machinery — no duplicate session starts.
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 — paths
starting with // or an absolute URL are dropped to prevent
open-redirect bouncing off the login page.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).
TokenVerifierTokenVerifier 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.
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 { /* ... */ }
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()]);
},
];
| 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. |
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.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.#[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\UsePhp\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. |
304 Not ModifiedWhen 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:
ETag, Last-Modified, Cache-Control, Vary)
are emitted304 Not ModifiedETag comparison follows the weak comparison rules of RFC 7232 §2.3.2, so
W/"v1" and "v1" match each other and * matches any tag.
#[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 { /* ... */ }
$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\UsePhp\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.
EtagStoreA 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)));
}
}
FileEtagStoreOut 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.
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);
PageComponent subclasses. Layouts and
ordinary services with #[Cache] are ignored to avoid surprising header
writes when fetched through the container.headers_sent() is true.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.render() instead.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.
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.
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.
Polidog\Relayer\Db\DatabaseException; the original PDOException is
kept as the previous exception.DatabaseException within the
configured timeout instead of hanging the worker.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.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.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.
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.
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']),
);
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).HttpClientException
within the configured timeout instead of hanging the worker.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).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.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.
Build schemas through the Validator facade:
use Polidog\Relayer\Validation\Validator;
$schema = Validator::object([
'email' => Validator::string()->trim()->email(),
'name' => Validator::string()->trim()->min(1, '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.
$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.object errors use dot paths (address.zip).The typical use is alongside $ctx->action()
(example/src/Pages/signup/page.psx):
$schema = Validator::object([
'name' => Validator::string()->trim()->min(1, '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
});
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),
]);
min, regex, …) before satisfies() — a failing
earlier check skips the remote call.JudgeException out of safeParse()/parse();
catch it and decide whether an outage blocks the form.Judge::probability(string $condition, mixed $state): float.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.
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.
For each request LocaleResolver picks the locale from, highest priority
first:
/{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.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.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')]).Accept-Language — q-value negotiated against the supported list.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 (usePHP 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 theLOCALE_COOKIE(or rely onAccept-Language); the cookie is CDN-safe and is the intended carrier for this case.
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.
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.
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.
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.
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.
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.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.
Profiler::class is always bound in DI:
APP_ENV not dev/development) → NullProfiler. Every method is
a no-op, callable without an if profiler enabled branch.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.
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.
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.
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.
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.
| 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 implementation. |
The only third-party runtime dependency is polidog/use-php (the JSX-style
component runtime). DI, dotenv, and Symfony YAML config are all wired by
Relayer::boot() — there is no other package to install.
vendor/bin/phpunit
MIT
HEADGET405Allowroute.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).
EtagStorePolidog\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). |