Skip to content
MAGENTO EXTENSIONS

Magento 2 Email Template Variables: {{var}}, {{for}} and Product Data

A working reference for Magento 2 email template variables: var, trans, if, depend, for loops, layout, strict mode, and how to list product data per item.

Shane Blandford
Shane Blandford · FOUNDERPUBLISHED · 9 MIN READ

Magento email templates are HTML with a small directive language inside double braces: {{var}} prints a value, {{trans}} translates a string, {{if}} and {{depend}} show or hide a block, {{for}} loops over an array, and {{layout}} renders a block from layout XML. Since 2.4.0 every template runs in strict mode, which decides what you can and cannot reach on an object.

Below is the reference we use when building client email templates, checked against the 2.4.8 core source. The last part covers the one thing core templates cannot do, listing product attributes per ordered item, and how we solved it in the OCM Labs Product Messaging module for Magento 2. We build and sell that module, so read that section with that in mind.

Where do Magento email templates live?

In two places. Every transactional email ships as a file in a module, for example Magento_Sales::email/order_new.html for the New Order email. You can override it in a theme, or create a database copy in the admin and select that copy in configuration.

Admin route. Go to Marketing > Communications > Email Templates, click Add New Template, pick the core template under Load Default Template, and click Load Template. Edit, save, then select your copy in the matching config field, which for order emails is Stores > Configuration > Sales > Sales Emails > Order > New Order Confirmation Template. Database templates are scoped by that config, so each store view can use a different one.

Theme route. Copy the file into your theme under the module's name:

APP/DESIGN/FRONTEND/VENDOR/THEME/
Magento_Sales/email/order_new.html
Magento_Email/email/header.html
Magento_Email/email/footer.html

The header and footer are pulled into every core email with {{template config_path="design/email/header_template"}} and the footer equivalent, so branding changes go there once rather than in every template.

Which directives can you use?

These are the directives the email filter (Magento\Email\Model\Template\Filter) handles:

  • {{var order.increment_id}}: Prints a variable. HTML-escaped by default.
  • {{var payment_html|raw}}: |raw skips the escaping, for variables that already hold HTML.
  • {{trans "Hi %name," name=$customer_name}}: Translatable string with placeholders. Escaped by default, |raw overrides.
  • {{if var}}...{{else}}...{{/if}}: Truthiness check on one variable. No comparison operators.
  • {{depend var}}...{{/depend}}: Like {{if}} with no {{else}}.
  • {{for item in items}}...{{/for}}: Loops over an array of arrays or data objects.
  • {{layout handle="..." order_id=$order_id area="frontend"}}: Renders a layout handle, passing parameters to its blocks.
  • {{template config_path="..."}}: Includes another template selected in config.
  • {{store url="customer/account/"}}: Builds a store URL. direct_url and _query_* parameters work too.
  • {{config path="general/store_information/phone"}}: Reads a config value, but only from an allowlist.
  • {{customVar code="my_code"}}: Prints a custom variable from System > Other Settings > Custom Variables.
  • {{css file="css/email.css"}} / {{inlinecss file="css/email-inline.css"}}: Embeds a stylesheet, or inlines its rules into the HTML.

Two of these trip people up. {{config}} only returns paths on an allowlist defined in Magento_Variable's di.xml: store information fields, the sender identity names and emails, and the base URLs. Anything else comes back empty, with no error. And |raw is not really a filter. Escaping is the default, and naming any other modifier replaces it. Use |raw only for variables you know hold safe HTML, such as formattedBillingAddress or payment_html.

What does strict mode change?

Strict mode means a template can read data but cannot call methods. Adobe added it in 2.3.4, made it the default in 2.4.0, and removed the legacy resolver entirely in 2.4.4, so on 2.4.7 and later there is no legacy mode left to fall back to. Old database templates still flagged is_legacy are rendered in strict mode anyway.

