The platform has two related notification layers:
ntf_notifications) — the bell icon, unread badge,
and /notifications page. Created with Notification::create_notification().signals.json entry carries a
notify block. Notify ignores signals without one. All current notifiable
signals are topic-based admin alerts (e.g. comment.posted,
purchase.completed).recipients payload key. Supported but unused by current signals; reserved for
future user-facing notifications.Two steps.
notify blockAdd a notify block to the signal's entry in signals.json (plugins use the
signals key in plugin.json, same shape). The signal's identity and payload
schema are declared in the same entry — see Signal Bus:
"comment.posted": {
"label": "New comment posted",
"description": "A new comment was posted on a blog post.",
"category": "Content",
"payload": {
"post_title": "Blog post title",
"post_url": "Blog post URL",
"comment_excerpt": "Comment body excerpt",
"author_name": "Comment author display name",
"source_user_id": "Acting user id (the commenter)"
},
"notify": {
"ntf_type": "comment",
"supports_topic": true,
"default_email": true,
"title_template": "New comment by {author_name}",
"body_template": "On \"{post_title}\": {comment_excerpt}",
"link_template": "{post_url}"
}
}notify key | Meaning |
|---|---|
ntf_type | Display type / icon for the in-app notification |
supports_topic | Whether users may opt in to it |
default_email | Default state of the "also email me" toggle when subscribing |
title_template | Notification title (also the email subject) |
body_template | Notification body (also the email body) |
link_template | URL opened when the notification is clicked |
label, description, and category (used by the preferences UI) live at
the top level of the entry, alongside the payload schema.Dispatch through the bus where the fact occurs — Notify is invoked automatically:
require_once(PathHelper::getIncludePath('includes/SignalBus.php'));
SignalBus::dispatch('comment.posted', array(
'post_title' => $post->get('pst_title'),
'post_url' => $post->get_url(),
'comment_excerpt' => mb_substr(strip_tags($comment_body), 0, 180),
'author_name' => $commenter_name,
'source_user_id' => $commenter_id,
));Notify renders title_template / body_template / link_template against the
payload. Substitution is deliberately dumb: {field} is replaced with the
payload value as plain text. A missing or null field substitutes the empty string
and logs once. There are no conditionals, formatting, or modifiers — anything
fancier is a derived payload field computed at the call site (e.g.
comment_excerpt is already truncated). Because templates reference payload field
names, they are versioned with the code, not exposed for live admin editing.
For each notifiable signal, Notify builds the recipient set from any targeted
recipients plus every topic subscriber (users with a NotificationPreference
row where ntp_subscribed = true), de-duplicates, and drops source_user_id so
nobody is notified of their own action.
Per recipient:
| Recipient | In-app | |
|---|---|---|
| Targeted, no preference row | yes | signal's default_email |
Targeted, muted (ntp_subscribed = false) | skipped | — |
| Targeted, subscribed | yes | their ntp_email_enabled |
| Topic subscriber | yes | their ntp_email_enabled |
Notification::create_notification() — a cheap insert.equ_queued_emails row with
READY_TO_SEND status; the SendQueuedEmails scheduled task delivers it on its
next run. This keeps email out of latency-sensitive paths like checkout. No
notification email is time-critical.Admins manage their own subscriptions at
/admin/admin_notification_preferences. Each notifiable signal can be subscribed
to, and optionally flagged "also email me." Preferences are stored one row per
(user, signal) in ntp_notification_preferences keyed by ntp_signal_name; the
load/save logic (adm/logic/admin_notification_preferences_logic.php) is
page-object-agnostic so a future user-facing preferences page can reuse it.
Mandatory transactional emails — purchase receipts, password reset, account
activation — are not notifications. They are required, non-opt-out messages
sent directly via EmailSender. Only opt-in notifications go through Notify.