Overrides
How site templates, routers, and controllers take priority over addon and framework defaults, and how to compose instead of copy.
What can be overridden
A view's template, router, and controller are each resolved by name, checked across a fixed chain of roots. For an addon view (@admin/login, say), the chain is:
site/views/@admin/login/index.latte ← checked first
phlat/addons/admin/views/login/index.latte ← addon default
The same chain applies to router.php and controller.php. For a plain site view (no @id), the chain is just site/views/{view}/... then phlat/views/{view}/.... The first file that exists wins, see PageView::templateFile() / routerFile() / controllerFile() and AddonPage::viewRoots() in the framework source.
It's a full swap, not a merge
The override isn't layered automatically. If site/views/@admin/login/index.latte exists, it entirely replaces the addon's template for that view, none of the addon's markup runs unless the override file itself references it. The same is true for router.php (none of the addon's routes register) and controller.php (none of the addon's data is returned).
This means a naive override starts by copy-pasting the whole file, which drifts from the addon's original as it evolves.
Compose templates with Latte, don't copy them
Latte's own inheritance tags work fine against an @id reference, so a thin override can extend the original instead of duplicating it:
{layout '@admin/login/index.latte'}
{block extra}
<p class="text-sm opacity-60">Need help? Contact support.</p>
{/block}
This only works if the original template declares a {block} at the seam you want to extend. When building an addon (or a site view meant to be overridden), add named blocks at the points a caller is likely to want to customize.
{embed} and {import} are useful for the same reason: {embed} lets an override replace a whole sub-template's blocks while reusing its structure, {import} pulls in reusable macros defined elsewhere. Reach for whichever fits before reaching for a full copy.
Composing controllers
Page::controller(string $viewName, array $context = []) calls another view's controller directly and returns its array. An override controller can call the addon's original and layer extra data on top instead of reimplementing it:
<?php
// site/views/@admin/login/controller.php
return function (Phlat\App $app, Phlat\Page $page, array $context): array {
$original = $page->controller('@admin/login', $context);
return [...$original, 'showSupportLink' => true];
};
Composing routers
A router override is plain PHP, so the same idea applies: include the addon's own router file to register its routes first, then add or override specific ones on the same $page->router:
<?php
// site/views/@admin/login/router.php
return function (Phlat\App $app, Phlat\Page $page): void {
(include "{$app->system}/addons/admin/views/login/router.php")($app, $page);
$page->router->get('sso', fn() => ...);
};
When a full override makes sense
Sometimes a full replacement is the right call: a login page redesigned from scratch, or a router with a completely different route set. The chain exists precisely to make that possible without touching addon or framework code. Reach for composition when you're customizing a seam; reach for a full override when you're replacing the whole thing.
<?php
// site/views/@admin/login/controller.php, shown as PHP for code highlighting.
// The template override would actually be Latte syntax:
//
// {layout '@admin/login/index.latte'}
//
// {block extra}
// <p class="text-sm opacity-60">Need help? Contact support.</p>
// {/block}
return function (Phlat\App $app, Phlat\Page $page, array $context): array {
$original = $page->controller('@admin/login', $context);
return [...$original, 'showSupportLink' => true];
};