Download the PHP package polidog/relayer without Composer

On this page you can find all versions of the php package polidog/relayer. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package relayer

Relayer

日本語

Opinionated, batteries-included framework on top of polidog/use-php. Bundles:

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

Requirements

Installation

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 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:

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:

It reads the extra.relayer.structure_version marker, writes only the files added in the versions between it and the current one, then advances the marker (the one mutation init deliberately never makes). Every step is skip-if-exists, so files you have edited are kept and reported as skipped; 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

Quick Start

public/index.php:

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:

Environment Variables

Put a .env in the project root:

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() 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.

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 / $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:

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.

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:

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:

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

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.

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

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:

Error pages

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.

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() 404error.psx / fallback. Alias for abort(404).
$ctx->abort($status) Any 4xx/5xxerror.psx with that status / fallback. Non-error codes throw InvalidArgumentException.

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.

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:

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

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.

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:

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

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, …):

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

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).

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:

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.

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():

Invalid CSRF tokens return a 403.

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):

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.

Binding arguments

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

$args is embedded verbatim in the base64 action token (it is not signed — tamper detection is the CSRF token's job). Keep bound values to identifiers and always re-validate authorization/integrity inside the handler (e.g. verify ownership of the incoming $id server-side).

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):

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.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:

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:

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:

Then pass it to boot():

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:

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.

Autowire defaults

The framework iterates every Definition you register and:

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:

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

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.

Request API (all immutable):

Method Returns
$req->method uppercase HTTP method
$req->path request path (no query string)
$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)

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

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.

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:

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.

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.

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.

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

Function-style factories use a declarative guard on PageContext:

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):

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:

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.

Notes

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:

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:

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:

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

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.

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.

Example

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

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

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:

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:

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:

Then wire it through services.yaml:

…or in AppConfigurator::configure():

Notes / caveats

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 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:

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

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

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:

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

Use it

Take an HttpClient dependency in a page or component constructor:

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).

What you get for free

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:

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

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

Parsing

With form actions

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

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

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. CookieLOCALE_COOKIE (CDN-safe; no session).
  4. Accept-Language — q-value negotiated against the supported list.
  5. DefaultAPP_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 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:

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

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:

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:

{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

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:

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:

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:

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 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.

Running Tests

License

MIT


All versions of relayer with dependencies

PHP Build Version
Package Version
Requires php Version >=8.5
ext-curl Version *
ext-openssl Version *
firebase/php-jwt Version ^7.0
polidog/use-php Version ^0.7.1
symfony/dependency-injection Version ^7.1
symfony/config Version ^7.1
symfony/yaml Version ^7.1
psr/container Version ^2.0
symfony/dotenv Version ^7.1
monolog/monolog Version ^3.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package polidog/relayer contains the following files

Loading the files please wait ...