Routing
Router files map URL sub-paths to closures. A page's view picks the router.
How routing works
On each request, PhlatPage walks the URL from the deepest segment upward until it finds a page. It loads that page's view router and matches the remaining sub-path against its routes.
A page at /shop/ with view shop is handled by site/views/shop/router.php. The sub-path is relative to the page URL, so the same router works wherever the page sits in the tree.
If the view has no router, the page renders on GET. If it has one and no route matches, the request 404s.
Router files
A router file returns a callable that registers routes on $page->router:
<?php
use Phlat\App;
use Phlat\Page;
return function (App $app, Page $page): void {
$page->router->get('', fn() => $page);
$page->router->get('archive', fn() => ...);
$page->router->post('contact', fn() => ...);
};
get, post, put, patch and delete register a route for that verb; any matches every verb.
A handler returns $page to render the current page, or a different Page to render that one. Res::redirect() and the Hx:: exit helpers exit internally and return nothing.
Dispatch order
Before matching, PhlatPage loads other router files for their hooks only, discarding the routes they register:
- The global router,
views/router.php - Each ancestor page's view router, root first
- The current page's view router, whose routes are kept
There is no per-page router override. Resolution is by view.
Route parameters
| Token | Matches |
|---|---|
{any} |
one path segment, including empty |
{all} |
everything after this point, across slashes, including empty |
{alpha} |
one non-empty segment, letters only |
{alphanum} |
one non-empty segment, letters and digits |
{num} |
one non-empty segment, digits only |
{slug} |
one non-empty segment, lowercase a-z and 0-9 with single hyphens between |
Use the narrower tokens for identifiers, so a malformed or empty value 404s in the router instead of reaching the handler. Captures are passed to the handler as positional arguments:
$page->router->get('post/{slug}', fn($slug) => ...);
$page->router->patch('pages/{all}', fn($path) => ...);
$page->router->get('archive/{num}/{slug}', fn($year, $slug) => ...);
A {all} directly after a literal / also matches the bare segment: 'new/{all}' matches new as well as new/anything/nested.
before() and after() hooks
Register hooks on $page->router from any router file. before runs once, right before route matching, after every router in the dispatch chain has loaded. after runs once a handler has resolved a result page, right before it is returned.
A hook that wants to stop the request redirects or throws. There is no return value to block with.
// Runs for every page under this section
$page->router->before(function ($app, $page) {
$app->session->requireLogin();
});
Fragment URLs
A :blockname suffix renders only that {block} of the page's template, so HTMX can fetch a region without a dedicated route. It works for GET automatically, and is parsed into $app->request->block.
<a href="{$page->url()}" hx-get="{$page->url()}:results">Results</a>
Caching a fragment
Give a {fragment} a cache: in seconds and its rendered HTML is stored and served without running any controller:
{fragment 'sidebar', cache: 300}
{include '@ui/card.latte', title: 'Latest', items: $latest}
{/fragment}
{fragment 'name'} is {block} and {partial} in one tag, so it is also reachable at :sidebar. Any page can embed it by fetching that URL. The fragment lands in the element whose id matches its name:
<div id="sidebar" hx-get="{p('widgets')->url()}:sidebar" hx-trigger="load" hx-swap="none"></div>
{companion} takes the same cache:. It is never rendered in its own page and has no {partial} wrapper, so the element that fetches it decides where it lands, which suits a component embedded in other pages:
{companion 'sidebar', cache: 300}
{include '@ui/card.latte', title: 'Latest', items: $latest}
{/companion}
<aside hx-get="{p('widgets')->url()}:sidebar" hx-trigger="load"></aside>
The cache key is the page URL, the block, the query string and whether the request came from htmx.
- Only anonymous GET requests are served from it, and never in debug mode. A signed-in visitor always gets a fresh render.
- Any save, create, delete or move of a page, collection item, file or user clears the whole fragment cache, as does a History sync. A fragment can list other pages, so one page's change can make any of them stale.
- Changes that fire no event, such as config, templates and code, show up when the TTL runs out. A deploy that keeps
data/cachefrom the last release should runphlat cache:clear fragments; the referencedeploy.phpgives each release an empty cache. - A fragment that starts a session, like a form with a CSRF token, is never stored.
- A cached fragment replays only its HTML, so it must not set response headers.
- Browsers still get
no-store. The cache is on the server. - Only blocks written directly in the target page's own template can be fetched this way, not blocks inside an
{include}d file. If the target's view has a router, it needs aget('')route.
Example
Use the Toolkit in handlers (Res, San, $app->session), never raw headers, superglobals or echo.
<?php
use Phlat\App;
use Phlat\Page;
use Phlat\Toolkit\Res;
use Phlat\Toolkit\San;
return function (App $app, Page $page): void {
$page->router->before(function ($app, $page) {
$app->session->requireLogin();
});
$page->router->get('', fn() => $page);
$page->router->post('', function () use ($app, $page) {
if (!$app->session->verifyCsrf($app->request->post('_csrf') ?? ''))
Res::redirect($page->url());
$name = San::text($app->request->post('name') ?? '');
$app->session->flash('flash', "Hello, {$name}.");
Res::redirect($page->url());
});
};