Skip to main content

Template basics

XenForo's template system sits between your controllers and the browser. It combines standard HTML with a lightweight expression language and a library of <xf:tag> components that handle everything from conditionals to full form layouts. This guide explains how the system works, what design decisions it makes for you, and when to use each feature. For the complete tag and function reference, see Template syntax reference.

Template types

XenForo has three types of template:

  • Public templates control the public-facing areas of your forum: thread listings, post displays, user profiles, and so on.
  • Admin templates control the Admin Control Panel.
  • Email templates define the structure and content of emails sent by XenForo.

All three types use the same syntax. The distinction matters because they are stored separately, rendered by different routers, and have access to different global variables. When you create a template in your add-on, you choose its type and it becomes available only within that context.

Any template type can also contain CSS/LESS. These "CSS templates" follow the same syntax but are compiled as stylesheets rather than HTML.

Working with templates

As an add-on developer, you will primarily work with templates through development mode. When enabled, templates are stored as files in your add-on's _output directory and can be edited with any text editor or IDE.

Templates can also be edited in the browser through Appearance > Templates in the Admin Control Panel, though this is more common for site administrators than developers.

Template modifications

Template modifications let you alter existing templates without editing them directly. This is XenForo's approach to the same problem that hooks or events solve in other systems: allowing multiple add-ons to modify the same template without conflicting.

A modification targets a template by name, finds content using a simple string match or regular expression, and then replaces, prepends, or appends content. Modifications are applied at render time and can be reordered by priority when multiple add-ons target the same template.

This is the recommended way to change core or third-party templates from your add-on. Editing a template directly would create a conflict if the original template is updated.

Template syntax overview

A XenForo template combines standard HTML with three additional syntaxes:

  • {$variableName} outputs a variable. The output is automatically escaped for safety.
  • {{ expression }} evaluates an expression: function calls, filters, math, ternaries, and so on.
  • <xf:tagName /> is a XenForo template tag for control structures, UI components, and more.
Template
<h1>{$title}</h1>
<p>{{ phrase('welcome_message') }}</p>
<p>{{ date($xf.time, 'M j, Y') }}</p>
note

For simple variable output, use {$var}. The double curly brace syntax {{ }} is for expressions: function calls, filters, math, ternaries, etc. Mixing these up is a common mistake.

Escaping and safety

XenForo templates automatically escape all output to prevent XSS vulnerabilities. Both {$var} and {{ expression }} escape HTML special characters before rendering. This means you generally do not need to think about escaping when outputting user data.

When you need to output trusted HTML (for example, rendered BBCode from the controller), use the raw filter to bypass escaping:

Template
{$renderedHtml|raw}

For values inserted into HTML attributes, use the for_attr filter to apply attribute-safe escaping:

Template
<div title="{$description|for_attr}">...</div>
warning

Never use raw with user-supplied content. Only apply it to values that have already been sanitized or were generated by trusted code.

Passing data to templates

Controllers pass data to templates through a $viewParams array. Each key becomes a template variable:

Controller
public function actionIndex(): \XF\Mvc\Reply\View
{
$viewParams = [
'title' => 'Demo Page',
'active' => true,
'items' => [
['title' => 'First item', 'value' => 12.3456],
['title' => 'Second item', 'value' => 9876.54321],
],
];

return $this->view('Demo:Index', 'demo_index', $viewParams);
}

These variables are then accessible in your template:

Template
<h1>{$title}</h1>

<xf:if is="$active">
<p>{{ phrase('the_feature_is_active') }}</p>
</xf:if>

<xf:foreach loop="$items" value="$item">
<p>{$item.title}: {{ $item.value | number(2) }}</p>
</xf:foreach>

This is the only way data flows from PHP into a template. There is no mechanism for a template to query the database or call arbitrary PHP. This constraint keeps templates focused on presentation and pushes logic into controllers and services where it can be tested.

Global variables

In addition to view parameters, every template has access to the $xf global object. This provides information about the current request, visitor, and environment without requiring the controller to pass it explicitly.

VariableDescription
$xf.visitorThe current user entity
$xf.visitor.user_idCurrent user's ID (0 if guest)
$xf.visitor.usernameCurrent user's username
$xf.visitor.is_adminWhether the current user is an admin
$xf.options.{optionName}XenForo option values
$xf.timeCurrent server timestamp
$xf.languageCurrent language object
$xf.styleCurrent style object
$xf.debugWhether debug mode is enabled
$xf.versionIdXenForo version ID

