The signal bus is the platform's canonical "something happened" primitive.
SignalBus::dispatch($signal, $payload) records a structured fact at the point
it occurs and fans it out to every registered subscriber — notifications,
(future) outgoing webhooks, email workflows, analytics, and plugin handlers.
Each subscriber derives its own presentation and side effects from the payload.
Notifications are the first subscriber; see Notifications for that consumer specifically.
purchase.completed.
Names follow a noun.verb taxonomy. Declared once in signals.json, dispatched
wherever the fact occurs. Signals are immutable facts: a subscriber cannot veto
a signal or mutate its payload for other subscribers.($signal, $payload),
registered in signal_subscribers.json (core) or a plugin's signalSubscribers
key. A subscriber matches exact signal names or * (everything).SignalBus::dispatch(string $signal, array $payload = array()): void
signal_subscribers.json file order, then plugin subscribers in active-plugin
load order. No priority system.source_user_id is an explicit
payload key, never $_SESSION).require_onced only when one of its
signals actually fires.Handlers run inline in the producing request — including checkout. Inline work
must be bounded to cheap local writes (an insert into your own queue/work table).
Network calls, bulk sends, and heavy queries belong in a scheduled-task drain.
Notify models this: it inserts the in-app notification row inline and enqueues
email to equ_queued_emails for the SendQueuedEmails task to deliver.
The core setting signal_bus_debug (default off) makes the bus log each dispatch
(signal name + JSON payload) to the error log and flag any non-JSON-serializable
payload value. It is debug-only, so production dispatch never pays for the check.
Keeping payloads JSON-serializable is the single most important discipline —
it is what lets webhooks and email workflows ship the payload as-is.
<entity>_id integers: user_id, order_id,
product_id, event_id, comment_id. A handler needing more loads the model
by id.source_user_id — the user whose action caused the signal, when there is one.
Consumers use it for "don't notify the actor" logic and attribution.gmdate('Y-m-d H:i:s')).buyer_name, product_name,
comment_excerpt) are encouraged where every consumer would otherwise
re-derive them, but they supplement the ids, never replace them. Compute them
at the call site, which already holds the loaded models.amount plus a separate currency.signals.jsonsignals.json at the public_html/ root declares every core signal. Plugins
declare their own signals under a signals key in plugin.json, same shape.
SignalBus::signals() merges core + active plugins and caches per request.
Each entry declares identity (label, description, category), the payload
schema, and optionally a notify block (read only by the Notify subscriber — the
bus never looks at it):
"purchase.completed": {
"label": "Sale completed",
"description": "A purchase or order was completed successfully.",
"category": "Orders",
"payload": {
"order_id": "Order id",
"user_id": "Buyer user id",
"product_name": "Product display name",
"amount": "Order total, decimal string",
"currency": "Configured site currency"
},
"notify": {
"ntf_type": "order",
"supports_topic": true,
"default_email": true,
"title_template": "Sale completed: {product_name}",
"body_template": "Order #{order_id} completed.",
"link_template": "/plugins/store/admin/admin_orders"
}
}payload is an advisory schema: field name → one-line description. It is
documentation and a merge-field source for consumer UIs. It is not validated at
dispatch except under signal_bus_debug.notify is consumed only by Notify — see
Notifications. A signal with no notify block produces no
notifications; it exists for other subscribers.ntp_signal_name preference rows, and future webhook
subscriptions. To rename, introduce the new name and deprecate the old.signal_subscribers.jsonAn ordered map of subscriber name → declaration at the public_html/ root:
{
"notify": {
"file": "includes/Notify.php",
"class": "Notify",
"method": "handle_signal",
"signals": ["*"]
}
}file — path relative to public_html/, resolved through
PathHelper::getIncludePath() and required lazily on first matching dispatch.class / method — a static method (string $signal, array $payload): void.
method defaults to handle.signals — array of exact names or "*" (everything).signalSubscribers in plugin.jsonSame shape, with file relative to the plugin directory. Read for active plugins
only, merged after core. This is the plugin extension seam: a plugin reacts to a
core signal by declaring a subscriber — no core edits. Plugins may also declare
and dispatch their own signals.
The signal catalog and subscriber registry are code-bound developer config —
they ship in the repo, are edited in source, and deploy through the upgrade
pipeline (like admin_menus.json, theme.json, plugin.json). Never hand-edit
signals.json / signal_subscribers.json on a deployed site; a site needing its
own signals or subscribers ships a plugin. Runtime-mutable state (per-user
preferences, future webhook URLs) lives in DB tables, never in these files.
signals.json (identity + payload schema). require_once(PathHelper::getIncludePath('includes/SignalBus.php'));
SignalBus::dispatch('comment.posted', array(
'comment_id' => $comment->key,
'post_id' => $post->key,
'post_title' => $post->get('pst_title'),
'post_url' => $post->get_url(),
'comment_excerpt' => mb_substr(strip_tags($comment_body), 0, 180),
'author_name' => $comment->get('cmt_author_name'),
'source_user_id' => $comment->get('cmt_usr_user_id'),
));notify block to its entry — see
Notifications.