In practice:

  • {{var order.increment_id}} reads getData('increment_id'). Works.
  • {{var order.getIncrementId()}} also works, but not by calling the method. Strict mode turns any get* call on a data object into getData() with the snake_case key, here increment_id.
  • {{var order.getAllVisibleItems()}} returns nothing. It becomes getData('all_visible_items'), and no such key exists.
  • {{var order.getShippingAddress().getCity()}} returns nothing for the same reason. The address is not stored as data on the order.
  • {{var this.getUrl($store,'customer/account/',[_nosid:1])}} is the one exception: getUrl on the template is still called.
  • A variable that resolves to an object prints as an empty string. Only scalars and arrays come out.

That is why core passes pre-computed values like formattedShippingAddress, created_at_formatted, and the order_data array instead of letting the template work them out. If you upgraded from an older store and your templates have gone blank in places, run bin/magento dev:email:override-compatibility-check to list database templates that still use method calls.

Which variables do order emails get?

The New Order email (Magento\Sales\Model\Order\Email\Sender\OrderSender) passes these:

  • order: The order. Read fields such as order.increment_id, order.customer_email, order.shipping_description.
  • order_id: The order's database ID, used by the items layout.
  • order_data.customer_name: Customer name.
  • order_data.is_not_virtual: True when the order ships. Core uses it to hide shipping blocks.
  • order_data.email_customer_note: The customer's order comment.
  • order_data.frontend_status_label: Status label as the customer sees it.
  • formattedBillingAddress, formattedShippingAddress: Rendered address HTML. Print with |raw.
  • payment_html: Rendered payment method block. Print with |raw.
  • created_at_formatted: Formatted order date.
  • store: The store, with store.frontend_name filled in for you.

The email template model adds store_phone, store_email, store_hours, logo_url, and logo_alt to every email.

A small working example

A trimmed order confirmation that shows the main directives together:

APP/DESIGN/FRONTEND/VENDOR/THEME/MAGENTO_SALES/EMAIL/ORDER_NEW.HTML
<!--@subject {{trans "Your %store_name order #%increment_id" store_name=$store.frontend_name increment_id=$order.increment_id}} @-->

{{template config_path="design/email/header_template"}}

<p>{{trans "%customer_name," customer_name=$order_data.customer_name}}</p>
<p>{{trans "Thanks for your order. We will email tracking details when it ships."}}</p>

{{depend order_data.email_customer_note}}
<p><strong>{{trans "Your note:"}}</strong> {{var order_data.email_customer_note|escape|nl2br}}</p>
{{/depend}}

{{if order_data.is_not_virtual}}
<p>{{trans "Shipping to:"}}<br/>{{var formattedShippingAddress|raw}}</p>
{{else}}
<p>{{trans "This order has no shipped items."}}</p>
{{/if}}

{{layout handle="sales_email_order_items" order_id=$order_id area="frontend"}}

<p><a href="{{store url="customer/account/"}}">{{trans "View your account"}}</a>
{{depend store_phone}} | {{var store_phone}}{{/depend}}</p>

{{template config_path="design/email/footer_template"}}

Can you loop over order items with {{for}}?

Not in a core order email. {{for}} needs an array, and the order's items are only reachable through a method call, which strict mode blocks. Core renders the items table through {{layout handle="sales_email_order_items" ...}} instead, a PHP block with a .phtml template behind it. To add a product attribute to that table you override Magento_Sales::email/items/order/default.phtml in your theme and load the attribute in PHP, and the change applies to every item in every order.

Two more {{for}} behaviors, read straight from ForDirective in the framework:

  • Inside the loop body, each {{...}} tag is resolved as a plain variable lookup, and the value is inserted without the default HTML escaping. Treat looped values as trusted output.
  • {{if}} and {{depend}} run before the loop does, across the whole template. A condition inside a loop cannot test the loop item, because item does not exist yet when it is evaluated. Use {{var loop.index}} if you need the zero-based position.

