# Theme Hooks and Output Filters

https://dev.wisecp.com/es/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

- **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

| 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

```php
// $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.

| 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.

```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') . '">');
```

```smarty
<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.

## Related Articles

- [Template Variables](https://dev.wisecp.com/en/template-variables)
- [How Hooks Work](https://dev.wisecp.com/en/how-hooks-work)
- [Writing a Hook Listener](https://dev.wisecp.com/en/writing-a-hook-listener)
- [Menus and Navigation](https://dev.wisecp.com/en/menus-and-navigation)
- [Theme Performance and Caching](https://dev.wisecp.com/en/theme-performance-and-caching)
