Theme templates are written in Twig. This page covers the anatomy of the main template, the helpers you can use and common mistakes.
The main template: layout.html.twig#
Every page extends layout.html.twig. The skeleton below is a working main template simplified from the default theme:
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="UTF-8">
{{ cp_consent_mode() }}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block seo %}
{% set seo_title %}{% block title %}{% endblock %}{% endset %}
{% set seo_description %}{% block meta_description %}{% endblock %}{% endset %}
{% set seo_robots %}{% block meta_robots %}index, follow{% endblock %}{% endset %}
{{ cp_seo_head({ title: seo_title, description: seo_description, robots: seo_robots }) }}
{% endblock %}
<link rel="icon" type="image/png" href="{{ cp_appearance().favicon }}">
<link rel="stylesheet" href="{{ cp_theme_url('dist/style.css') }}">
{% set cpFonts = cp_font_stylesheet() %}
{% if cpFonts %}<link rel="stylesheet" href="{{ cpFonts }}">{% endif %}
<style{{ csp_nonce_attr() }}>{{ cp_appearance_css() }}</style>
{% block stylesheets %}{% endblock %}
{{ cp_hook('theme.head.end', {}) }}
</head>
<body class="{% block body_class %}{% endblock %}">
<a class="skip-link" href="#main">{{ 'website.skip_to_content'|trans }}</a>
{% include '@Theme/partials/header.html.twig' %}
<main id="main">
{% block content %}{% endblock %}
</main>
{{ cp_hook('theme.footer.before', {}) }}
{% include '@Theme/partials/footer.html.twig' %}
{{ cp_cookie_consent() }}
<script{{ csp_nonce_attr() }} src="{{ cp_theme_url('dist/main.js') }}"></script>
{% block javascripts %}{% endblock %}
</body>
</html>
Blocks filled in by modules#
Module templates extend this main template with {% extends '@Theme/layout.html.twig' %} and fill the following blocks. Your main template must define these blocks, otherwise modules cannot place their content:
| Block |
What the module puts in |
title, meta_description, meta_robots, meta_keywords |
SEO title, description, robots value |
stylesheets |
Page-specific CSS |
body_class |
<body> class |
content |
The actual content of the page |
javascripts |
Page-specific script |
Two hooks the theme must offer#
{{ cp_hook('theme.head.end', {}) }} {# before </head>: analytics counters etc. #}
{{ cp_hook('theme.footer.before', {}) }} {# before the footer: modules' cards #}
Without these two lines, contributions such as statistics modules and forum summary cards do not appear. Other theme hooks: theme.header.alerts, theme.header.user_badge.
Overriding templates#
Module pages are called through the @Theme/... path. To change the look of a page, put the template with the same path in your theme:
Resources/views/blog/show.html.twig blog post
Resources/views/blog/archive.html.twig blog archive
Resources/views/page/show.html.twig page
Resources/views/forum/topics.html.twig forum topics
Resources/views/search/index.html.twig search results
Resources/views/partials/header.html.twig
Resources/views/partials/footer.html.twig
The path must be exactly the same as the path in the default theme. Start your templates by copying the same file from the default theme and changing it: you see the variable names and blocks from it.
Helpers that do not start a session#
Do not use `app.user` on the front end
Twig's standard app.user, is_granted() and app.flashes expressions start a session even for an anonymous visitor. A response with a session cannot be put into the cache shared by everyone; the site's cache collapses. CPalius offers session-safe counterparts for these:
| Standard |
Use on the front end |
Behaviour |
app.user |
cp_user() |
null if there is no session cookie |
is_granted('x') |
cp_is_granted('x') |
false if there is no session cookie |
app.flashes |
cp_flashes() |
empty if there is no session cookie |
{% set me = cp_user() %}
{% if me %}
<a href="/account">{{ me.username }}</a>
{% else %}
<a href="/login">{{ 'website.login'|trans }}</a>
{% endif %}
In admin screens (Studio, AACP) a session is always there; the standard expressions are free to use there.
Helper reference#
Address and asset#
| Helper |
What it does |
cp_theme_url('dist/style.css') |
The versioned address of a theme file (cache busting); the file is copied under public/themes/<theme>/ on first use |
cp_asset('path') |
The address of a core asset compiled with AssetMapper |
cp_path_if_exists('route_name', {…}) |
Returns an empty string if the route does not exist; does not break the menu link when the module is off |
csp_nonce_attr() |
The nonce attribute for inline <script>/<style> |
asset|cp_thumb(400, 300) |
A thumbnail of an uploaded image (the second and third parameters are size; 'fit' for fitting) |
Menu and fields#
| Helper |
What it does |
cp_menu_location('main_header') |
Prints the menu assigned to the location with ready-made markup |
cp_menu_location_items('main_header') |
Gives the item tree for your own markup |
cp_menu('name') |
Gives the item tree of a named menu (label, url, openInNewTab, children) as an array |
cp_widget_area('sidebar') |
Draws a widget area |
cp_field(node, 'field') |
Prints a field formatted |
cp_field_value(node, 'field') |
The raw value of the field |
cp_fields(node, 'group') |
Prints a field group |
cp_has_field(node, 'field') |
Whether the field is filled in |
cp_fields_preload(nodes) |
Loads the references in a list in bulk |
cp_entity_view(node, 'teaser') |
Draws the entity in a view mode |
Language#
| Helper |
What it does |
cp_locales() |
List of active languages |
cp_locale_switcher('buttons') or 'dropdown' |
A ready-made language switcher |
cp_locale_links() |
The addresses of the current page in other languages |
cp_locale_url('en') |
The address of the current page in a certain language |
cp_hreflang_links() |
<link rel="alternate" hreflang> tags (inside <head>) |
'key'|trans |
Translation |
SEO#
| Helper |
What it does |
cp_seo_head({…}) |
Title, description, canonical, Open Graph tags (basic tags if there is no Seo module) |
cp_schema_org(node) |
schema.org JSON-LD |
Appearance and preferences#
| Helper |
What it does |
cp_appearance() |
The brand data the administrator chose (logo, favicon, colours) |
cp_appearance_css() |
The :root CSS variables of the chosen colours/sizes |
cp_font_stylesheet() |
The CSS address generated from the font settings (empty if none) |
cp_consent_mode() |
Google Consent Mode v2 defaults (inside <head>) |
cp_cookie_consent() |
The cookie consent bar (at the end of the body) |
User and permission#
| Helper |
What it does |
cp_user(), cp_is_granted('x'), cp_flashes() |
Session-safe user, permission and flash messages |
cp_current_user_avatar_url() |
The avatar address of the signed-in user |
cp_notification_unread_count(), cp_recent_notifications() |
Notification counter and recent notifications |
Text#
| Helper |
What it does |
text|text_format('basic_html') |
Passes the text through a text format and gives safe HTML |
text|reading_time |
Estimated reading time |
date|cp_date / date|cp_date(true) |
A date appropriate to the language (the second form with the time) |
System#
| Helper |
What it does |
cp_version() |
Core version |
cp_hook('point', {…}) |
Draws a hook point |
Modules also add their own helpers (e.g. Showcase: cp_showcase_items; Messages: cp_messages_unread). These exist only while the module is active; if you use them in your theme, write them with guards such as cp_path_if_exists or by checking that the module is active.
Common mistakes#
- The
for ... if syntax does not exist in Twig 3. Use a filter: {% for x in items|filter(i => i.active) %}.
- Macro visibility: a macro imported at the top of a child template is not visible inside that template's
block. Import the macro inside the block.
- Escaping: do not print user data with
|raw. For rich text use the text_format filter.
- External resource:
<script src="https://cdn..."> is blocked by CSP. Take the library into the theme's dist/ folder.
- Inline script: it does not run without a nonce:
<script{{ csp_nonce_attr() }}>.
- No translation key:
'key'|trans prints the key as it is. Add it to the TR and EN files.