Transactional Email Templates in Magento 2: Variables, Layouts, Testing
AI generated
M2
di.xml
Magento 2 · Email Templates · TransportBuilder · MailHog
Transactional email templates in Magento 2
from email_templates.xml to the MailHog test

Transactional emails decide whether customers trust an order confirmation, a shipping notice, or an account access link, and in Magento 2 their reliability depends on clean registration via email_templates.xml, correctly used {{var}} and {{trans}} directives, and a traceable store-specific resolution of the email templates. Anyone who injects custom variables via plugin, controls header and footer via layout XML per store view, and tests locally with MailHog builds robust email templates instead of fragile one-off solutions.

18 min read email_templates.xml · TransportBuilder · MailHog Magento 2.4.x · PHP 8.4

1. Transactional emails in Magento: scope and boundaries

A transactional email in Magento 2 is any message sent as a direct reaction to a concrete action in the shop: order confirmation, invoice, shipping notification, password reset, or account activation. Unlike a newsletter, which runs through Magento_Newsletter and a subscriber list, a transactional email is always assembled via Magento\Framework\Mail\Template\TransportBuilder and sent synchronously or through a queue consumer as soon as the triggering event occurs. The technical basis for this is always a registered email template, never an inline string in PHP code.

For agencies, this distinction matters because transactional emails have different requirements for reliability and traceability than marketing emails: an order confirmation must not get stuck in a queue, a password reset link has to arrive within seconds. The following sections therefore deal exclusively with the email layer itself, meaning registration, directives, custom variables, layout control, store resolution, and local testing of email templates, without touching the business logic of returns, invoice PDFs, or fraud checks.

2. Template registration via email_templates.xml

Every module that wants to ship its own transactional email declares it in etc/email_templates.xml. The file follows the schema urn:magento:module:Magento_Email:etc/email_templates.xsd and defines, per template node, a unique id, a label for the backend, the filename of the associated .html file, the type (html or text), and the area the template applies to. This id is the key that TransportBuilder::setTemplateIdentifier() later uses to reference the template, and it also shows up as a selectable value in system configuration fields of type Magento\Config\Model\Config\Source\Email\Template.

The actual .html file lives under view/frontend/email/ or view/adminhtml/email/ for backend mails and gets parsed, rendered, and stored in the template cache on first access. Important for clean email templates: the filename in email_templates.xml must exactly match the physical filename, otherwise Magento throws a LocalizedException on load instead of silently failing. A second, often overlooked rule: if only the content of the .html file changes, a cache flush is enough, a fresh setup:upgrade is not needed for pure text changes to already registered email templates.


<?xml version="1.0"?>
<!--
Registers a custom transactional email template for the frontend area.
The "id" is the unique identifier used later by TransportBuilder::setTemplateIdentifier().
-->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd">
    <template id="mironsoft_orderfollowup_email_template"
              label="Mironsoft Order Follow-up Email"
              file="order_followup.html"
              type="html"
              module="Mironsoft_EmailTemplates"
              area="frontend"/>
</config>

3. Backend override: managing email templates in Marketing

Under Marketing > Communications > Email Templates, any administrator can load an XML-registered template, edit it in the WYSIWYG editor, and save it as a standalone copy in the database. Technically this creates a row in the email_template table that still references the original id from email_templates.xml via orig_template_code, but carries its own content, its own variables, and its own title. This database-stored copy always takes precedence over the XML template as soon as it is selected in the relevant configuration path, for example Sales Emails > Order > New Order Confirmation Template.

The advantage of this mechanism: editorial changes to a transactional email require no deploy, no code cache flush, and no developer resources. The downside agencies should watch for in review: database templates are not automatically synchronized with new module versions on a reindex or setup upgrade, they freeze the state at the time of the edit. Anyone making structural changes, such as new variables or new directive logic, to a template already overridden in the backend must maintain the change in both places or deliberately reset the database copy.

4. Directive syntax: {{var}} and {{trans}} in the template

Inside an .html file for email templates, Magento does not process the content as PHP and not as a regular layout, but through Magento\Email\Model\Template\Filter, an extension of the generic template filter with email-specific directives. The directive {{var object.getMethod()}} outputs a value directly, where object comes from the variables passed via setTemplateVars() and can be read via getter methods or array access, for example {{var order.getIncrementId()}} or {{var customer_name}}. Without an escaping filter, the value is inserted raw, which is why an additional escape filter makes sense for user-generated content.

