Theme Hooks and Output Filters

3 views Markdown

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

templates/website/{Theme}/hooks.php Optional. Plain PHP, no class, no return value. Registers listeners and the theme's helper functions.
Theme::boot() Included once per request, before the first page is built. Called by the view layer and the context-free off-request path.
Scope Website requests only. The admin panel and scheduled tasks never resolve a theme.
Cost Every listener runs on every page view.

The Two Families

FamilyThe theme isMechanismReturn
Data (filter)the listenerHook::add() in the hooks fileReplaced by the return, or changed by reference
Markup (injection)the publisher{hook name='...'} in the layout and partialsThe listeners' strings are concatenated, printed raw

Reference

The Hook API

signatures
// $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.

filter:template.variables Fires in the view layer once the data set is complete, before the template is included.
($template, $data) The full path of the template processed, and the variable array. Neither is by reference.
Return An array REPLACES the data set entirely. Anything else is ignored and the set is left as it was.
One listener per theme Only the LAST returned array survives; everything a theme adds belongs in one body.

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.

filter:client.page.output Fires in the themed branch of the view layer, on both the printed and returned paths.
(&$output, $view, $engine) The whole HTML by reference, the view name that produced it, and the active engine. Changed in place; the return is unused.
Scope Tag engines only. A theme declaring the plain PHP engine prints through include and is never captured.
Fragments too Section fragments answered over AJAX come through the same branch; tell them apart by $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.

PointWhere the layout puts itWhat belongs thereReturn
ui:client.head.cssHead, endStylesheets, style blocksReturn <link> or <style> only, no visible markup
ui:client.head.jsHead, endA library the theme's script usesReturn <script> only, inline or with a src
ui:client.body.beginBody, startTag manager frames, top bannersReturn any HTML string
ui:client.body.endBody, endDeferred scripts, chat widgets, modalsReturn any HTML string
ui:client.nav.itemsHeader nav list, endAn extra top-level itemReturn <li>, never a bare <a>
ui:client.header.actionsHeader action clusterAn icon button beside the cartReturn an <a> or <button> carrying .btn.btn-soft.header-icon-btn
ui:client.user_menu.itemsAccount dropdownA row in the signed-in menuReturn an <a> carrying .dropdown-item
ui:client.drawer.itemsMobile drawerMobile twin of a nav itemReturn a bare <a> carrying .drawer-link, no <li>
ui:client.content.topAbove page contentA site-wide notice stripReturn any HTML string
ui:client.footer.columnsAfter footer columnsAn extra link columnReturn one grid column <div>, same shape as its siblings, no <ul>/<li>
ui:client.footer.bottomFooter bottom stripA badge, a legal lineReturn any HTML string

Other Hooks

HookWhenWhat a theme does with it
filter:client.themeWhile the theme name resolvesServe a different theme per host, segment or preview
filter:client.menuAfter a menu tree is builtAdd, drop or reorder a node the panel does not manage
filter:client.breadcrumbBefore the trail is shortenedRename or reroot the first crumb
filter:client.predefined_dataEnd of the client data packAdjust a pack value while knowing the route
filter:client.routesBefore routes are matchedA short address for a page this theme publishes
filter:routing.matchLast routing fallbackAnswer a slug no real route claims
gate:client.page_accessBefore the controller is builtSend a page to its canonical host, or refuse it
filter:sitemap.linksWhile the sitemap is collectedPublish the address a page declares canonical

Example

A complete hooks file: one variables listener, one output filter, one markup injection.

templates/website/Acme/hooks.php
/*
 * 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') . '">');
the layout that publishes the points
<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

A second variables listener deletes the first's work

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.

Every argument of a by-reference run is by reference

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 listener is a per-page cost

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.

A fatal inside a listener is swallowed

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.

Injected markup reaches the page raw

The markup points do not escape, so the injecting side is responsible. Never concatenate visitor input into what a point returns.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.