Notification Template Variables
Exactly what a notification template can print, and which of the three layers each name comes from. Also which of them silently overwrite anything you set yourself.
Overview
A template receives one flat set of names. It is assembled from three sources in a fixed order. Knowing which source a name comes from answers the two questions that actually come up. Why did a placeholder come out empty, and why was the value you passed ignored?
Structure
Assembly Order
The event layer is assembled first and the other two are laid over it, not under it. That is the source of nearly every surprise in this article.
the resolver entity -> the event's variables (yours)
↓
View::notifications adds template_name and template_type
↓
variables_handler adds the recipient block (user id > 0 only)
adds the installation block (always)
↓ ^ both OVERWRITE what is already there, with four exceptions
the engine renders the body with the merged set
↓
the rendered body becomes notifi_body
↓
the engine renders the shell, then the subject, with the same set
Declared Versus Injected
Two lists exist and they are not the same list. One drives the badges an operator sees while editing a template; the other is what actually arrives. Neither is a filter: a name absent from both still prints if the resolver set it.
| List | Built by | Used for | Effect at build time |
|---|---|---|---|
| the platform sets | Notification::variables() | the editor's badge list | none |
| the group baseline | Notification::group_variables() | the editor's badge list | none |
| the entry's own list | the variables key in the configuration entry | the editor's badge list | none |
| what is actually injected | the resolver, then the build call | building the message | everything |
Because the first three are documentation rather than mechanism, they drift. Measured against the shipped code: the recipient set declares seventeen names while nineteen are injected. The installation set declares one name that the build call adds separately. The gaps are listed with the tables below.
Reference
The Signatures
// coremio/helpers/notification.php
// $type is 'system' | 'user'. Anything else returns $variables with the braces
// stripped, which is how the configuration entry's CSV becomes a badge list.
public static function variables(string $type = '', array $variables = []): array;
// The group baseline the editor shows: 'invoice' | 'order' | 'service' | 'domain'
// | 'user-tickets' | 'admin-tickets'. Every other group returns an empty array.
public static function group_variables(string $group = ''): array;
// coremio/classes/View.php
public static function notifications($type = 'mail', $template_name = '', $content = '',
$variables = [], $lang = '', $user = 0): array;
// Merges the two platform layers into $variables, then renders $str IN PLACE.
// $str is by reference: it is both the template source and the result.
public static function variables_handler($type, $user_id = 0, $variables = [], &$str = '', $lang = ''): void;
// coremio/classes/TemplateEngine.php
// $engine is 'smarty' | 'twig' | 'none'. Returns the ORIGINAL string on any
// compile error, so a failure looks like a template that did nothing.
public static function render_notification($engine, $content, $variables = []): string;
The Installation Layer
Twenty two names are declared and all of them are injected every time. One more is added by the build call and never declared.
| Name | Holds | Worth knowing |
|---|---|---|
website_url | The installation address. | Rewritten to a secure scheme when the installation forces one. |
website_domain | The host on its own, with no scheme. | For prose, not for building a link. |
website_title | The site title, in the message language. | Comes from the website translations, not from the company details. |
company_name | The legal name. | Falls back to the first line of the information block when unset. |
website_infos | The multi-line information block. | Line breaks are converted to markup for mail and left alone for text messages. |
website_address | The postal address, per language. | A language specific address overrides the general one. |
website_emails | Public addresses, joined into one string. | Already a string, not a list: it cannot be looped. |
website_phones | Public numbers, joined into one string. | Same shape as the addresses above. |
website_contact_url | The contact page, in the message language. | Localised per recipient, so it differs between two rows of one dispatch. |
support_link | The ticket creation page. | Pair it with the flag below before printing it. |
is_enable_support | Whether ticketing is on. | A boolean, for a condition. The shipped footer hides its whole contact block behind it. |
website_header_logo | The site's light logo. | An absolute address. |
website_footer_logo | The site's dark logo. | An absolute address. |
notifi_header_logo | The mail specific logo. | Falls back to the site logo. A vector file is swapped for a raster one when it exists, because mail clients cannot draw vectors. |
notifi_footer_logo | The mail specific dark logo. | Same fallback and the same substitution. |
theme_color1 | The primary colour. | Digits only, with no leading marker: the template writes the marker itself. |
theme_color2 | The secondary colour. | Same shape. |
theme_text_color | The body text colour. | Same shape. |
social_links | A list of social profiles. | A list of rows, see the shapes below. Empty when none are configured, so guard the loop. |
current_year | The year, four digits. | For a copyright line, so it never goes stale. |
template_name | The event, as group/name. | Set by the build call itself. |
notifi_body | The finished event body. | Declared here, but it only exists once the body is built. Usable in the shell, empty in an event body. |
template_type | The channel: mail or sms. | Injected but not declared, so it never appears in the editor's badge list. Lets one shared partial branch on the channel. |
The Recipient Layer
Present only when the build was given a user id. Seventeen names are declared; two more are injected without being declared.
| Name | Holds | Worth knowing |
|---|---|---|
user_greeting_name | The company name if there is one, otherwise the full name. | The right one to open a message with. Deferred: a resolver that already set it wins. |
user_full_name | First and last name. | Deferred to the resolver. |
user_name | First name. | Deferred to the resolver. |
user_surname | Last name. | Deferred to the resolver. |
user_company_name | The company name, empty for an individual. | Overwritten unconditionally. |
user_email | The account address. | The address on the account, not necessarily the one this copy is going to. |
user_phone | The phone, prefixed when present. | Null rather than empty when the account has none. |
user_id | The account id. | Useful in a reference line, meaningless to a customer on its own. |
user_group | The customer group name. | Empty when the account is in no group. |
user_country | Country name from the primary address. | Null when there is no address on file. |
user_city | City. | Same source and same caveat. |
user_state | State or province. | Same source and same caveat. |
user_address | Street address. | Same source and same caveat. |
user_zipcode | Postal code. | Same source and same caveat. |
user_ip | The address recorded on the account. | Registration time, not the address of whatever triggered this message. |
user_login_link | The sign-in page, in the message language. | Localised per recipient. |
user | The whole account row plus its address. | A collection, see the shapes below. Reach for a named variable first. |
admin_login_link | The panel sign-in page. | Injected but not declared. For staff copies; do not print it in a customer facing body. |
user_pass | Five asterisks. | Injected but not declared, and a mask rather than a value. It exists so an older template that prints it shows a mask instead of a blank. |
The Event Layer, by Group
What the group's resolver builds before it looks at the event name. Only six groups declare a baseline; the rest build their set entirely inside the event's own case.
invoice, invoice_idn, invoice_payment_link, invoice_subtotal, invoice_total, invoice_tax_rate, invoice_tax, invoice_date_created, invoice_date_due, invoice_date_paid, invoice_date_taxed, invoice_payment_method, invoice_remaining_day, invoice_delayed_day, invoice_refund_date, invoice_cancelled_date, legal_invoice_download_link, items. Amounts arrive already formatted with their currency symbol, so do not format them again. The last four are set by their own events only.
order, order_id, order_number, order_name, order_amount, order_currency, order_payment_method, order_status, order_detail_link, order_date_created, order_date_start, order_date_end, order_period, order_period_unit, order_group_name, order_category_name, order_services_summary, items. The item collection has a different shape from the invoice one: one row per purchased product, with its add-ons nested.
service, service_id, service_order_id, service_name, service_type, service_module, service_subscription_identifier, service_period, service_period_unit, service_period_time, service_cycle, service_amount, service_date_created, service_date_start, service_date_end, service_detail_link, service_group_name, service_category_name, service_domain, service_ip, service_requirements, service_addons, service_server_ip, service_server_hostname, service_server_port, service_ns1, service_ns2, service_ns3, service_ns4, service_username, service_password, service_assigned_ips. The last group of names are live access details, including a decrypted password: see the pitfalls.
domain, day, grace_days, redemption_days, redemption_fee, days_past_due, domain_transfer_code, reason. A domain is a service, so its templates can print any service name as well.
ticket, ticket_id, ticket_num, ticket_link, ticket_subject, ticket_department, ticket_service, ticket_status, ticket_priority, ticket_admin_name, ticket_date, ticket_last_reply_date, ticket_assigned_by_admin, user_last_message, admin_last_message, user_reply, admin_reply, plus the entire service set when the ticket is attached to one. The link differs by group: the staff set points into the panel, the customer set into the portal.
Collections and Their Keys
Four of the names are not strings. Printing one directly shows nothing useful; these are for a loop or for a keyed read.
| Name | Shape | Keys on each row |
|---|---|---|
items (invoice) | rows, one per invoice line | id, owner_id, user_id, user_pid, description, quantity, amount, total_amount, currency, rank, amountF, service_id, service_domain, service_ip, service_group_name, service_category_name. A renewal line also carries service_type, service_old_duedate, service_new_duedate. |
social_links | list of rows | name, url, icon. The shipped shell builds its image file name from the lower-cased name. |
user | one row | The account columns, plus address holding the primary address. Every field worth printing already has a named variable. |
service_addons and service_requirements | lists of rows | The stored rows as they are, unformatted. Loop them only when the template really has to itemise; there is no ready formatting behind them. |
A formatted amount ends in a capital F on the invoice item rows. The bare key is the raw number; the one ending in F is the string to print. Getting that pair the wrong way round prints an unformatted figure with no currency on it.
Per Event Extras
Beyond the baseline, a resolver's case adds what only that event needs. A representative sample, with what the caller has to pass for each.
| Event | Adds | Fed by the context key |
|---|---|---|
user/two-factor-verification | code | code |
user/email-activation | activation_code, activation_link | code, activation_link, optional to_email to redirect the message |
user/email-changed | old_email, new_email plus the device block | old, new, optional to_email |
user/password-changed | reset_password_link plus the device block | reset_link |
invoice/invoice-reminder | invoice_remaining_day | remaining_day |
invoice/invoice-overdue | invoice_delayed_day | delayed_day |
invoice/invoice-auto-payment-failed | error_message, card_ln4 | error_message, card_ln4 |
admin-messages/backup-completed | whatever the caller built | variables, forwarded verbatim |
The device block referred to above is a small fixed set added to the security events. It carries browser, platform, ip, location_country, location_city and date. The two location names are placeholders and are always empty, so a template that prints them prints nothing.
Example
All three layers in one fragment, and then the two supported ways to add a name of your own.
{* recipient layer *}
Dear {$user_greeting_name},
{* event layer: already formatted with its currency, so it is printed as-is *}
Invoice {$invoice_idn} for {$invoice_total} is due on {$invoice_date_due}.
{* event layer, a collection: amountF is the formatted string, amount is the number *}
{foreach from=$items item=item}
- {$item.description} {$item.amountF}
{if $item.service_domain != ""}({$item.service_domain}){/if}
{/foreach}
{* installation layer, guarded because the list can be empty *}
{if $is_enable_support}Questions: {$support_link}{/if}
{$company_name} {$current_year}
// coremio/helpers/notification.php, in the group's resolver.
// Do not reuse a platform name: user_email and website_url are written after
// this runs and would overwrite whatever you put there.
switch ($name) {
case 'acme-quota-reached':
$variables['quota_percent'] = (int) ($context['percent'] ?? 0);
$variables['quota_limit'] = Money::formatter_symbol(
(float) ($context['limit'] ?? 0),
(int) ($service['amount_cid'] ?? 0),
);
break;
}
// Runs once per recipient, right before that recipient's body is rendered, so it
// can also vary the value per recipient. The first argument is by reference.
Hook::add('filter:notification.render_variables', 1, function (&$variables, $group, $name, $recipient) {
if ($group !== 'service') return;
$variables['acme_portal_link'] = 'https://portal.example.com/s/' . (int) ($variables['service_id'] ?? 0);
});
// Editor only: puts the name in the badge list the operator sees while editing.
// It changes nothing at render time, so both listeners are needed for a name that
// is meant to be discoverable as well as printable.
Hook::add('filter:notification.template_merge_fields', 1, function (&$fields, $group, $name) {
if ($group === 'service') $fields[] = 'acme_portal_link';
});
Pitfalls
The service set carries the stored credential decrypted, next to the server address, the port and the username. Printing it puts a live login into an inbox and into the message log permanently. Link to the service page instead, and keep the credential for the single message whose whole purpose is to deliver it.
Both platform layers are laid over the resolver's set, and only the four recipient display names defer to what is already there. Set an address, a link or a colour under a platform name and it is overwritten before the template ever sees it. Nothing is logged.
There is no warning and no marker in the output. A misspelt placeholder looks exactly like a value that happened to be empty. When a line disappears from a message, check the spelling against the tables here before looking at the resolver.
The badge list an operator sees is assembled from three static declarations that nothing verifies against the resolver. A name can be missing from it and still print, and it can be listed and never arrive. Trust what the resolver sets, and update the declarations so the operator can trust them too.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.