Skip to content

Templates and Twig helpers

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)
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.
Last updated: 13.10.2026