Theme Hooks and Output Filters
A theme changes the data it receives and the markup it emits from one file of its own. No controller or core template is edited.
Overview
Every theme may ship a hooks.php, included once before its first page is built. It travels and is deleted with the theme.
Data listeners change what reaches the view: a variable this theme needs on every page, a menu tree, the finished HTML. Markup points work the other way: the theme opens them, a module injects into them.
Structure
Where It Lives
The Two Families
| Family | The theme is | Mechanism | Return |
|---|---|---|---|
| Data (filter) | the listener | Hook::add() in the hooks file | Replaced by the return, or changed by reference |
| Markup (injection) | the publisher | {hook name='...'} in the layout and partials | The listeners' strings are concatenated, printed raw |
Reference
The Hook API
// $priority: lower runs first; a clash is resolved by bumping, never by dropping a listener.
// $properties: a closure, or ['class' => 'X', 'method' => 'y'], or ['class' => 'X', 'method::static' => 'y'].
public static function add($name, $priority, $properties = []): void;
// By value. Returns one entry per listener; a markup point concatenates them.
public static function run($name, ...$args): array;
// By reference. EVERY argument is passed by reference, so every one must be a plain variable.
public static function runRefs($name, &...$args): array;
The Variables Filter
The single point through which a theme adds a variable to every page. It runs for every template, so a listener wanting only some tests the path.
The Output Filter
The finished HTML, after the engine built it and before it reaches the browser. A rewrite that would otherwise touch every payload happens here.
$view.
Markup Points
Written as {hook name='ui:client.head.css'} in the layout. The shipped themes open more than a hundred and fifty; those every theme should carry are below.
| Point | Where the layout puts it | What belongs there | Return |
|---|---|---|---|
ui:client.head.css | Head, end | Stylesheets, style blocks | Return <link> or <style> only, no visible markup |
ui:client.head.js | Head, end | A library the theme's script uses | Return <script> only, inline or with a src |
ui:client.body.begin | Body, start | Tag manager frames, top banners | Return any HTML string |
ui:client.body.end | Body, end | Deferred scripts, chat widgets, modals | Return any HTML string |
ui:client.nav.items | Header nav list, end | An extra top-level item | Return <li>, never a bare <a> |
ui:client.header.actions | Header action cluster | An icon button beside the cart | Return an <a> or <button> carrying .btn.btn-soft.header-icon-btn |
ui:client.user_menu.items | Account dropdown | A row in the signed-in menu | Return an <a> carrying .dropdown-item |
ui:client.drawer.items | Mobile drawer | Mobile twin of a nav item | Return a bare <a> carrying .drawer-link, no <li> |
ui:client.content.top | Above page content | A site-wide notice strip | Return any HTML string |
ui:client.footer.columns | After footer columns | An extra link column | Return one grid column <div>, same shape as its siblings, no <ul>/<li> |
ui:client.footer.bottom | Footer bottom strip | A badge, a legal line | Return any HTML string |
Other Hooks
| Hook | When | What a theme does with it |
|---|---|---|
filter:client.theme | While the theme name resolves | Serve a different theme per host, segment or preview |
filter:client.menu | After a menu tree is built | Add, drop or reorder a node the panel does not manage |
filter:client.breadcrumb | Before the trail is shortened | Rename or reroot the first crumb |
filter:client.predefined_data | End of the client data pack | Adjust a pack value while knowing the route |
filter:client.routes | Before routes are matched | A short address for a page this theme publishes |
filter:routing.match | Last routing fallback | Answer a slug no real route claims |
gate:client.page_access | Before the controller is built | Send a page to its canonical host, or refuse it |
filter:sitemap.links | While the sitemap is collected | Publish the address a page declares canonical |
Example
A complete hooks file: one variables listener, one output filter, one markup injection.
/*
* ONE variables listener for the whole theme: the view layer keeps only the LAST
* returned array, so a second registration would drop everything this one writes.
*/
Hook::add("filter:template.variables", 1, function ($template, $data) {
// Derived from core config, which a template cannot read for itself.
$data["support_enabled"] = (int) (Config::get("options/ticket-system") ?? 0) === 1;
// Routes this theme answers 404 for, as a keyed map: the sandbox has no in_array(),
// so the template asks {if !$route_off.affiliate} instead.
$data["route_off"] = array_fill_keys(Theme::active()->meta()["disabled_routes"] ?? [], true);
// Runs on EVERY page, so anything with a query goes through the cache. The key
// carries the currency AND the language, because the output depends on both;
// leave either out and one visitor is served the other one's copy.
$data["footer_groups"] = Cache::remember('website',
'acme_footer_' . Money::getUCID() . '_' . Language::selected(), 3600,
fn (): array => Products::groups());
// Page-specific work stays behind a path test rather than a second registration.
if (str_ends_with(str_replace('\\', '/', (string) $template), '/page/pricing.php'))
$data["plans"] = AcmePricing::cards();
return $data; // returning anything else drops every key the platform collected
});
/*
* The finished page. Cheaper and safer than rewriting every payload that produced a
* link: one place, and it cannot miss one.
*/
Hook::add('filter:client.page.output', 1, function (&$output, $view, $engine) {
if (str_contains($view, '/')) return; // fragments are not documents
$output = str_replace('</body>', '<script src="/assets/acme-widget.js" defer></script></body>', $output);
});
/*
* Markup, not data: the layout's points take a string and print it as-is. Registered
* here so the theme's own extra stylesheet needs no edit to any layout file.
*/
Hook::add("ui:client.head.css", 1, fn (): string =>
'<link rel="stylesheet" href="' . Theme::active()->assetUrl('css/extra.css') . '">');
<head>
{block name=head}{/block}
{hook name='ui:client.head.css'}
{hook name='ui:client.head.js'}
</head>
<body>
{hook name='ui:client.body.begin'}
{* A value the hooks file put there, read like any other variable. *}
{if !$route_off.affiliate}<a href="{link route='affiliate'}">{lang key='nav_affiliate'}</a>{/if}
{block name=content}{/block}
{hook name='ui:client.body.end'}
</body>
Pitfalls
The failure is invisible in the file that caused it: the page that breaks reads the other listener's variable. Keep one body, add keys to it.
A literal, inline array, cast, null-coalesced read or function result cannot be passed. PHP raises a fatal, not a notice. Assign the context to a variable first.
A bare query in a listener runs on every page view. Put database reads behind the cache, keyed by language and currency when the result depends on them.
The dispatcher catches it and carries on. A listener that dies leaves no error: the value it should have changed arrives unchanged. When one does nothing, suspect a fatal, not the registration.
The markup points do not escape, so the injecting side is responsible. Never concatenate visitor input into what a point returns.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.