The directive {{trans "Text with %placeholder" placeholder=$value}} additionally handles translation via the active store language and allows named placeholders that are replaced at runtime, similar to __() in PHP code, only inside the template. For structural reuse there is {{template config_path="design/email/header_template"}}, which includes another template stored in the system configuration, typically for header and footer. Anyone needing conditional content in a transactional email, for example a discount block only when a coupon is set, uses {{depend var}}...{{/depend}} or {{if var}}...{{/if}}, both evaluated by the same filter as {{var}} and {{trans}}.


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

<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
    <tr>
        <td class="email-intro">
            <!-- {{trans}} handles translatable strings with named placeholders -->
            <p>{{trans "Hello %name," name=$customer_name}}</p>
            <p>{{trans "your order #%increment_id was shipped on %date." increment_id=$order.getIncrementId() date=$shipping_date}}</p>
        </td>
    </tr>
    <tr>
        <td class="email-tracking">
            <!-- {{var}} outputs a raw variable value, optionally through a filter -->
            <p><a href="{{var tracking_url}}">{{trans "Track your shipment"}}</a></p>
            <p>{{var order.getShippingDescription()}}</p>
        </td>
    </tr>
</table>

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

5. Injecting custom template variables

The obvious but unclean way to get additional data into a transactional email is copying and adapting a core sender class. Cleaner is a plugin on Magento\Framework\Mail\Template\TransportBuilder::setTemplateVars that, as an around plugin, extends the passed-in variables with custom values before the original call continues. The plugin itself gets a dedicated TemplateVariablesProviderInterface injected via constructor property promotion, a small service contract interface with one method getVariables(), which can be implemented differently per use case, for example order, customer, or quote.

Alternatively, when a completely custom transactional email is built from scratch, a dedicated sender class instead of a plugin on the generic TransportBuilder pays off. This class injects TransportBuilder, the custom variables provider, and a logger directly through the constructor, assembles template identifier, template options (area, store), and template variables in a single chained call, and encapsulates error handling in one place. The decisive architectural point: the variable logic belongs in a dedicated, exchangeable class, not in the send method itself, so it stays independently testable and does not need to touch the sending logic when new requirements arise.


final class OrderFollowupSender
{
    /**
     * @param TransportBuilder $transportBuilder Builds the transport with template and variables.
     * @param TemplateVariablesProviderInterface $variablesProvider Supplies additional custom variables.
     * @param LoggerInterface $logger Logs failed sends without interrupting the caller.
     */
    public function __construct(
        private readonly TransportBuilder $transportBuilder,
        private readonly TemplateVariablesProviderInterface $variablesProvider,
        private readonly LoggerInterface $logger,
    ) {
    }

    /**
     * Sends the order follow-up transactional email for a given order.
     *
     * @param OrderInterface $order Order the follow-up email refers to.
     * @return bool True on success, false if sending failed.
     */
    public function send(OrderInterface $order): bool
    {
        $storeId = (int) $order->getStoreId();

        try {
            $transport = $this->transportBuilder
                ->setTemplateIdentifier('mironsoft_orderfollowup_email_template')
                ->setTemplateOptions(['area' => 'frontend', 'store' => $storeId])
                ->setTemplateVars(array_merge(
                    [
                        'order' => $order,
                        'customer_name' => $order->getCustomerFirstname(),
                    ],
                    $this->variablesProvider->getVariables($order)
                ))
                ->setFrom('sales')
                ->addTo((string) $order->getCustomerEmail())
                ->getTransport();

            $transport->sendMessage();

            return true;
        } catch (\Exception $exception) {
            $this->logger->error($exception->getMessage());

            return false;
        }
    }
}

For the additional variables to actually arrive, the plugin type must be registered on the TransportBuilder in di.xml. A sortOrder is usually uncritical here, as long as no second plugin also modifies the same method.


<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
    <type name="Magento\Framework\Mail\Template\TransportBuilder">
        <plugin name="mironsoft_addCustomTemplateVariables"
                type="Mironsoft\EmailTemplates\Plugin\AddCustomTemplateVariablesPlugin"
                sortOrder="10"/>
    </type>
</config>

6. Email layout XML: controlling header, footer, and logo

The standard way to control header, footer, and logo of a transactional email goes through Stores > Configuration > General > Transactional Emails, where a logo, a logo alt text, and a header and footer template can be stored per store view. That is enough for simple branding but hits limits as soon as header or footer need more complex, block-based structure, for example dynamic social media icons or store-specific legal text that itself consists of multiple building blocks.

