How Notification Templates Work
Every mail and text message the platform sends is built from three files on disk, one set per event and language. A template author owns those files the same way a theme author owns a view.
Overview
A notification is not written in code. Code decides that something happened and hands over the facts; the wording, the markup and the subject line live under templates/notifications. An installation can change what a customer reads without a developer, and a module can ship its own wording.
The unit is the event, written as group/name. Each event owns three files per installed language: the mail body, the text message body and a small file carrying the subject. The mail body is a fragment, not a document.
Two things reach that template. The resolver for the group turns what the caller passed (an invoice row, a service id, a ticket) into named variables. The platform then adds the installation's own values: logos, colours, company details, the recipient's profile. The template only prints.
Structure
The File Layout
Three roots, each answering a different question. A subject line and a text message read the same whatever the mail looks like, so they live outside the designs entirely. The mail body belongs to the design that produces it.
templates/notifications/
├── content/ # SHARED TEXT — one copy serves every design
│ └── en/invoice/
│ ├── invoice-created.json # {"subject": "..."}
│ └── invoice-created.txt # the text message body, no shell at all
├── themes/ # DESIGN — one directory per installed design
│ ├── aurora/ # the design that ships with the product
│ │ ├── header.html # the outermost shell
│ │ ├── content.html # the body slot, a single {$notifi_body} placeholder
│ │ ├── footer.html # the closing shell
│ │ └── en/invoice/
│ │ └── invoice-created.html # the mail body, a FRAGMENT of the shell
│ └── ledger/ # a second design; may carry only some events
├── custom/ # the operator's own edits, one tree per design
│ └── ledger/en/invoice/invoice-created.html
└── .htaccess # nothing in this tree is served over HTTP
Never build one of these paths by hand; the helpers below are the only place that knows the order. A design carrying no file for an event still sends the mail. The body comes from the base design; the shell stays its own.
custom/{active} → themes/{active} → themes/aurora. The first file that exists wins.
content/{lang} — no design takes part.
custom/{active} for any design other than the base one. Deleting that file restores the default.
The Shell
A mail body is never sent on its own. The platform concatenates three files into one shell. It assigns the finished event body to notifi_body and builds the shell with the same variables. The shell resolves through the designs in the same order as the body. A design that ships none frames its mails with the base one.
content.html in each language is a single placeholder, so the frame is really the header and the footer.
.txt body is delivered exactly as written, with no logo, footer or contact block.
.json file's subject goes through the same engine and the same variables, so it can carry placeholders. It is read for the mail channel only.
Groups and Resolvers
A group is a directory and a section of the configuration file at once. It decides which resolver builds the variables and which notification category the recipient's preferences are checked against.
| Group | Events shipped | Resolver | Preference category |
|---|---|---|---|
invoice | 10 | resolve_invoice_context | invoices |
service | 11 | resolve_service_context | product |
order | 4 | resolve_order_context | product |
domain | 13 | resolve_domain_context | domain |
user | 36 | resolve_user_context | general |
user-tickets | 8 | resolve_ticket_context | support |
admin-tickets | 4 | resolve_ticket_context | support |
admin-messages | 18 | resolve_admin_message_context | general |
sms-intl | 3 | resolve_sms_intl_context | product |
newsletter | 2 | none | not dispatched, see below |
license-transfer | 3 | none | not registered, see below |
The last two rows are the two ways an event can exist outside the normal path. newsletter has configuration entries but no resolver, so a dispatch would answer error. Its templates are built directly and pushed onto the queue by the newsletter code, which supplies the variables itself. license-transfer is the opposite: three events sit on disk with no configuration section at all. They are sent through the low-level entry point, which falls back to mail-and-text-on when it finds no settings.
The Delivery Path
From a single call to an actual message the chain is fixed, and every step can end it.
Notification::dispatch('invoice', 'invoice-created', ['entity' => $invoice])
1. gate:notification.dispatch a listener may veto -> blocked
2. read notifications/invoice/invoice-created -> disabled if absent or off
3. run the group's resolver: entity -> variables + user_id -> error if it returns nothing
4. check the recipient's preference bitmask for the category -> opted_out
5. build the recipient list (owner, extra addresses, admins) -> no_recipients if empty
6. render per recipient, in THAT recipient's language and channel
7. deliver now if the event is in the sync list, otherwise queue -> sent | queued
8. write the in-app rows: the recipient's, and the admin copy
9. action:notification.dispatched
Step 6 reads the template once per recipient, not once per dispatch. Two recipients of the same event can be reading in different languages and on different channels. The synchronous list at step 7 is short and holds security-critical events; everything else waits for the queue.
Reference
The Two Entry Points
public static function dispatch(string $group, string $name, array $context = []): array;
public static function send(array $params): array|string|bool;
public static function get_recipients(string $group, string $name, array $context = []): array;
status says what happened.
What a Dispatch Returns
Never a boolean. The array always carries status, and a successful one also carries batch_id and one entry per recipient under items.
| status | Meaning | Where it is decided |
|---|---|---|
queued | Rows written to the queue, delivery follows within a minute. | normal path |
sent | Delivered inline, because the event is in the synchronous list or the caller forced it. | normal path |
blocked | A listener vetoed the event. | the dispatch gate hook |
disabled | The event has no configuration entry, or its switch is off. This is what an unregistered event answers. | the configuration file |
error | The group has no resolver, or the resolver returned nothing (unknown invoice, deleted user). | the resolver map |
opted_out | The recipient's preference bitmask excludes the group's category. | the recipient's profile |
no_recipients | Nobody left after the channel switches and per-channel preferences were applied. | the recipient builder |
The Template Engine
Which engine parses the files is an installation-wide setting, read from options/notification-template-engine. It ships as Smarty, and every template in the tree is written for it. Two other values exist: Twig, and a plain string replacement mode that understands nothing but {name}.
It is not the theme sandbox and does not behave like one:
The Queue
Pitfalls
The path is built from the recipient's language with no fallback. If the file is not there nothing is produced and the recipient is skipped. The dispatch still reports success for everyone else. Measured in the shipped tree: eight events exist in English and are absent in German.
Compilation is all or nothing, and a failure is caught and answered with the raw source. A stray placeholder does not fail on its own line. Every other placeholder in the file ships unrendered too, and the customer receives a message full of visible braces. The warning goes to the log, not to the operator.
The base design ships with the product. An edit made while it is active is written straight over the shipped file, and the next update replaces it. Any other design saves into custom/, which an update cannot touch.
Adding three files gives you nothing. Without a configuration entry the switch cannot be found and the dispatch answers disabled. Without a resolver case the variables never exist, and without a label the panel lists the raw key. The trio is one of several places that have to agree.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.