Skip to content

Getting Started

Installation

  1. Install the Twig transport package through the MODX package manager.
  2. The installer creates a twig namespace pointing to {core_path}components/twig/.
  3. The twigparser service is now available site-wide.

No other configuration is needed. The extra activates automatically.

This extra uses Twig 3 (the Twig 3.x documentation is the right reference for syntax, filters, and functions).

Where Twig Syntax Works

Twig syntax is processed in:

  • Templates - your MODX page templates
  • Chunks - both when called via [[$ChunkName]] and via the chunk() Twig function
  • Resource content - the content field of any resource
  • Snippet output - if a snippet returns a string containing Twig syntax, it gets rendered
  • ContentBlocks fields - when the ContentBlocks plugin is enabled (see the ContentBlocks guide)

Twig is not processed in the MODX manager interface.

Built-in Functions

The extra provides functions that bridge Twig templates to MODX features.

chunk(name, properties)

Renders a MODX chunk. Properties are passed as chunk placeholders.

{{ chunk('CardTpl', {'title': 'My Card', 'image': '/img/card.jpg'}) }}

The chunk itself can also contain Twig syntax.

snippet(name, properties)

Runs a MODX snippet and outputs the result.

{{ snippet('SiteNav', {'depth': 2, 'startId': 0}) }}

placeholder(key, default) / ph(key, default)

Reads a MODX placeholder. Returns the default if the placeholder is not set.

{{ placeholder('page_header', 'Welcome') }}
{{ ph('page_header') }}

option(key, default) / config(key, default)

Reads a MODX system setting.

{{ option('site_name') }}
{{ config('site_url') }}

lexicon(key, params, language)

Returns a lexicon string. The lexicon topic must already be loaded.

{{ lexicon('setting_site_name') }}

trans(key, topic, params, language)

Loads a lexicon topic and translates in one call.

{{ trans('setting_site_name', 'en:setting') }}

link(id, params, context, scheme, options)

Generates a URL for a MODX resource.

<a href="{{ link(12) }}">About Us</a>
<a href="{{ link(5, {'sort': 'date'}) }}">Blog</a>

field(name, default, resource)

Reads a resource field or Template Variable from the current resource. Falls back to the default if the field is empty. For accessing fields on the current resource, prefer the resource global instead.