For that case, a dedicated layout XML file in the area="frontend" pays off, for example view/frontend/layout/email_orderfollowup_header.xml, defining blocks as usual via referenceContainer and block nodes. The sender class briefly emulates the frontend context of the respective store for this, renders the layout handle, fetches the rendered HTML of header and footer via getBlock(), and passes it as an additional template variable, for example header_html, to setTemplateVars(). In the .html template, {{var header_html}} simply picks up the already rendered block, without the email directives themselves needing to know any block logic.

The advantage of this interplay between layout XML and email templates: header and footer follow the same conventions as the rest of the theme, every store view can use its own blocks and its own .phtml files, and layout changes land as usual through the normal theme fallback from store view over website to default, instead of through a separate, hard-to-maintain email-specific logic.


<?xml version="1.0"?>
<!-- view/frontend/layout/email_orderfollowup_header.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <!-- Rendered in area="frontend" per store view during design emulation -->
            <block class="Mironsoft\EmailTemplates\Block\Header"
                   name="email.header"
                   template="Mironsoft_EmailTemplates::email/header.phtml"/>
            <block class="Mironsoft\EmailTemplates\Block\Footer"
                   name="email.footer"
                   template="Mironsoft_EmailTemplates::email/footer.phtml"/>
        </referenceContainer>
    </body>
</page>

7. Multi-store and multi-language: template resolution

Magento resolves the transactional email actually sent in a clear order. First the store-specific configuration value of the relevant system config path is read, for example sales_email/order/template, scoped to store view, website, and default in that priority. If this value points to a database copy saved in the backend, that copy is used, including its own content and its own variables. If it still points to the XML default, meaning the identifier declared in email_templates.xml, Magento loads the physical .html file from the active theme, with full theme fallback down to the module's base theme.

The language of the email templates itself does not follow the file system path, but the active store locale at the time the send happens: {{trans}} directives and __() calls in sender code pull their translation from the theme's or module's i18n CSV files for exactly the locale that is active when setTemplateOptions(['area' => ..., 'store' => $storeId]) is called. Therefore a single .html template is enough for multiple languages, as long as all visible text goes through {{trans}} instead of being a hardcoded string in the template. Only when structure or imagery should differ between stores does it actually require different templates per store view, not just different translations.

For agencies with international shops, the practical consequence is: before creating a second, language-specific template, it is worth checking whether {{trans}} plus full i18n coverage is not already enough. An additional template per language doubles maintenance effort on every content change, while a clean translation file stays central and is maintained through the normal language package fallback.

8. Transactional emails compared: unsafe vs. recommended

With transactional emails, the chosen implementation directly decides whether an email looks correct in all clients, whether checkout blocks on SMTP problems, and whether content stays maintainable without a deploy. The following overview shows common unsafe patterns next to the recommended pattern for the same task around email templates.

Task Unsafe / Error-prone Recommended pattern Benefit
Email content HTML string assembled in PHP code Registration via email_templates.xml Backend-editable, translatable, no deploy for text changes
Layout in the template CSS flexbox/grid, external stylesheets Table-based, inline-styled markup Renders correctly in Outlook, Gmail, and mobile clients
Time of sending Synchronous in the checkout request Asynchronous via message queue consumer Checkout does not block on SMTP latency or outage
Custom variables If/else chains directly in the sender TemplateVariablesProvider via plugin/DI Testable, extensible, without touching the sender class
Multi-store branding One global template for all stores Store-specific overrides with fallback chain Multi-language and branding without code duplication

The table makes clear that practically every row follows the same underlying idea: logic, presentation, and sending of email templates belong in separate building blocks, exchangeable via service contracts and plugins, not in a single, growing method. Anyone who maintains this separation from the start can later replace individual building blocks, for example synchronous sending against a queue consumer, without rewriting the entire template.

9. Local testing with MailHog and preview rendering

In Mark Shust's docker-magento setup, a MailHog container runs by default, intercepting every outgoing SMTP connection from the container instead of actually delivering it. Every transactional email sent via TransportBuilder in the local environment therefore automatically lands in the MailHog web interface, usually on port 8025, instead of in a real customer's inbox, including headers, raw text, and rendered HTML view. For agencies, this is the standard way to check the complete sending path, from the trigger logic through the directive evaluation to the actual SMTP handshake, without risk.

