Magento 2 Custom Email Templates: Declare, Send and Override
Sooner or later every Magento project sends an email the core does not have: a quote request confirmation, a back in stock alert, a notification to the warehouse. Doing it right means more than calling PHP's mail function. The template should be translatable, editable by the merchant in the admin, styled like the other store emails, and sent with the correct store sender.
This article covers custom email templates in Magento Open Source and Adobe Commerce 2.4.8: declaring the template, writing it in the current template syntax, sending it with TransportBuilder, letting admins override it, and overriding core templates in a theme.
Declaring the template
Templates are registered in etc/email_templates.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Email:etc/email_templates.xsd">
<template id="vendor_quote_email_confirmation"
label="Quote Request Confirmation"
file="quote_confirmation.html"
type="html"
module="Vendor_Quote"
area="frontend"/>
</config>
With area="frontend", Magento looks for the file in view/frontend/email/quote_confirmation.html of the module, or in the active theme if it overrides it. The label is what admins see when they load the default template.
The id is not arbitrary if you want admins to choose a template in the configuration. I will come back to that below.
Writing the template
<!--@subject {{trans "Your quote request at %store_name" store_name=$store.frontend_name}} @-->
<!--@vars {
"var customer_name":"Customer Name",
"var request_id":"Request ID",
"var store.frontend_name":"Store Name"
} @-->
{{template config_path="design/email/header_template"}}
<p class="greeting">{{trans "Hello %name," name=$customer_name}}</p>
<p>{{trans "We received your quote request #%id and will answer within one business day." id=$request_id}}</p>
{{if comment}}
<p>{{trans "Your message:"}}</p>
<p>{{var comment}}</p>
{{/if}}
<p>{{trans 'You can see your requests in <a href="%url">your account</a>.' url=$this.getUrl($store,'customer/account/',[_nosid:1]) |raw}}</p>
{{template config_path="design/email/footer_template"}}
A few notes on the syntax:
- The
@subjectcomment becomes the subject line. The@varscomment only feeds the "Insert Variable" dialog in the admin. It does not limit what you can pass. {{trans}}makes text translatable through the normal CSV dictionaries, with named placeholders. See translations and i18n.- Header and footer come from the design configuration, so your email matches the store's other emails and picks up their logo and styles.
{{if}}and{{depend}}hide blocks when a variable is empty.{{for}}loops over arrays.{{var}}output is escaped. Use therawmodifier only for HTML you built and trust.
Strict mode
Since 2.3.4, templates are processed in strict mode. You can read data from variables, including DataObject getters like $store.frontend_name, but you cannot call arbitrary methods on objects from the template. Older tutorials with {{var order.getSomething()}} do not work for new templates. Helpers on the template filter itself, like the $this.getUrl() call in the example above, are allowed and are used the same way in core templates. Templates saved in the database before the change may be marked as legacy and still use the old behavior until they are edited.
The practical rule: prepare the data in PHP and pass simple values or arrays. It is also safer, because a template edited in the admin cannot run code it should not.
Sending the email
<?php
declare(strict_types=1);
namespace Vendor\Quote\Model;
use Magento\Framework\App\Area;
use Magento\Framework\App\Config\ScopeConfigInterface;
use Magento\Framework\Mail\Template\TransportBuilder;
use Magento\Framework\Translate\Inline\StateInterface;
use Magento\Store\Model\ScopeInterface;
use Magento\Store\Model\StoreManagerInterface;
class ConfirmationSender
{
private const XML_PATH_TEMPLATE = 'vendor_quote/email/confirmation';
private const XML_PATH_IDENTITY = 'vendor_quote/email/identity';
public function __construct(
private readonly TransportBuilder $transportBuilder,
private readonly StateInterface $inlineTranslation,
private readonly ScopeConfigInterface $scopeConfig,
private readonly StoreManagerInterface $storeManager
) {
}
public function send(string $email, string $name, string $requestId, int $storeId, string $comment = ''): void
{
$store = $this->storeManager->getStore($storeId);
$templateId = (string) $this->scopeConfig->getValue(self::XML_PATH_TEMPLATE, ScopeInterface::SCOPE_STORE, $storeId);
$identity = (string) $this->scopeConfig->getValue(self::XML_PATH_IDENTITY, ScopeInterface::SCOPE_STORE, $storeId);
$this->inlineTranslation->suspend();
try {
$transport = $this->transportBuilder
->setTemplateIdentifier($templateId)
->setTemplateOptions(['area' => Area::AREA_FRONTEND, 'store' => $storeId])
->setTemplateVars([
'customer_name' => $name,
'request_id' => $requestId,
'comment' => $comment,
'store' => $store,
])
->setFromByScope($identity ?: 'general', $storeId)
->addTo($email, $name)
->getTransport();
$transport->sendMessage();
} finally {
$this->inlineTranslation->resume();
}
}
}
Some details that matter:
- Store id in the template options. It decides the locale, the design and the store URLs. Without it, an email sent from cron or the admin can come out in the wrong language with admin URLs.
- Inline translation suspended. If inline translation is enabled for debugging, it would otherwise inject its markup into the email.
- Sender by scope.
setFromByScope()takes an identity code such asgeneral,salesorsupportand resolves the name and email from Stores, Configuration, General, Store Email Addresses. - Errors.
sendMessage()throwsMagento\Framework\Exception\MailExceptionon failure. Catch it at the caller, log it, and do not let a mail server problem break the request that triggered the email.
Sending many emails
For a single confirmation, sending in the request is fine. For a batch, like a reminder to every customer with an open quote, do not loop over thousands of recipients in a controller or a single cron run. Publish one message per recipient to a queue and send from a consumer, so a slow mail server or one bad address does not stop the whole batch, and failed messages can be retried. The same applies to anything triggered by a mass action in the admin.
Also note that TransportBuilder in 2.4 has no public method for attachments. If you need to attach a file, such as a PDF quote, you need your own builder or a plugin that adds a MIME part to the message. Check how your mail transport or SMTP extension handles it before choosing.
Letting admins choose the template
Merchants expect to edit emails in Marketing, Communications, Email Templates, load your default, change it, and then select their version in the configuration. Add a field in system.xml:
<field id="confirmation" translate="label" type="select" sortOrder="10" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Confirmation Email Template</label>
<source_model>Magento\Config\Model\Config\Source\Email\Template</source_model>
</field>
<field id="identity" translate="label" type="select" sortOrder="20" showInDefault="1" showInWebsite="1" showInStore="1">
<label>Email Sender</label>
<source_model>Magento\Config\Model\Config\Source\Email\Identity</source_model>
</field>
And a default in config.xml:
<default>
<vendor_quote>
<email>
<confirmation>vendor_quote_email_confirmation</confirmation>
<identity>general</identity>
</email>
</vendor_quote>
</default>
Here is the detail about the id. The template source model builds its list from the config path of the field, replacing slashes with underscores. The path vendor_quote/email/confirmation becomes vendor_quote_email_confirmation, which is why the template id in email_templates.xml uses that exact name. If they do not match, the dropdown shows only custom templates and no default.
When an admin selects a database template, the config value becomes its numeric id, and setTemplateIdentifier() loads it from the database instead of the file. Your sending code does not change.
Overriding core templates in a theme
For core emails like the order confirmation, you have two options. Admin templates are easy for merchants to edit, but they live only in the database and are easy to lose between environments. Theme files live in git. To override in the theme, copy the file to the same module folder inside your theme:
mkdir -p app/design/frontend/Vendor/theme/Magento_Sales/email
cp vendor/magento/module-sales/view/frontend/email/order_new.html \
app/design/frontend/Vendor/theme/Magento_Sales/email/order_new.html
Keep the overridden files to a minimum and record which core version you copied from. After each upgrade, compare them with the new core files, because core templates sometimes change variables. The upgrade checklist has a step for this.
Email styles come from email.less and email-inline.less in the theme's web/css folder. The header template loads them with the {{css}} and {{inlinecss}} directives, and inline styles are applied when the email is generated, because many mail clients ignore style tags.
Testing emails
Never test with real mail delivery from a development environment. Run a catcher like Mailpit or MailHog and point Magento at it. Docker setups like Warden include one, as described in the local Docker environment guide. Test each store view, since the locale and design depend on the store id, and test with a long name and an empty optional field.
For sales emails, also check the "Asynchronous sending" setting under Stores, Configuration, Sales, Sales Emails, General Settings. With it enabled, order emails are sent by cron instead of during the request, which is faster for the customer but means no cron, no email.
FAQ
Why does my custom email template not appear in the configuration dropdown?
The template id in email_templates.xml must match the config path of the field with slashes replaced by underscores. Also check that the module is enabled and the cache is clean.
Why are my template variables empty?
Strict mode does not allow arbitrary method calls in templates. Prepare the values in PHP and pass them in setTemplateVars, then read them with var.
Should I edit core email templates in the admin or in the theme?
Theme files are versioned and deploy the same everywhere. Admin templates are easier for merchants. Many teams use the theme for structure and let merchants change text in the admin, but pick one owner per template.
Why does the email come out in the wrong language?
The store id is missing or wrong in setTemplateOptions. Pass the store of the customer or order, not the current store, when sending from cron or the admin.
I am available for freelance Magento and Adobe Commerce work, including transactional email changes. If you need help with this, get in touch.
Related articles
Writing Alpine.js Components in Hyvä: Patterns That Scale
How to write Alpine.js components on Hyvä: passing PHP data, view models, customer data events, form keys, component events, CSP and keeping templates clean.
Hyvä Compatibility Modules: Make Luma Extensions Work
How Hyvä compatibility modules work: the CompatModuleRegistry, rewriting extension templates with Alpine.js, hyva_ layout handles and the Luma fallback.
Hyvä Tailwind Customization: Colors, Fonts and Safelists
Customize Tailwind CSS in a Hyvä theme: content paths, brand colors and fonts in v3 and v4, @apply components, CMS classes, safelists and keeping CSS small.
Hyvä Theme Setup Guide for Magento 2.4.8: Install to Deploy
Step by step Hyvä setup on Magento 2.4.8: Composer install, child theme, Tailwind build, hyva:config:generate, deploy order and the usual first project issues.