{# Preferred: use the resource global #}
{{ resource.pagetitle }}
{{ resource.HeroImage }}

{# field() is useful for defaults and cross-resource lookups #}
{{ field('HeroImage', '/images/fallback.jpg') }}
{{ field({'name': 'CustomTV', 'default': 'none', 'resource': 42}) }}

Global Variables

Four globals are available in every Twig template:

resource

The current MODX resource. Access built-in fields and Template Variables as properties:

{{ resource.pagetitle }}
{{ resource.alias }}
{{ resource.id }}
{{ resource.parent }}
{{ resource.content|raw }}

Template Variables work the same way -- no need for a separate function call:

{{ resource.HeroImage }}
{{ resource.CustomField }}
{% if resource.ShowBanner %}
    <div class="banner">{{ resource.BannerText }}</div>
{% endif %}

TV values are processed/rendered by default (same as [[*myTv]]). If you need the raw stored value before MODX applies output rendering:

{{ resource.tvRawValue('HeroImage') }}

In CLI, API, or manager contexts where no resource is loaded, resource is null. Guard against this if your template may run in those contexts:

{% if resource %}
    <h1>{{ resource.pagetitle }}</h1>
{% endif %}

modx

The MODX instance. Use resource for accessing resource fields. The modx global is available for other MODX features:

{{ modx.resource.id }}
{{ modx.resource.pagetitle }}

Use modx sparingly. The helper functions and resource global are usually clearer.

placeholders

An array of all currently set MODX placeholders.

{{ placeholders.hero_title|default('No title') }}

Twig Filters

All standard Twig filters work. Some commonly useful ones:

{{ title|upper }}
{{ title|lower }}
{{ title|capitalize }}
{{ description|default('No description available') }}
{{ content|raw }}
{{ price|number_format(2, '.', ',') }}
{{ items|length }}
{{ html_content|striptags }}
{{ name|trim }}
{{ list|join(', ') }}
{{ date_string|date('d/m/Y') }}

HTML Escaping

Twig auto-escapes all output by default. This is a security feature that prevents XSS attacks, but it means HTML content comes out as visible tags if you are not expecting it.

{# Variable contains: <p>Hello <strong>world</strong></p> #}

{{ value }}       {# outputs: &lt;p&gt;Hello &lt;strong&gt;world&lt;/strong&gt;&lt;/p&gt; #}
{{ value|raw }}   {# outputs: <p>Hello <strong>world</strong></p> #}

Use the |raw filter when you know the content is safe HTML that should be rendered as markup. This is common with:

  • richtext field content from ContentBlocks
  • chunk output that contains HTML
  • snippet output that returns HTML
  • MODX resource fields like content or introtext
{{ field('content')|raw }}
{{ chunk('HeroBanner', {'title': 'Welcome'})|raw }}

Do not use |raw on user-supplied input that has not been sanitised.

When in doubt, leave auto-escaping on. Only add |raw when you see escaped HTML appearing as text on the page.

Twig Control Structures

Conditionals

{% if field('HeroImage') %}
    <img src="{{ field('HeroImage') }}" alt="{{ field('pagetitle') }}">
{% else %}
    <div class="placeholder">No image</div>
{% endif %}

Loops

{% set items = ['Home', 'About', 'Contact'] %}
<nav>
{% for item in items %}
    <a href="#">{{ item }}</a>
{% endfor %}
</nav>

Setting Variables

{% set site = option('site_name') %}
{% set year = 'now'|date('Y') %}
<footer>&copy; {{ year }} {{ site }}</footer>

Mixing MODX Tags and Twig

Twig and MODX tags work together in the same template. Twig runs first, then MODX processes its tags in the output.

{# Twig handles the logic #}
{% if resource.parent == 5 %}
    <nav>[[!SiteNav? &startId=`5`]]</nav>
{% endif %}

{# MODX handles the content #}
<h1>[[*pagetitle]]</h1>
<div>[[*content]]</div>

You can also call MODX elements through the Twig functions instead of tags:

{% if resource.parent == 5 %}
    <nav>{{ snippet('SiteNav', {'startId': 5}) }}</nav>
{% endif %}

<h1>{{ resource.pagetitle }}</h1>

Both approaches work. Choose whichever is clearer for your template.

Processing order

Understanding the order of operations prevents surprises:

  1. Twig runs first. All {{ }}, {% %}, and {# #} blocks are evaluated and replaced with their output.
  2. MODX runs second. The result from step 1 is then processed by the MODX parser, which handles [[tags]].
  3. Fenom runs last (only if pdoTools is installed and the pdotools_fenom_parser setting is enabled). Any {$var} or other Fenom syntax in the output is processed after both Twig and MODX tags have been resolved.

This means:

  • Twig can wrap MODX tags in conditionals. The MODX tag is only present in the output if the condition is true, so MODX only processes it when needed.
  • Twig cannot read MODX tag output. By the time MODX processes [[*pagetitle]], Twig has already finished. You cannot use {% if [[*pagetitle]] == 'Home' %} -- use {% if field('pagetitle') == 'Home' %} instead.
  • MODX snippet output containing Twig is rendered. If a snippet returns a string with {{ }} syntax, it goes through the Twig pass. This is intentional and useful for snippets that return Twig-powered markup.
  • Twig variables cannot be interpolated into MODX tags. [[*{{ fieldname }}]] does not work because Twig processes {{ fieldname }} first, but the result is just text that gets concatenated into the MODX tag string. Use the field() helper instead.

JavaScript frameworks and verbatim

Vue, Angular, Alpine.js, Handlebars, and other frontend frameworks also use {{ }} syntax. Twig will try to parse those expressions and throw an error.

Wrap frontend template blocks in {% verbatim %} to tell Twig to leave them alone:

{% verbatim %}
<div id="app">
    <p>{{ message }}</p>
    <span v-if="show">{{ count }} items</span>
</div>
{% endverbatim %}

Everything inside {% verbatim %}...{% endverbatim %} is output as-is without Twig processing.

Custom Twig Functions

Initializers

Register a function directly on the Twig environment:

$twigParser = $modx->services->get('twigparser');
$twigParser->registerInitializer(function (\Twig\Environment $twig) {
    $twig->addFunction(new \Twig\TwigFunction('price', fn ($cents) => number_format($cents / 100, 2)));
});

Then in your template:

{{ price(1999) }} {# outputs 19.99 #}

Extensions

Register a full Twig extension class:

use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class PriceExtension extends AbstractExtension
{
    public function getFunctions(): array
    {
        return [
            new TwigFunction('price', fn ($cents) => number_format($cents / 100, 2)),
            new TwigFunction('vat', fn ($cents, $rate = 0.2) => number_format($cents * $rate / 100, 2)),
        ];
    }
}

$twigParser = $modx->services->get('twigparser');
$twigParser->registerExtension(new PriceExtension());

Shared Runtime

If your extension needs to call MODX features (chunks, snippets, URLs), use the shared runtime instead of reimplementing MODX calls:

use Boffinate\Twig\Support\ModxRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;

class CardsExtension extends AbstractExtension
{
    public function __construct(private ModxRuntime $runtime) {}

    public function getFunctions(): array
    {
        return [
            new TwigFunction('card', fn (string $title) =>
                $this->runtime->chunk('CardTpl', ['title' => $title])
            ),
            new TwigFunction('card_url', fn (int $id) =>
                $this->runtime->link($id)
            ),
        ];
    }
}

$twigParser = $modx->services->get('twigparser');
$twigParser->registerExtension(new CardsExtension($twigParser->getRuntime()));

The runtime provides: chunk(), snippet(), placeholder(), option(), lexicon(), translate(), link(), field().

OnTwigInit Event

MODX plugins can listen to the OnTwigInit system event to register functions or globals when the Twig environment starts up:

// Plugin code listening to OnTwigInit
$twig->addFunction(new \Twig\TwigFunction('build_id', fn () => 'v2.1'));
$twig->addGlobal('release', '2026.03');
return '';

The event receives $twig (the Twig Environment), $parser (the Twig parser instance), and $modx.

Caching

Compiled Twig templates are cached under {core_cache_path}/twig/. When you clear the MODX cache, the Twig cache is also cleared automatically by the TwigCacheClear plugin.

If template changes do not appear after editing, clear the MODX cache. Make sure the TwigCacheClear plugin is enabled.

Debugging with dump()

The Twig debug extension is controlled by the twig.debug system setting (enabled by default). The dump() function outputs a variable inspection, or the template's own variables when called with no arguments. When Symfony VarDumper is installed (it is included as a dev dependency), you get interactive, collapsible HTML output rendered inside an iframe, instead of plain var_dump.

Dump a single variable

{{ dump(value) }}
{{ dump(row_data) }}
{{ dump(placeholders) }}

Dump everything available in the template

{{ dump() }}

With no arguments, dump() shows every variable that was passed to the current template. This is the quickest way to find out what data you have to work with.

Globals (modx, resource, placeholders) are excluded from no-arg dump() output because they are always present and would obscure the template-specific data you are looking for. To inspect a global, dump it explicitly:

{{ dump(modx) }}
{{ dump(resource) }}
{{ dump(placeholders) }}

What you see when dumping modx

dump(modx) shows all properties of the MODX instance, but only the ones useful for template work are expandable:

  • config -- all system settings (expandable)
  • context -- the current context object (expandable)
  • resource -- the current resource (expandable)
  • request -- the modRequest object (expandable)
  • response -- the modResponse object (expandable)
  • user -- the current user (expandable)
  • placeholders -- all set placeholders (expandable)
  • version, cultureKey, resourceIdentifier, resourceMethod, site_id, uuid -- scalar metadata (always visible)

Framework internals (pdo, driver, cacheManager, classMap, services, etc.) are still listed but shown as collapsed stubs -- you can see their type but cannot expand them. This keeps the dump readable and prevents memory issues from serialising the entire MODX runtime.

Block form

The block form writes output to the Symfony dump collector if available, but in a MODX context it works the same as the function form:

{% dump value %}
{% dump row_data %}

Output size limits

Two safety measures prevent memory exhaustion from large dumps:

  • Per-dump limit (2 MB): If a single dump() call produces more than 2 MB of HTML, it is replaced with a truncation message. Dump specific variables instead of the full context to stay under this limit.
  • Per-render limit (5 MB): If a single renderString() call produces more than 5 MB of total output, Twig discards the result and returns the original template. Check the MODX error log for details when this happens.

Fenom compatibility

When pdoTools Fenom is enabled, dump output is rendered inside an <iframe> with srcdoc to isolate VarDumper's JavaScript and CSS from Fenom's { parser. This happens automatically -- no configuration needed. The iframe auto-resizes as you expand and collapse dump nodes.

Remove dump() before going live

dump() only works when twig.debug is enabled (it is by default). Set twig.debug to false in production -- dump() calls will silently return nothing, but it is still good practice to remove them from production templates.