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.

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:
Magento_Sales/email/order_new.html
Magento_Email/email/header.html
Magento_Email/email/footer.htmlThe 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}}:|rawskips the escaping, for variables that already hold HTML.{{trans "Hi %name," name=$customer_name}}: Translatable string with placeholders. Escaped by default,|rawoverrides.{{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_urland_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}}readsgetData('increment_id'). Works.{{var order.getIncrementId()}}also works, but not by calling the method. Strict mode turns anyget*call on a data object intogetData()with the snake_case key, hereincrement_id.{{var order.getAllVisibleItems()}}returns nothing. It becomesgetData('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:getUrlon 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 asorder.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, withstore.frontend_namefilled 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:
<!--@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, becauseitemdoes 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 timecustomer_name: Customer name, or the guest's billing namestore: The order's storerule_name: Admin name of the rulematched_items: One entry per matched item:sku,name,qty,row_total,product_id,product_url,image_url, plus every product attributematched_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:
<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?+–
Can I use comparisons like {{if qty > 1}}?+–
Why does {{config path="..."}} return an empty string?+–
What is the difference between {{if}} and {{depend}}?+–
Do theme email templates override admin templates?+–
How do I add a product attribute to the core order items table?+–

Shane Blandford
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.
✦ KEEP READING
Related from the Journal.
Magento 2 Follow-Up Emails After Purchase: What Core Does and What It Doesn't
Magento 2 sends no delayed follow-up emails after purchase. What core does, what a DIY cron queue needs, and where a per-SKU module fits.

How to Customize the Magento 2 Checkout Success Page by Product
Customize the Magento 2 checkout success page per product: a CMS block for every order, a SKU-checking view model, and the gotchas.

How to Attach a PDF to Magento 2 Order Emails (Manuals, Warranty Cards)
Magento 2 order emails can't carry attachments. Here is the custom TransportBuilder code, what it leaves out, and a per-SKU module that sends PDFs.
