Template Directives v26.9+
Transactional emails, newsletter templates, and CMS pages/blocks are processed by Maho's template filter, which understands a small set of {{...}} directives. This page documents the directives and the condition syntax, including the expression support introduced in 26.9.
Variables: {{var}}
Outputs a template variable. Dotted paths traverse objects, and method calls are supported:
Hi {{var customer.name}},
your order {{var order.increment_id}} has shipped.
{{var order.getBillingAddress().format('html')}}
For object properties (order.increment_id), Maho first looks for a real getter method (getIncrementId()) and falls back to raw data access, so computed values like order.billing_address resolve the same way they do in PHP code.
Conditions: {{if}} and {{depend}}
{{if}} renders one of two branches, {{depend}} renders its body only when the condition holds:
{{if order.getIsNotVirtual()}}
Your tracking number is {{var shipment.getTrackingNumber()}}.
{{else}}
Your download is ready in your customer account.
{{/if}}
{{depend comment}}
Note from the merchant: {{var comment}}
{{/depend}}
Expressions v26.9+
Conditions are evaluated with Symfony ExpressionLanguage, so they are not limited to a single variable - comparisons, boolean logic, and method calls all work:
{{if order.grand_total > 100 && customer.group_id == 2}}
Thanks for the big wholesale order!
{{/if}}
{{if order.status in ['complete', 'closed']}}...{{/if}}
{{if customer.getName() starts with 'Dr.'}}...{{/if}}
Commonly used operators:
| Category | Operators |
|---|---|
| Comparison | == != < <= > >= |
| Boolean | && (and), || (or), ! (not) |
| Strings | ~ (concatenation), starts with, ends with, contains, matches |
| Arrays | in, not in |
| Numbers | + - * / % |
The full syntax is documented in the ExpressionLanguage reference.
Evaluation rules
- Truthiness follows PHP:
'',0,'0',null,false, and empty arrays are false, everything else is true. (Before 26.9,0and'0'were treated as true - a quirk of the old evaluator, not a feature.) - Absent variables evaluate as empty.
{{depend comment}}with nocommentvariable simply renders nothing; it does not log or fail. - A broken condition never breaks the template. If an expression fails to parse or evaluate, the else branch renders (or nothing, for
{{depend}}) and a warning is written to the log with the offending expression. - Property paths prefer getters. As with
{{var}},order.status_labelcallsgetStatusLabel()when it exists and falls back togetData('status_label').
Security
Template authors get the same power in conditions as in {{var}}, no more:
- The ExpressionLanguage
constant()function is disabled, so templates cannot read PHP or class constants. getConfig()calls against encrypted configuration paths (API passwords, keys) are neutralized, mirroring the protection applied to{{var}}output.
Includes: {{include}} and {{template}}
{{include template="..."}} includes another template by code; {{template config_path="..."}} includes the email template referenced by a system configuration path, which is how the shared header and footer are pulled into every transactional email:
Sanitization of stored content v26.9+
A directive is not valid HTML. The nested quotes in {{media url="..."}} close the surrounding attribute. Any HTML sanitizer that parses content with an unresolved directive in it will therefore mangle that directive, usually into a broken %7B%7B… URL.
Maho avoids this by sanitizing admin-authored rich content on save. The directives are masked, the malicious-code filter runs, then the directives are restored. This happens in the resource model, so admin, API and programmatic saves are all covered:
| Content | Sanitized in |
|---|---|
| CMS pages | Mage_Cms_Model_Resource_Page::_beforeSave() |
| CMS blocks | Mage_Cms_Model_Resource_Block::_beforeSave() |
| Product & category WYSIWYG attributes | Mage_Catalog_Model_Abstract::_beforeSave() |
| Blog posts | Maho_Blog_Model_Resource_Post::_beforeSave() |
Two consequences worth knowing:
- Render does not filter. The stored value is already clean, so output only has to resolve the preserved directives.
- Bulk import is not covered. It writes attribute values straight to the database instead of through the model.
If you are writing a save path for your own rich-text content, reuse the shared helper rather than reimplementing the masking:
$object->setData('content', Mage::getSingleton('core/input_filter_maliciousCode')
->filterPreservingDirectives($object->getData('content'), false,
Mage::helper('cms')->getPageTemplateProcessor()));
The second argument also rewrites links to open in a new tab. Pass true for article-style content such as blog posts. Leave it off when the links are ordinary internal navigation, as in CMS and catalog content.
The third argument is the processor that will actually render the content. Always pass it. Which directives are safe to preserve depends on that processor rather than on a global list:
Mage_Cms_Model_Template_Filterresolves all thirteen maskable directives.Mage_Catalog_Model_Template_Filterresolves five:{{media}},{{skin}}and{{store}}of its own, plus{{include}}and{{template}}inherited from\Maho\Filter\Template.
Omitting the argument does not fall back to a smaller subset. It masks nothing at all.
A preview that is never persisted has no save step to hook into. Resolve the directives first, then run the plain filter over the resolved markup. This is what the newsletter and email template preview blocks do.
The masking pattern is a security boundary
Whatever the masking pattern in Mage_Core_Model_Input_Filter_MaliciousCode matches is restored without sanitization. That makes it much stricter than the renderer's own CONSTRUCTION_PATTERN. It enforces three rules:
- The keyword must be one the supplied processor actually resolves.
- The body must consist of well-formed
name="value"parameters. - No parameter may be named like an event handler (
onfollowed by letters).
Rule 1 matters because a renderer leaves a directive it has no handler for untouched in the output. Masking such a directive would hand the payload straight to the browser.
Rule 2 matters because excluding < and > is not enough on its own. An HTML attribute is terminated by a quote, so a body like " onerror="alert(1) grafts a live event handler onto the enclosing tag without using an angle bracket at all.
Rule 3 is why the first two are not enough. A well-formed parameter is also a well-formed HTML attribute, so {{media url="a" onerror="alert(1)"}} satisfies both: media is a keyword the processor resolves, and the body parses. Emitted verbatim inside alt="…", the HTML parser ends the attribute at the directive's first quote and reads onerror as the next attribute of the tag. Resolving the directive discards the extra parameter, so this only bites on a render path that emits rather than resolves — excluding the name keeps the preserved text inert either way. Only on plus letters is rejected, so a widget parameter such as on_sale still masks.
The exclusion deliberately stops at event handlers rather than growing to cover href, src and friends, which the same trick can also graft onto the tag. Rejecting on plus letters costs nothing, since no directive parameter is ever named onclick, whereas href and src are plausible widget parameter names, so excluding them would stop masking a legitimate directive and mangle it on save. What keeps those inert is the invariant at the end of this box: every handler reads its own named parameters and drops the rest, or stripDirectives() removes the construction. Preserve that invariant on new render paths instead of extending this list.
Whether the renderer runs at all counts too. Catalog descriptions reach the template processor only when Allow Dynamic Media URLs in Products and Categories (catalog/frontend/parse_url_directives) is enabled, and that setting is per store view. Deciding it at save time would be wrong twice over: the flag can be switched off long after the save, and it is per store while the stored value is shared. So the save keeps the directive intact, and Mage_Catalog_Helper_Output calls stripDirectives() when the rendering store will not resolve it. Nothing is lost, since a directive that is never parsed would not have worked anyway.
That fallback is keyed off is_wysiwyg_enabled alone, deliberately, because that is the flag the save side masks on. An attribute with WYSIWYG enabled but Allow HTML Tags on Frontend disabled would otherwise match none of the three branches — neither escaped, nor resolved, nor stripped — and its directives would reach the page exactly as authored.
No processor means no preservation. If you cannot name the processor that will resolve a directive, there isn't one. Masking on the assumption that something downstream will resolve it is how content ends up shipping a live event handler. Product alert emails are the worked example: their markup is echoed into an already-rendered {{var alertGrid}}, which the template filter does not rescan, so they strip directives and filter as plain HTML.
{{var}}, {{depend}} and {{if}} are never masked, even though every processor implements them. With no template variables assigned, which is precisely the CMS and catalog case, they render verbatim. The invariant to preserve: a masked directive must always resolve at render time and never reach the browser as authored.
Embedding video
Sanitization strips <iframe>, so a pasted YouTube embed does not survive the save. Use the YouTube Video widget instead. It renders the iframe when the page is displayed, so the markup is never stored and never sanitized. It embeds through youtube-nocookie.com, YouTube's privacy-enhanced host, which sets no tracking cookies until the visitor starts playback.
Paste the link in whatever form you have it. Every YouTube host and URL shape below is accepted, with or without a scheme and with or without a www. prefix:
| Form | Example |
|---|---|
| Watch | youtube.com/watch?v=dQw4w9WgXcQ |
| Share | youtu.be/dQw4w9WgXcQ |
| Embed | youtube.com/embed/dQw4w9WgXcQ |
| Shorts | youtube.com/shorts/dQw4w9WgXcQ |
| Live | youtube.com/live/dQw4w9WgXcQ |
| Legacy | youtube.com/v/dQw4w9WgXcQ |
| Mobile | m.youtube.com/watch?v=dQw4w9WgXcQ |
| Privacy host | youtube-nocookie.com/embed/dQw4w9WgXcQ |
| Bare id | dQw4w9WgXcQ |
The widget is YouTube-only. A link that carries no video id, such as a playlist, a search-results page or a channel, renders nothing rather than a broken frame.
What sanitization removes
Mage_Core_Helper_Purifier sanitizes with Symfony's HTML sanitizer on the W3C Sanitizer API baseline, which is HTML5-native. video, audio, source, picture, figure, details, section, article, mark and time all survive a save, as do id, class, style, target, rel, loading, srcset and any data-* attribute. Removed:
| Input | After save |
|---|---|
<script>, <iframe> (Vimeo and other embeds, maps), <object>, <embed> | removed with contents |
<form>, <input>, <select>, <textarea> | removed with contents |
data: URIs in a media src | dropped |
event handler attributes (onclick, onerror, ...) | dropped |
YouTube embeds have the widget above as a replacement. To put a form on a page, use a block or a widget rather than markup in a content field: a form in stored content posts wherever its action says, under the merchant's own domain and certificate.
class and style are kept even though the W3C baseline classes them as unsafe, because the WYSIWYG preserves both and stores text alignment as inline style. The sanitizer does not parse CSS, so the CSS-level vectors (expression(), behavior:, javascript:) are removed by the regex pass in Mage_Core_Model_Input_Filter_MaliciousCode, which runs first.
data-* needs a word of explanation, because the sanitizer matches allowed attributes by exact name and has no wildcard for them. allowAttribute('data-attr', '*') allows an attribute literally called data-attr on every element; it does not allow data-role. Since merchants put arbitrary data attributes on CMS markup, an enumerated list would always be incomplete, so Mage_Core_Helper_Purifier reads the data-* names off the content being sanitized and allows those. They are inert by definition, and the surrounding policy still applies, so a data-* allowance cannot smuggle an event handler through.
Stored content is only affected when the entity is next saved, so existing pages keep working until someone edits them. To adjust the policy, change Mage_Core_Helper_Purifier::buildConfig() rather than skipping sanitization at the call site.
This mechanism addresses client-side HTML only. A directive's parameters are preserved exactly as authored and resolved on output, so constraining what content directives are permitted to do ({{block}}, {{config}}, {{layout}}) is a separate concern.
Where directives are available
| Context | Filter |
|---|---|
| Transactional emails | Mage_Core_Model_Email_Template_Filter |
| Newsletter templates | Mage_Newsletter_Model_Template_Filter |
| CMS pages and blocks | Mage_Cms_Model_Template_Filter |
These filters form an inheritance chain (newsletter extends widget extends CMS extends email), so they all share the directives and condition evaluation described above. On top of the base set, the email filter adds {{block}}, {{layout}}, {{store}}, {{skin}}, {{media}}, {{config}}, {{customvar}}, {{protocol}}, {{inlinecss}}, and output modifiers such as {{var subscriber_email|escape}}; {{widget}} is available in the CMS, widget, and newsletter contexts.