Notification Template Variables

8 views Markdown

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?

the installation layer Logos, colours, company details, contact links, the current year. Added to every template of every event, whether the event asked for them or not.
the recipient layer The account the message is being built for. Present only when a user id reached the build call. That is why the same name is filled in one flow and empty in another.
the event layer What the group's resolver built from the entity, plus whatever the resolver's case for that specific event added. This is the layer you extend when you add an event.

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.

from a dispatch to a finished body
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
four names defer to you The recipient's display names are only filled when the resolver did not already set them. Those four are the full name, the first name, the surname and the greeting name. Every other name in both platform layers is written unconditionally.
no user id, no recipient block The whole recipient layer is skipped when the build is called with a user id of zero. A message aimed at an address that is not an account leaves every one of those names empty.
the body is a variable too The finished event body is assigned as a variable and the shell prints it. That name is meaningful in the shell files only. Printing it inside an event body prints nothing, because it does not exist yet.

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.

ListBuilt byUsed forEffect at build time
the platform setsNotification::variables()the editor's badge listnone
the group baselineNotification::group_variables()the editor's badge listnone
the entry's own listthe variables key in the configuration entrythe editor's badge listnone
what is actually injectedthe resolver, then the build callbuilding the messageeverything

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

exact 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;
Notification::variables() The two platform sets by name. With any other argument it becomes a small utility that strips the braces off a list. That is how the configuration entry's comma separated value turns into badges.
Notification::group_variables() The group's baseline by name. The answer for a group without one is an empty array, not an error. An unbaselined group shows a short badge list rather than a broken editor.
View::notifications() Reads the files, merges the layers, builds body, shell and subject. Everything in this article happens inside one call to it.
View::variables_handler() Where both platform layers are actually written, and where the overwrite rule lives. It works in place through its by-reference argument and returns nothing.
filter:notification.render_variables Runs once per recipient, immediately before that recipient's body is built. The way to add a name without touching a resolver, and the only one that can vary the value per recipient. The variable set arrives by reference: write into it, because the return value is ignored.
filter:notification.template_merge_fields Editor time only. Adds a name to the badge list an operator sees while editing, and has no effect on the message whatsoever. The list arrives by reference too; append to it, and the return value is ignored.

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.

NameHoldsWorth knowing
website_urlThe installation address.Rewritten to a secure scheme when the installation forces one.
website_domainThe host on its own, with no scheme.For prose, not for building a link.
website_titleThe site title, in the message language.Comes from the website translations, not from the company details.
company_nameThe legal name.Falls back to the first line of the information block when unset.
website_infosThe multi-line information block.Line breaks are converted to markup for mail and left alone for text messages.
website_addressThe postal address, per language.A language specific address overrides the general one.
website_emailsPublic addresses, joined into one string.Already a string, not a list: it cannot be looped.
website_phonesPublic numbers, joined into one string.Same shape as the addresses above.
website_contact_urlThe contact page, in the message language.Localised per recipient, so it differs between two rows of one dispatch.
support_linkThe ticket creation page.Pair it with the flag below before printing it.
is_enable_supportWhether ticketing is on.A boolean, for a condition. The shipped footer hides its whole contact block behind it.
website_header_logoThe site's light logo.An absolute address.
website_footer_logoThe site's dark logo.An absolute address.
notifi_header_logoThe 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_logoThe mail specific dark logo.Same fallback and the same substitution.
theme_color1The primary colour.Digits only, with no leading marker: the template writes the marker itself.
theme_color2The secondary colour.Same shape.
theme_text_colorThe body text colour.Same shape.
social_linksA list of social profiles.A list of rows, see the shapes below. Empty when none are configured, so guard the loop.
current_yearThe year, four digits.For a copyright line, so it never goes stale.
template_nameThe event, as group/name.Set by the build call itself.
notifi_bodyThe finished event body.Declared here, but it only exists once the body is built. Usable in the shell, empty in an event body.
template_typeThe 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.

NameHoldsWorth knowing
user_greeting_nameThe 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_nameFirst and last name.Deferred to the resolver.
user_nameFirst name.Deferred to the resolver.
user_surnameLast name.Deferred to the resolver.
user_company_nameThe company name, empty for an individual.Overwritten unconditionally.
user_emailThe account address.The address on the account, not necessarily the one this copy is going to.
user_phoneThe phone, prefixed when present.Null rather than empty when the account has none.
user_idThe account id.Useful in a reference line, meaningless to a customer on its own.
user_groupThe customer group name.Empty when the account is in no group.
user_countryCountry name from the primary address.Null when there is no address on file.
user_cityCity.Same source and same caveat.
user_stateState or province.Same source and same caveat.
user_addressStreet address.Same source and same caveat.
user_zipcodePostal code.Same source and same caveat.
user_ipThe address recorded on the account.Registration time, not the address of whatever triggered this message.
user_login_linkThe sign-in page, in the message language.Localised per recipient.
userThe whole account row plus its address.A collection, see the shapes below. Reach for a named variable first.
admin_login_linkThe panel sign-in page.Injected but not declared. For staff copies; do not print it in a customer facing body.
user_passFive 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, 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, 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, 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 The entire service set above, plus 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.
user-tickets and admin-tickets 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.
user, admin-messages, sms-intl No baseline at all. Each event's case builds exactly what its template needs. Two events in the same group can share almost no names.

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.

NameShapeKeys on each row
items (invoice)rows, one per invoice lineid, 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_linkslist of rowsname, url, icon. The shipped shell builds its image file name from the lower-cased name.
userone rowThe account columns, plus address holding the primary address. Every field worth printing already has a named variable.
service_addons and service_requirementslists of rowsThe 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.

EventAddsFed by the context key
user/two-factor-verificationcodecode
user/email-activationactivation_code, activation_linkcode, activation_link, optional to_email to redirect the message
user/email-changedold_email, new_email plus the device blockold, new, optional to_email
user/password-changedreset_password_link plus the device blockreset_link
invoice/invoice-reminderinvoice_remaining_dayremaining_day
invoice/invoice-overdueinvoice_delayed_daydelayed_day
invoice/invoice-auto-payment-failederror_message, card_ln4error_message, card_ln4
admin-messages/backup-completedwhatever the caller builtvariables, 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.

a body reading from each layer
{* 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}
adding a name to one event: the resolver's case
// 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;
}
adding a name from a module, without editing the resolver
// 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

One of these names is a working password

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.

Reusing a platform name loses your value

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.

A name that does not exist prints as nothing

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 declared list is documentation, not a contract

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.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.