How do you list per-product data in an email?

You pass the template an array where each entry already holds the fields you need. That is the gap Product Messaging fills. A rule matches SKUs on an order, and its email template gets these variables:

  • order: The order, reloaded at send time
  • customer_name: Customer name, or the guest's billing name
  • store: The order's store
  • rule_name: Admin name of the rule
  • matched_items: One entry per matched item: sku, name, qty, row_total, product_id, product_url, image_url, plus every product attribute
  • matched_items_html: A pre-rendered table of the matched items (Product, SKU, Qty)

Because matched_items is a plain array, {{for}} works on it, and any attribute is available as {{var item.your_attribute_code}} with no setup. Here a store has a manual_url product attribute:

MARKETING > COMMUNICATIONS > EMAIL TEMPLATES (TEMPLATE CONTENT)
<p>Hi {{var customer_name}},</p>
<p>Order <strong>#{{var order.increment_id}}</strong> includes products with a manual online.</p>

<table cellpadding="0" cellspacing="0" border="0" width="100%">
    <tr>
        <th align="left">Product</th>
        <th align="left">SKU</th>
        <th align="left">Manual</th>
    </tr>
    {{for item in matched_items}}
    <tr>
        <td>{{var item.name}}</td>
        <td>{{var item.sku}}</td>
        <td><a href="{{var item.manual_url}}">Download manual</a></td>
    </tr>
    {{/for}}
</table>

If you do not need custom columns, one line drops in the whole table:

{{var matched_items_html|raw}}

Leave off |raw and the table HTML is escaped and shows up as literal markup.

A few details from the template variable reference: matched_items is a snapshot taken when the rule matched, so a three-day delayed email still shows what was bought even if the product has since been edited. order is reloaded at send time. Built-in fields win over an attribute with the same name, cost is never captured, and product_url is left out for products that are not individually visible.

The rules match by SKU list only, with no category or attribute conditions, and the emails are transactional, with no unsubscribe link or open tracking. The same rules can attach a PDF, which we covered in attaching a PDF manual to Magento order emails, and can show a block on the order confirmation page, covered in showing different checkout success page content per product.

The module is $250 for Magento Open Source and $500 for Adobe Commerce, and supports 2.4.8 to 2.4.9 on PHP 8.2 to 8.5, on Luma and Hyva. Our other modules are on the Magento extensions page.

FAQ

Why does {{var order.getSomething()}} print nothing?+
Strict mode. On 2.4.4 and later, methods are not called from templates. A get* call on a data object is turned into a getData() lookup with the snake_case key, so it only works when that key is stored on the object. Pass the value in as its own variable instead.
Can I use comparisons like {{if qty > 1}}?+
No. {{if}} and {{depend}} only check whether a single variable is truthy. Compute the condition in PHP and pass a true or false variable to the template.
Why does {{config path="..."}} return an empty string?+
The path is not on the allowlist. {{config}} only reads store information fields, sender identities, and base URLs. For your own value, create a custom variable and print it with {{customVar code="..."}}.
What is the difference between {{if}} and {{depend}}?+
{{if}} supports an {{else}} branch and {{depend}} does not. Both test one variable for truthiness.
Do theme email templates override admin templates?+
No, the other way round. A database template selected in configuration is used instead of the file, wherever that file lives. The theme file is used when the config field is left on the default.
How do I add a product attribute to the core order items table?+
Override Magento_Sales::email/items/order/default.phtml in your theme and read the attribute in PHP. A {{for}} loop cannot reach order items in a core email, because getting them takes a method call.
Shane Blandford

Shane Blandford

FOUNDER

Founder of Orange Collar Media, a Denver ecommerce agency behind 800+ Magento and Shopify builds. In Magento since version 1.x - building, rescuing, and supporting revenue-critical stores since 2008.

All posts by Shane Blandford→

KEEP READING

Related from the Journal.

Your store can sell more. Let's find out how much.

Start a project