For pure rendering testing without any sending, the admin interface under Marketing > Communications > Email Templates offers a preview function: when opening a template, Magento evaluates all directives server-side with sample data and shows the result directly in the browser, without a mail object or a transport instance being created at all. For templates with application-specific variables that the generic preview does not know, for example custom variables injected via plugin, a small, self-written bin/magento command implemented via the Symfony Console API and registered via di.xml as a CommandListInterface entry pays off.

Such a command loads the template via Magento\Email\Model\TemplateFactory, sets the same variables as the production sender, and calls getProcessedTemplate() instead of taking the detour through TransportBuilder and an actual mail transport. The result can be written as an .html file to var/email-preview/ and opened directly in a browser, completely without an SMTP connection, without MailHog, and without the risk of accidentally emailing a real address. In practice, both routes get combined: the CLI preview for fast iteration on variables and directives, MailHog for the complete end-to-end test including subject line, sender address, and client rendering.

10. Summary

A resilient transactional email in Magento 2 does not come from a quickly assembled string in sender code, but from the consistent use of the building blocks provided for exactly this: registration via email_templates.xml, directives like {{var}} and {{trans}} instead of hardcoded text, a dedicated TemplateVariablesProvider instead of copied core classes, layout XML for block-based header and footer, and a clear store-specific fallback chain for multi-store operation. Each of these building blocks is individually exchangeable, as long as responsibilities stay cleanly separated.

Local testing with MailHog and a custom preview option without a real send round off the workflow: changes to email templates can be checked this way before they ever reach a real address. Anyone who consistently applies these nine building blocks builds email templates that can be maintained, translated, and extended, without taking on risk for production sending on every change.

Transactional email templates in Magento 2: the essentials at a glance

Registration

email_templates.xml declares the id, label, and area. The id is referenced by TransportBuilder::setTemplateIdentifier() in the sender.

Directives

{{var}} outputs values, {{trans}} translates with placeholders. Both are evaluated by Magento\Email\Model\Template\Filter.

Custom variables

An around plugin on TransportBuilder::setTemplateVars, fed from a dedicated TemplateVariablesProviderInterface via DI.

Testing

MailHog intercepts every SMTP connection locally. A CLI preview renders templates entirely without sending.

11. FAQ: Transactional Email Templates in Magento

1What distinguishes a transactional email from a newsletter?
Transactional emails are triggered by a concrete event and sent via TransportBuilder with a registered template. Newsletters run through Magento_Newsletter and a subscriber list, independent of a single customer event.
2How do I register a new email template?
Create a template node in etc/email_templates.xml with id, label, file, type, and area, place the .html file under view/{area}/email/, and run setup:upgrade.
3How do I override a core template without code changes?
Load it in the backend under Marketing > Email Templates, adjust it, and save it as a copy. The copy references the original via orig_template_code but has precedence in the configuration path.
4{{var}} versus {{trans}}?
{{var}} outputs a value directly. {{trans}} additionally translates and supports named placeholders, comparable to __() in PHP code.
5Custom variables without copying core classes?
An around plugin on TransportBuilder::setTemplateVars mixes additional values from a custom TemplateVariablesProviderInterface into the variables array.
6Header, footer, logo per store view?
Simple branding via the transactional email fields in the store configuration. For blocks, render a layout XML in the area=frontend and pass it as a template variable.
7Fallback chain for template resolution?
Store view before website before default. Database copy wins over the XML default, then the normal theme fallback applies for the .html file.
8Local testing without a real send?
MailHog in the docker-magento setup intercepts every SMTP connection and shows every email in its own web interface, usually on port 8025.
9Check rendering without any send attempt?
The preview function in the email templates grid, or a custom bin/magento command that writes getProcessedTemplate() directly into a local HTML file.
10Why sending via message queue instead of synchronously?
Synchronous sending blocks the request on SMTP latency or outage. A queue consumer keeps the request fast and handles sending robustly in the background with retry logic.

Mironsoft

Magento 2 development, email templates, and Hyva themes

Transactional emails that arrive reliably and look clean?

We register email templates cleanly via email_templates.xml, inject custom variables via plugin instead of core copies, and set up local testing with MailHog for your team, so every transactional email stays traceable and maintainable.

Template audit

Review and document existing email templates, overrides, and directive usage

Custom sender

Custom TransportBuilder plugins, variable providers, and layout XML for header/footer

Testing setup

MailHog integration and CLI preview commands for risk-free local testing