Routing & Pages#
The router maps src/Pages/ exactly as the filesystem is laid out (conventions compatible with the Next.js App Router).
| File | Role |
|---|---|
page.psx | Renders that route (one per directory) |
layout.psx | Wraps child pages (stacked from root to leaf) |
error.psx | Error page (catches abort()/notFound(). 404/fallback) |
route.php | JSON API endpoint |
[param]/ | Dynamic segment (read via $ctx->params['param']) |
(group)/ | Route group. Organizes files without adding to the URL / per-group layout.psx |
_private/ | A folder starting with _. The folder and everything under it is excluded from routing |
A single directory is either a page or a
route.php(they cannot coexist).
The two ways to write a page#
Function style#
<?php
use App\Service\UserRepository;
use Polidog\Relayer\Router\Component\PageContext;
return function (PageContext $ctx, UserRepository $users): Closure {
$ctx->metadata(['title' => 'Users']);
return fn () => (
<ul>
{array_map(fn ($u) => <li>{$u->name}</li>, $users->all())}
</ul>
);
};With two stages (the factory returns a render closure), you can declare metadata and cache policy before rendering. You may also return the Element directly in a single stage.
Class style#
<?php
namespace App\Pages\Users;
use Polidog\Relayer\Router\Component\PageComponent;
use Polidog\UsePhp\Runtime\Element;
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>;
}
}Arguments are autowired by type: PageContext, Request, Identity (optional if nullable; non-null means authentication is required), and container services. Do not read $_GET / $_POST / $_SERVER directly — always take a Request.
Dynamic segments#
src/Pages/docs/[slug]/page.psx matches /docs/:slug, and the value is read via $ctx->params['slug'] (this site's documentation display page is a real example). A segment is one delimiter and does not include slashes. The name in [name] is alphanumeric, starting with a letter or underscore. Catch-all [...slug] and optional [[param]] like Next.js are not supported (single segments only). When you want to accept multiple levels, carve out a [param] for each level.
Route groups (group)/ and excluded folders _private/#
Route groups — a folder wrapped in parentheses, (name)/, does not add a URL segment. You can organize files without changing the URL, or put a different layout.psx on only some routes (the parenthesized name itself does not appear in the URL).
src/Pages/
(marketing)/
layout.psx ← a layout only for everything under (marketing)
page.psx → "/"
about/page.psx → "/about"
(app)/
layout.psx ← a different layout
dashboard/page.psx → "/dashboard"A collision where different physical paths resolve to the same URL (for example, (a)/about and (b)/about both resolving to /about) is an error not on the first request but at scan / routes:compile time.
Excluded folders — a folder starting with _ (_private, _components, etc.) takes that folder and everything under it out of routing. Use it when you want to keep fragments or drafts that should not have a URL alongside the rest under src/Pages/.
404 and errors#
When a lookup is empty, do not set http_response_code() by hand; declare "not found" with $ctx->notFound() (= $ctx->abort(404)). An HttpException is thrown, and the router renders error.psx (or the built-in error page).
return function (PageContext $ctx, UserRepository $users): Closure {
$user = $users->find($ctx->params['id']) ?? $ctx->notFound();
return fn () => <h1>{$user->name}</h1>;
};Any 4xx/5xx is done like $ctx->abort(403). For 3xx, use not abort() but $ctx->redirect() (→ Server Actions). There is one error.psx directly at the root. Statuses other than 404 can receive a status/message (404 always shows a unified Not Found view).
Layouts#
layout.psx is a class extending LayoutComponent, where the child pages are in $this->getChildren(). The layout of each nested level stacks in order from the root. Layouts are created with new without going through container DI, so the standard practice is to keep them as charms with no dependencies (this site's header/footer is such a case).
Inspecting and compiling routes#
vendor/bin/relayer routes # list all detected routes
vendor/bin/relayer routes:compile # write out a snapshot for productionroutes lists all detected pages and API endpoints together with their methods.
By default the router scans src/Pages/ on every request. Running routes:compile at deploy time writes it out to var/cache/routes/routes.php (portable and OPcache-warmed), and production reads that single file instead of walking the tree. Only the presence of the file gates this; if it is absent it falls back to a live scan (dev is always up to date and never goes stale). URL collisions from route groups, and conflicts between a page and route.php, fail not on the first request but at routes:compile (deploy time).
Change history (1)
- Created