Routing
Router files map URL sub-paths to closures. A page's view picks the router.
On this page
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.phpof the view's owner: your site's for a site view, the addon's or the core's for an@aliasview - 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.
In a template, isFragmentRequest() is true for such a request.
<a href="{$page->url()}" hx-get="{$page->url()}:results">Results</a>
{partial 'target-id'} lets a block say where it lands, in place of an hx-target on the link. On an HTMX fragment request it wraps its content in <template hx type="partial" hx-target="#target-id" hx-swap="innerHTML">, which HTMX 4 places by itself. On a full page it adds nothing. swap: changes the strategy: {partial 'target-id', swap: 'outerHTML'}.
Caching a fragment
Give a {fragment} a cache: in seconds and its rendered HTML is stored in the cache of the page that renders it 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 (target: and swap: override the partial's), 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>
{component} 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:
{component 'sidebar', cache: 300}
{include '@ui/card.latte', title: 'Latest', items: $latest}
{/component}
<aside hx-get="{p('widgets')->url()}:sidebar" hx-trigger="load"></aside>
The entry is keyed by 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.
- A page's cache is cleared when the page is created, saved, moved or deleted, or when any page below it is. A collection item or file write clears the page that owns it and the pages above it, and so does a History restore. A section's listing stays fresh through writes to the pages in it, and the home page's cache is cleared by every page write.
- A page move, any user change and a History sync clear every page's cache.
- A fragment that lists pages outside its own page's subtree stays stale until its TTL runs out. Give it
in:to hold it in the cache of a page above them all. See below. - Changes that fire no event, such as config, templates and code, show up when the TTL runs out. A deploy that keeps
site/data/cachefrom the last release should runphlat cache:clear fragments; the referencedeploy.phpgives each release an empty cache. - A fragment that starts a session 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.
Choosing the owning page
in: names the page whose cache holds the entry, as a literal path. It takes the place of the page that renders it, so a write anywhere under that page clears it. {fragment}, {component} and {cache} all take it:
{fragment 'latest', cache: 300, in: '/'}
{include '@ui/card.latte', title: 'Latest', items: $latest}
{/fragment}
A page that does not exist holds nothing, so the region renders every time.
Caching a region
{cache} keeps part of a template, inside a normal render, in the cache of the page being rendered for a number of seconds. The key is the page URL and query. key: replaces it so several pages can share one region, and a shared region needs in:, since the page that holds it must be cleared whenever any of them could change:
{cache 'nav', 300, key: $section->uuid, in: '/'}
{include '@ui/menu.latte', items: $section->children()}
{/cache}
A hit skips the region entirely, so nothing inside it may set a response header or a variable used after it. The same rules as for a fragment apply: only anonymous GET requests are served from the cache.
Visitor options
$app->options stores choices the server renders, such as a theme or a dismissed banner, in a cookie. Declare each one in config as a slug key and a list of allowed values. The first value is the default, and a cookie can only ever hold a declared value.
"options": {
"theme": ["default", "dark"]
}
$app->options->get('theme') returns the visitor's value or the default, and set('theme', 'dark') changes it. Setting an option back to its default removes the cookie. A visitor who holds any option cookie is not served cached pages, since theirs differ from everyone else's. The core options view takes the change: a page with that view accepts a POST of value to {page url}/{key}, then redirects back to the referring path on your own site. An htmx request gets an empty 204 instead, for a page that applies the change in place. The theme-picker component of the @ui addon does this for a theme option.
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) {
$name = San::text($app->request->post('name') ?? '');
$page->session()->flash('flash', "Hello, {$name}.");
Res::redirect($page->url());
});
};