Phrases

XenForo stores all user-visible strings as phrases, which are translatable and editable by administrators. Rather than hardcoding text in templates, you reference a phrase by name:

Template
<h1>{{ phrase('demo_page_header') }}</h1>

The phrase name must be a string literal. This is a compile-time requirement that allows XenForo to track which phrases are in use and by which templates. When the phrase name is determined at runtime, use phrase_dynamic() instead:

Template
{{ phrase_dynamic($phraseName) }}

Phrases can accept parameters. If a phrase is defined as Hello, {username}!, you pass the value like this:

Template
{{ phrase('greeting_with_name', {'username': $xf.visitor.username}) }}

Page structure

Most templates need to set a page title and breadcrumb trail. These tags do not render inline; they pass data up to the page container, which assembles the final HTML document.

Template
<xf:title>{{ phrase('demo_page') }}</xf:title>

<xf:breadcrumb href="{{ link('demo') }}">{{ phrase('demo') }}</xf:breadcrumb>

<xf:sidebar>
<div class="block">
<div class="block-container">
<h3 class="block-header">{{ phrase('info') }}</h3>
<div class="block-body block-row">
Sidebar content here.
</div>
</div>
</div>
</xf:sidebar>

<xf:title> sets both the <h1> and the browser tab title. If you need them to differ, use <xf:h1> to override the visible heading independently.

See the Template syntax reference for the full set of page structure tags including <xf:h1>, <xf:description>, <xf:head>, <xf:page>, and <xf:pageaction>.

Control structures

XenForo templates support conditionals with <xf:if>, loops with <xf:foreach>, and variable assignment with <xf:set>.

Here is a practical example combining all three with a controller:

Controller
public function actionList(): \XF\Mvc\Reply\View
{
$viewParams = [
'items' => $this->finder('Demo:Item')
->order('created_date', 'DESC')
->fetch(),
];

return $this->view('Demo:ItemList', 'demo_item_list', $viewParams);
}
Template
<xf:title>{{ phrase('demo_items') }}</xf:title>

<xf:foreach loop="$items" value="$item" i="$i">
<xf:set var="$rowClass" value="{{ $i % 2 == 1 ? 'odd' : 'even' }}" />
<div class="block-row block-row--{$rowClass}">
<h3>{$item.title}</h3>
<p>{{ date($item.created_date, 'M j, Y') }}</p>
</div>
<xf:else />
<div class="block-row">{{ phrase('no_items_found') }}</div>
</xf:foreach>

The <xf:foreach> tag supports an <xf:else> block that renders when the array is empty, similar to an "empty state" fallback. The i counter is 1-based, so the first iteration has $i = 1.

See the Template syntax reference for the full list of supported operators and attributes.

Reusing template code

XenForo provides several ways to share markup between templates. Choosing the right one depends on what you are trying to achieve.

Macros

Use macros when you have a self-contained block of markup that needs to be called from multiple places with different data. A macro defines its own arguments, has its own scope, and can be called from any template.

Template: demo_macros
<xf:macro id="item_row" arg-item="!" arg-showDate="true">
<div class="block-row">
<h3>{$item.title}</h3>
<xf:if is="$showDate">
<span class="u-muted">{{ date($item.created_date, 'M j, Y') }}</span>
</xf:if>
</div>
</xf:macro>
Template: demo_item_list
<xf:foreach loop="$items" value="$item">
<xf:macro id="demo_macros::item_row" arg-item="{$item}" />
</xf:foreach>

Setting an argument value to ! makes it required. Arguments without ! can have default values.

Includes

Use <xf:include> when you want to pull in an entire template. Unlike macros, includes share the parent's variable scope by default. You can remap variables with <xf:map> or inject new ones with <xf:set>:

Template
<xf:include template="demo_shared_header">
<xf:map from="$items" to="$headerItems" />
<xf:set var="$showCount" value="true" />
</xf:include>

Template inheritance

Use <xf:extends> when multiple templates share the same overall structure but vary in specific sections. The parent defines extension points, and each child overrides only the parts it needs.

