Developer Guide¶
This guide is for PHP developers building MODX extras that want to render Twig templates, register custom Twig functions, or integrate with the Twig parser from their own code.
Getting the Parser¶
The Twig parser is registered as a service called twigparser:
The service is a singleton. Every call to get('twigparser') returns the same instance, so extensions and initializers registered anywhere are available everywhere.
Checking if Twig is available¶
If your extra should work with or without the Twig extra installed, check before using it:
if ($modx->services->has('twigparser')) {
$twig = $modx->services->get('twigparser');
// Use Twig rendering
} else {
// Fall back to MODX chunk/placeholder rendering
}
This lets you ship an extra that takes advantage of Twig when it is present but does not require it.
Rendering Twig Templates¶
renderString()¶
The main method for rendering Twig markup from PHP:
$twig = $modx->services->get('twigparser');
$html = $twig->renderString('<h1>{{ title }}</h1><p>{{ body }}</p>', [
'title' => 'Hello',
'body' => 'World',
]);
// $html = '<h1>Hello</h1><p>World</p>'
The second argument is an array of variables that become available in the template. These are the template's local context -- the same as ContentBlocks' $phs or the properties you would pass to a chunk.
The three globals (resource, modx, placeholders) are always available in addition to the variables you pass.
Rendering with data from your extra¶
A typical pattern is to fetch data in PHP, then pass it to a Twig template string that the user configures:
// Your extra fetches some data
$products = $this->getProducts($categoryId);
// The user provides a template (from a system setting, chunk, TV, etc.)
$template = $modx->getOption('myextra.product_tpl', null,
'{% for product in products %}<div>{{ product.name }}</div>{% endfor %}'
);
// Render it
$twig = $modx->services->get('twigparser');
$output = $twig->renderString($template, [
'products' => $products,
'category_id' => $categoryId,
'total' => count($products),
]);
Rendering a MODX chunk through Twig¶
If you call $modx->getChunk(), the chunk content is automatically passed through Twig when the Twig parser is active. Chunk properties become Twig variables:
// The chunk "ProductCard" can contain Twig syntax:
// <div class="card"><h3>{{ name|upper }}</h3><p>{{ price }}</p></div>
$html = $modx->getChunk('ProductCard', [
'name' => 'Widget',
'price' => '£9.99',
]);
This works because the Twig parser wraps MODX chunks in a proxy (modChunkTwig) that renders the chunk output through Twig after MODX processes it. The chunk's MODX placeholders ([[+name]]) and Twig variables ({{ name }}) both work.
Registering Custom Twig Functions¶
There are three ways to add functions, filters, or other Twig features. Choose whichever fits your use case.
1. Initializers¶
An initializer is a callable that receives the Twig Environment when it is first created. Use this for quick, one-off additions:
$twig = $modx->services->get('twigparser');
$twig->registerInitializer(function (\Twig\Environment $env, \Boffinate\Twig\Twig $parser, \MODX\Revolution\modX $modx) {
$env->addFunction(new \Twig\TwigFunction('product_url', function (int $id) use ($modx) {
return $modx->makeUrl($id) . '?view=product';
}));
$env->addFilter(new \Twig\TwigFilter('currency', function (float $amount) {
return '£' . number_format($amount, 2);
}));
$env->addGlobal('app_version', '2.1.0');
});
The initializer receives three arguments:
| Argument | Type | Description |
|---|---|---|
$env |
Twig\Environment |
The Twig environment -- add functions, filters, globals, tests |
$parser |
Boffinate\Twig\Twig |
The Twig parser instance |
$modx |
MODX\Revolution\modX |
The MODX instance |
Initializers run once when the Twig environment is first set up.
2. Extensions¶
A Twig extension is a class that bundles related functions, filters, and tests together. Use this when you have several related additions:
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
use Twig\TwigFilter;
class MyExtraExtension extends AbstractExtension
{
public function __construct(private \MODX\Revolution\modX $modx) {}
public function getFunctions(): array
{
return [
new TwigFunction('myextra_items', [$this, 'getItems']),
new TwigFunction('myextra_count', [$this, 'getCount']),
];
}
public function getFilters(): array
{
return [
new TwigFilter('myextra_format', [$this, 'format']),
];
}
public function getItems(int $categoryId): array
{
// Your data fetching logic
return [];
}
public function getCount(int $categoryId): int
{
return count($this->getItems($categoryId));
}
public function format(string $value): string
{
return strtoupper(trim($value));
}
}
// Register it
$twig = $modx->services->get('twigparser');
$twig->registerExtension(new MyExtraExtension($modx));
Templates can then use:
{% set items = myextra_items(5) %}
<p>{{ myextra_count(5) }} items</p>
{% for item in items %}
<div>{{ item.name|myextra_format }}</div>
{% endfor %}
3. The OnTwigInit system event¶
If your extra is installed as a MODX package, you can register a plugin that listens to the OnTwigInit event. This runs when the Twig environment is created, before any templates are rendered:
// Plugin code, listening to OnTwigInit
// Available variables: $twig, $parser, $modx
$twig->addFunction(new \Twig\TwigFunction('myextra_version', fn () => '1.0.0'));
$twig->addGlobal('myextra_config', [
'api_url' => $modx->getOption('myextra.api_url'),
'enabled' => (bool) $modx->getOption('myextra.enabled'),
]);
return '';
| Variable | Type | Description |
|---|---|---|
$twig |
Twig\Environment |
The Twig environment |
$parser |
Boffinate\Twig\Twig |
The Twig parser instance |
$modx |
MODX\Revolution\modX |
The MODX instance |
Use OnTwigInit when your extra is a standalone package and you want its Twig functions available automatically on every site that has both your extra and the Twig extra installed.
To register the event in your transport package:
// In your _build/data/transport.plugins.php or equivalent
[
'name' => 'MyExtraTwigPlugin',
'description' => 'Registers Twig functions for MyExtra.',
'file' => 'elements/plugins/MyExtraTwig.php',
'events' => [
['event' => 'OnTwigInit', 'priority' => 0, 'propertyset' => 0],
],
]
When to use which¶
| Approach | Best for |
|---|---|
| Initializer | Quick additions in a snippet, plugin, or bootstrap file |
| Extension class | Bundling several related functions together with clean class structure |
| OnTwigInit event | Package-level registration that activates automatically when your extra is installed |
All three approaches have the same end result -- the functions are available in every Twig template rendered after registration.
The Shared Runtime¶
If your Twig functions need to call MODX features (render chunks, run snippets, generate URLs, read settings), use the shared ModxRuntime instead of reimplementing those operations:
use Boffinate\Twig\Support\ModxRuntime;
use Twig\Extension\AbstractExtension;
use Twig\TwigFunction;
class CatalogExtension extends AbstractExtension
{
public function __construct(private ModxRuntime $runtime) {}
public function getFunctions(): array
{
return [
new TwigFunction('catalog_card', function (array $product) {
return $this->runtime->chunk('CatalogCard', $product);
}, ['is_safe' => ['html']]),
new TwigFunction('catalog_url', function (int $resourceId) {
return $this->runtime->link($resourceId, ['view' => 'product']);
}),
new TwigFunction('catalog_setting', function (string $key) {
return $this->runtime->option('catalog.' . $key);
}),
];
}
}
$twig = $modx->services->get('twigparser');
$twig->registerExtension(new CatalogExtension($twig->getRuntime()));
Runtime methods¶
| Method | Equivalent MODX call | Description |
|---|---|---|
chunk($name, $props) |
$modx->getChunk() |
Render a MODX chunk with properties |
snippet($name, $props) |
$modx->runSnippet() |
Run a MODX snippet |
placeholder($key, $default) |
$modx->getPlaceholder() |
Read a placeholder |
option($key, $default) |
$modx->getOption() |
Read a system setting |
lexicon($key, $params, $lang) |
$modx->lexicon() |
Translate a lexicon key |
translate($key, $topic, $params, $lang) |
load topic + $modx->lexicon() |
Load a lexicon topic and translate |
link($id, $params, $ctx, $scheme, $opts) |
$modx->makeUrl() |
Generate a resource URL |
field($name, $default, $resource) |
$resource->get() / ->getTVValue() |
Read a resource field or TV |
getModx() |
-- | Get the raw modX instance |
getParser() |
-- | Get the Twig parser instance |
Why use the runtime instead of $modx directly?¶
- The runtime methods handle edge cases (property encoding, parser iteration limits, field/TV fallback logic).
- If the internal implementation changes, the runtime API stays stable.
- Your extension stays decoupled from MODX internals.
You can still access $modx directly through $runtime->getModx() when you need something the runtime does not cover.
Accessing the Twig Environment¶
If you need the raw Twig\Environment instance (for example, to check which extensions are loaded or to configure loader paths):
$twig = $modx->services->get('twigparser');
$env = $twig->getEnvironment();
// Check if a function exists
$env->getFunction('myextra_items'); // returns TwigFunction or false
// Add a global at any time
$env->addGlobal('build_time', time());
Integrating with a Custom Event¶
If your extra fires its own rendering event (like ContentBlocks fires ContentBlocks_BeforeParse), you can pass templates through Twig before your extra processes them:
// Inside your extra's rendering pipeline
$template = $this->getFieldTemplate();
$placeholders = $this->getFieldData();
// Pass through Twig if available
if ($this->modx->services->has('twigparser')) {
$twig = $this->modx->services->get('twigparser');
$template = $twig->renderString($template, $placeholders);
}
// Continue with your extra's own rendering
$output = $this->processPlaceholders($template, $placeholders);
This is the pattern the ContentBlocks plugin uses. The key points:
- Call
renderString()with the template and the data your extra would normally pass as placeholders. - The data array becomes the Twig template's local variables.
- Do this before your extra processes its own placeholder syntax, so users can mix Twig and your extra's syntax.
- Check
services->has('twigparser')first so your extra still works without the Twig extra installed.
Firing a system event for other extras¶
If you want other extras to be able to hook into your rendering, fire a system event and let plugins handle the Twig integration:
// In your extra's rendering code
$result = $this->modx->invokeEvent('MyExtraBeforeParse', [
'tpl' => $template,
'phs' => $placeholders,
]);
if (is_string($result) && $result !== '') {
$template = $result;
}
Then a Twig plugin can listen to MyExtraBeforeParse:
// Plugin listening to MyExtraBeforeParse
$twig = $this->modx->services->get('twigparser');
return $twig->renderString($tpl, $phs);
This is the approach ContentBlocks uses with ContentBlocks_BeforeParse. It keeps the Twig dependency optional -- sites without the Twig extra are unaffected.
Providing Configurable Templates¶
A common pattern for extras is to let users configure templates through system settings or chunk names. With Twig available, you can offer Twig syntax in those templates:
class MyExtra
{
public function render(array $data): string
{
$tplSetting = $this->modx->getOption('myextra.item_tpl');
// If the setting looks like a chunk name, use the chunk
// If it contains Twig/HTML, render it directly
if ($tplSetting && !str_contains($tplSetting, '{{') && !str_contains($tplSetting, '<')) {
return $this->modx->getChunk($tplSetting, $data);
}
// Default template with Twig syntax
$template = $tplSetting ?: '<div class="item"><h3>{{ title }}</h3></div>';
if ($this->modx->services->has('twigparser')) {
$twig = $this->modx->services->get('twigparser');
return $twig->renderString($template, $data);
}
// Fallback: simple placeholder replacement
$output = $template;
foreach ($data as $key => $value) {
$output = str_replace('[[+' . $key . ']]', (string) $value, $output);
}
return $output;
}
}
Passing Complex Data to Templates¶
renderString() accepts any value that Twig can handle. You can pass nested arrays, objects, and callables:
$twig->renderString($template, [
// Simple values
'title' => 'Hello',
'count' => 42,
'published' => true,
// Arrays (loopable in Twig)
'items' => [
['name' => 'Alpha', 'price' => 10],
['name' => 'Beta', 'price' => 20],
],
// Nested arrays (accessible via dot notation in Twig)
'config' => [
'show_images' => true,
'columns' => 3,
],
// Objects (Twig accesses public properties and getters)
'resource' => $modx->resource,
]);
In the template:
{{ title }}
{% for item in items %}
{{ item.name }}: {{ item.price }}
{% endfor %}
{% if config.show_images %}...{% endif %}
{{ resource.pagetitle }}
Error Handling¶
renderString() throws Twig\Error\SyntaxError if the template has invalid Twig syntax. If your extra should handle this gracefully instead of crashing the page:
use Twig\Error\SyntaxError;
use Twig\Error\RuntimeError;
try {
$output = $twig->renderString($template, $data);
} catch (SyntaxError $e) {
$this->modx->log(\modX::LOG_LEVEL_ERROR,
'MyExtra: Twig syntax error in template: ' . $e->getMessage()
);
$output = '<!-- Template error: ' . htmlspecialchars($e->getMessage()) . ' -->';
} catch (RuntimeError $e) {
$this->modx->log(\modX::LOG_LEVEL_ERROR,
'MyExtra: Twig runtime error: ' . $e->getMessage()
);
$output = '';
}
Cache Management¶
Compiled Twig templates are cached at {core_cache_path}/twig/. If your extra generates templates dynamically and you need to clear the cache:
// Instance method
$twig = $modx->services->get('twigparser');
$twig->clearCompiledTemplates();
// Static method (no parser instance needed)
\Boffinate\Twig\Twig::clearCompiledTemplatesForModx($modx);
The TwigCacheClear plugin already clears the cache on OnSiteRefresh (MODX cache clear). You only need to call these methods if your extra needs to invalidate the cache at other times.
Testing¶
The Twig extra includes a ParserTestCase base class that sets up a working MODX + Twig environment for integration tests. If your extra depends on the Twig parser, you can extend this class:
use MODX\Revolution\Tests\Twig\ParserTestCase;
class MyExtraTest extends ParserTestCase
{
protected function usesTwigParser(): bool
{
return true;
}
public function test_my_custom_function_works(): void
{
$parser = $this->modx->parser;
$parser->registerInitializer(function ($twig) {
$twig->addFunction(new \Twig\TwigFunction('greet', fn ($name) => "Hello $name"));
});
$this->assertSame('Hello World', $this->processContent('{{ greet("World") }}'));
}
public function test_my_extension_renders_chunk(): void
{
$this->registerChunk('TestChunk', '<p>{{ message }}</p>');
$twig = $this->modx->services->get('twigparser');
$output = $twig->renderString('{{ chunk("TestChunk", {"message": "Hi"}) }}', []);
$this->assertSame('<p>Hi</p>', $output);
}
}
Available test helpers¶
The ParserTestCase provides:
| Method | Description |
|---|---|
processContent($content) |
Run content through the full MODX + Twig parser pipeline |
renderTemplateContent($content, $props) |
Render content as if it were a MODX template |
renderResourceContent($content, $props) |
Render content as if it were resource content |
registerChunk($name, $content) |
Create an in-memory chunk (no database write) |
registerSnippet($name, $code) |
Create a snippet in the database (cleaned up after test) |
registerResource($fields) |
Create a resource in the database (cleaned up after test) |
registerTemplateVar($name, $fields) |
Create a TV in the database (cleaned up after test) |
assignTemplateVarValue($resource, $name, $value) |
Set a TV value on a resource |
executePluginFile($path, $variables) |
Execute a plugin file with injected variables |
All database fixtures are automatically cleaned up in tearDown.
Running tests¶
Tests run against a live MODX installation through DDEV:
API Summary¶
Boffinate\Twig\Twig¶
| Method | Description |
|---|---|
renderString(string $content, array $placeholders): string |
Render a Twig template string with variables |
getEnvironment(): Environment |
Get the raw Twig Environment |
getRuntime(): ModxRuntime |
Get the shared MODX runtime helper |
registerInitializer(callable $fn): void |
Register a function to run on environment setup |
registerExtension(ExtensionInterface $ext): void |
Register a Twig extension |
clearCompiledTemplates(): void |
Clear the compiled template cache |
Boffinate\Twig\Support\ModxRuntime¶
| Method | Description |
|---|---|
chunk(string $name, array $props = []): string |
Render a MODX chunk |
snippet(string $name, array $props = []): string |
Run a MODX snippet |
placeholder(string $key, $default = null): mixed |
Read a placeholder |
option(string $key, $default = null): mixed |
Read a system setting |
lexicon(string $key, array $params = [], string $lang = ''): ?string |
Translate a lexicon key |
translate(string $key, string $topic = '', array $params = [], string $lang = ''): ?string |
Load topic and translate |
link(int\|string $id, ...): string |
Generate a resource URL |
field(mixed $name, $default = null, $resource = null): mixed |
Read a resource field or TV |
getModx(): modX |
Get the MODX instance |
getParser(): Twig |
Get the Twig parser instance |