Best practices
How to work with PhlatPage's design: the page tree as the model, HTML from every route, blocks as endpoints and lean Latte templates.
On this page
PhlatPage is built for one site, rendered on the server, with the page tree as its structure. Each part of the system leans on the others: the tree decides URLs, routing and cache invalidation, blocks double as HTMX endpoints, and Latte keeps the markup readable. A site that follows that shape stays small. These are the habits that keep it there.
Let the page tree be the model
Start every piece of content with one question: does it need its own URL?
| Answer | Use | Kept in |
|---|---|---|
| Yes | A page | site/pages/, every write recorded in History |
| No, it belongs to a page | A collection item | site/data/collections/{field}.db |
| It is an account | A user | site/data/users/ |
Comments, likes and registrations are collection items. A product, an article or a book is a page, even when there are thousands of them: place a large set with children.path rather than moving it into a collection.
Read structure from the tree instead of restating it. Navigation is a page's children(), a listing is a find() query, and a section's settings are fields on the section page. A config key or a hard-coded list for something the tree already knows will drift from it.
{foreach $page->children() as $child}
<a href="{$child->url()}">{$child->title}</a>
{/foreach}
{foreach find('view=article, sort=-created, limit=4') as $article}
{include 'article/card.latte', p: $article}
{/foreach}
Group behavior by view. A view's router matches sub-paths relative to the page, so the same shop view works at /shop/ or /en/shop/ without changes.
Write through the API
Change content with Page::save(), Page::new() and the collection methods, whether from the admin, the CLI, a seeder or your own route. A save updates the registry, records History and clears the right caches. Writing _data.json yourself skips all three.
Records are immutable. save() returns the record as saved and leaves the original as it was read, so keep the result:
$page = $page->save(['title' => $title]);
Answer with HTML
Every route returns a page, a block of one, or a redirect. UI state lives in the URL and the DOM, never in JSON passed to client code. A form posts, the handler validates and saves, then redirects to a page that shows the result:
<?php
use Phlat\App;
use Phlat\Page;
use Phlat\Toolkit\Res;
use Phlat\Toolkit\San;
return function (App $app, Page $page): void {
$page->router->get('', fn() => $page);
$page->router->post('', function () use ($app, $page) {
$page->comments->new([
'name' => San::text($app->request->post('name') ?? ''),
'comment' => San::text($app->request->post('comment') ?? ''),
]);
$page->session()->flash('flash', 'Thanks for your comment.');
Res::redirect($page->url());
});
};
For an error, throw a typed exception such as NotFoundException or ValidationException. The error template renders it for a full page and for an HTMX request alike, so a handler never builds an error response.
Reach for a block before a route
Any {block} in a page's own template can be fetched alone by adding :blockname to the page URL. That covers most of what an interactive region needs: pagination, filters, a refreshed list, a search result. Before adding a route that returns part of a page, name the block.
A link or form that loads a block sets three attributes: href for the full page, hx-get for the block and hx-push-url so the address bar matches. Without JavaScript the link still works, because the full page renders the same block. Inside the block, {partial} says where its content lands, so the link needs only hx-swap="none":
<section id="updates">
{block list}
{partial 'updates'}
{foreach $updates as $update}
{include 'updates/entry.latte', p: $update}
{/foreach}
<a
n:if="$next"
href="{$page->url('', ['show' => $next])}"
hx-get="{$page->url()}:list?show={$next}"
hx-push-url="{$page->url('', ['show' => $next])}"
hx-swap="none"
class="btn btn-block"
>Load more</a>
{/partial}
{/block}
</section>
{fragment 'list'} is {block} and {partial} in one tag, for the common case where the target id matches the block name.
The two tags split by who decides where the HTML goes:
| Tag | Renders in its own page | Placed by |
|---|---|---|
{fragment} |
Yes | The block itself, through its {partial} |
{component} |
No | The element that fetches it |
Use a fragment for a region that refreshes itself, and a component for a piece other pages embed, such as a sidebar of latest posts that each page loads with hx-trigger="load".
Name the page in shared templates
A few rules keep blocks addressable:
- Only blocks written in the page's own template have a URL. A block inside an
{include}d file does not. - A view with a router needs a
get('')route, or its blocks 404. - Let
{partial}place the content rather than callingHx::retarget()from a template. Response headers are last-write-wins, so a header set by an included template can override the including view's. Headers belong in the route handler that owns the response.
Cache at the page that owns the content
Give a fragment or component a cache: in seconds and its HTML is stored in the cache of the page that renders it. A write to that page or any page below it clears the entry, so the tree is the dependency model and there are no cache keys to manage:
{fragment 'latest', cache: 300}
{foreach $page->children('sort=-created, limit=5') as $post}
{include 'blog/card.latte', p: $post}
{/foreach}
{/fragment}
When the content comes from another branch of the tree, hold the entry at a page above both with in:. in: '/' is cleared by every page write. {cache} does the same for a region inside a normal render.
Cache what you have measured as slow, not by default. A cached region replays only its HTML, so it must not set headers or start a session, and only anonymous GET requests are served from it. See Routing for the full rules.
Keep templates declarative
A template reads values and lays them out. Work moves outward by how widely it applies:
| Where | What goes there |
|---|---|
| The template | Markup, loops, conditions, small lookups with p() and find() |
The view's controller.php |
Data every page of the view needs, returned as variables |
| The view's Page class | Logic that belongs to the page, such as a derived value or its navigation |
A field's render.latte |
How one kind of value is shown, everywhere it appears |
A controller that grows past a few lines usually holds logic that belongs on the Page class. A method there is available wherever the page is reached, through children(), find() or routing, not only in its own view.
Let fields render themselves. {$page->body|render} uses the field's render template, so formatting lives in one file rather than in every view that shows the field. Override a core type's template at site/fieldtypes/@phlat/{type}/render.latte when the site needs a different look.
Latte's n: attributes keep conditions on the element they control, which keeps the markup flat:
<article>
<h1>{$page->title}</h1>
<p
n:if="!$page->description->empty()"
class="text-lg"
>{$page->description}</p>
<ul n:if="$tags">
<li
n:foreach="$tags as $tag"
n:class="$tag === $active ? 'font-bold'"
>{$tag}</li>
</ul>
</article>
A field value is always truthy, so test it with ->empty(). Output is escaped by default; use |noescape only for HTML you trust.
Shared partials
Put reusable markup in site/views/components/ and drive it with parameters. An included template sees the includer's variables, but pass every name it uses anyway, one per line, so it works wherever it is included:
{include 'components/card.latte',
title: $post->title,
url: $post->url(),
image: $post->hero
}
One layout chain is enough for most sites: the shell site/views/index.latte, a layout.latte that fills its body block, and each view filling main. See Views.
Keep UI state in the DOM
A toggle, a selected tab or a panel size is an attribute on an element. Flip it with hx-on: and style it with Tailwind's data-[x=y]: variants. Nothing has to be kept in sync with a script:
<div
id="preview"
data-size="md"
class="data-[size=md]:max-w-4xl data-[size=lg]:max-w-7xl"
>...</div>
<button
type="button"
hx-on:click="document.getElementById('preview').dataset.size = 'lg'"
>Wide</button>
A choice the server must render on the next request, such as a theme or a dismissed banner, is a visitor option stored in a cookie. Write plain JavaScript only for what HTMX and the DOM cannot do, with handlers delegated on document so they survive swaps.
Use the Toolkit
Read input through the typed request methods ($app->request->query(), post(), param()) and reach for the Toolkit rather than raw PHP functions: F::read() for files, Res::redirect() for responses, San::text() for input, Hx:: for HTMX headers. Every Toolkit class has the method you need, so you never have to mix the two styles. See Toolkit.
Override, don't fork
Anything in phlat/ and its addons is a default. To change a shipped template, field or view, put a file at the same path under site/ and leave the original alone, so upgrades keep working. Use event hooks to observe a request or a write, and change data at an explicit boundary such as a route handler or save(). See Overrides.
Ship less
A page loads no JavaScript or CSS it does not use. Style with plain DaisyUI and Tailwind classes, give images a fixed aspect ratio and size them with a transform such as $image->src('w=800,h=450,crop'), and load a region with HTMX only when it is not needed for the first paint. The fewer moving parts a page has, the faster it is and the easier it is to change.