Template: demo_base_layout (parent)
<div class="block">
<div class="block-header">
<xf:extension id="header">
<h2>{{ phrase('default_header') }}</h2>
</xf:extension>
</div>
<div class="block-body">
<xf:extension id="content">
<p>{{ phrase('default_content') }}</p>
</xf:extension>
</div>
</div>
Template: demo_custom_page (child)
<xf:extends template="demo_base_layout" />

<xf:title>{{ phrase('custom_page') }}</xf:title>

<xf:extension id="content">
<p>This replaces the parent's content block.</p>
</xf:extension>

The child overrides content but inherits header unchanged. Tags like <xf:title> in the child still execute for their side effects.

To append to a parent's extension rather than replacing it, use <xf:extensionparent>:

Template
<xf:extension id="content">
<xf:extensionparent />
<p>This appears after the parent's content.</p>
</xf:extension>

Wrapping

Use <xf:wrap> when you want to render your template inside a layout wrapper. This inverts the relationship: instead of a child extending a parent, the content template names its wrapper. The wrapper receives the content as {$innerContent|raw}:

Template: demo_account_page
<xf:wrap template="demo_account_wrapper">
<xf:set var="$activeTab" value="settings" />
</xf:wrap>

<h3>Account Settings</h3>
<p>Settings form here.</p>

Choosing the right approach

ApproachUse when...
MacroYou have a repeatable component (a card, a row, a badge) called with different data.
IncludeYou want to pull in a full template that works with the current variables.
ExtendsMultiple pages share a common layout and each page overrides specific sections.
WrapA content template needs to declare which layout surrounds it.

See the Template syntax reference for full details on <xf:extends>, <xf:extension>, <xf:extensionparent>, <xf:extensionvalue>, <xf:wrap>, and macro extends.

Including CSS and JavaScript

Add-ons include their own styles and scripts using <xf:css> and <xf:js>:

Template
<xf:css src="demo_styles.less" />

<xf:js src="demo/addon/main.js" />

Both tags also support inline content for small snippets. See the Template syntax reference for the full set of attributes including prod, dev, and min options for <xf:js>.

Forms

XenForo provides a set of form tags that handle the repetitive parts of building forms: label alignment, hint text, error display, accessibility attributes, and consistent styling. Rather than writing this boilerplate in plain HTML for every form field, you use *row tags that combine a label, input control, and optional supporting text into a single component:

Template
<xf:form action="{{ link('demo/save') }}" ajax="true" class="block">
<div class="block-container">
<div class="block-body">
<xf:textboxrow name="title"
value="{$title}"
label="{{ phrase('title') }}"
explain="{{ phrase('enter_a_title') }}" />

<xf:selectrow name="category"
value="{$categoryId}"
label="{{ phrase('category') }}">
<xf:options source="{$categories}" />
</xf:selectrow>

<xf:checkboxrow label="{{ phrase('options') }}">
<xf:option name="is_enabled" value="1" selected="{$isEnabled}">
{{ phrase('enabled') }}
</xf:option>
</xf:checkboxrow>

<xf:submitrow submit="{{ phrase('save') }}" />
</div>
</div>
</xf:form>

Setting ajax="true" on <xf:form> enables AJAX submission. XenForo handles the request, displays validation errors inline, and shows a success flash message without a full page reload.

When none of the specialized *row tags fit, use <xf:formrow> to wrap custom HTML in a form row with a label.

Every *row tag also has a standalone version without the row wrapper. For example, <xf:textbox /> renders just the input control, which is useful when you need a bare input outside of a form layout (like a search bar or a custom <xf:formrow>).

See the Template syntax reference for the full list of form tags, standalone variants, and their attributes.

Template callbacks

The <xf:callback> tag lets you call a PHP method directly from a template. This is an escape hatch for cases where computed data cannot cleanly come from the controller, such as a sidebar widget that needs to appear across many pages without modifying each controller.

Template
<xf:callback class="Demo\Template" method="renderStats" params="['sidebar']"></xf:callback>

The method name must begin with a read-only prefix (like get, render, is, has) to enforce that callbacks cannot modify state. This keeps template rendering side-effect free.

Prefer passing data from your controller when possible. Callbacks bypass the normal controller-to-template data flow, which makes templates harder to understand and test.

See the Template syntax reference for the full list of allowed method name prefixes.

Next steps