Signal Bus
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.
Concepts
- Signal — a named fact that already happened, e.g.
purchase.completed. Names follow anoun.verbtaxonomy. Declared once insignals.json, dispatched wherever the fact occurs. Signals are immutable facts: a subscriber cannot veto a signal or mutate its payload for other subscribers. - Structured payload — a flat associative array of JSON-serializable scalars (entity ids, names, amounts, ISO-8601 UTC times). Never objects, resources, or pre-rendered HTML. The payload is the machine-readable record; every consumer reads it directly.
- Subscriber — a class with a static handler
($signal, $payload), registered insignal_subscribers.json(core) or a plugin'ssignalSubscriberskey. A subscriber matches exact signal names or*(everything).
The dispatch contract
SignalBus::dispatch(string $signal, array $payload = array()): void
- Never throws into the caller. The whole dispatch and each subscriber are wrapped in try/catch; one failing subscriber is logged and the rest still run.
- Synchronous and ordered. Handlers run inline — core subscribers in
signal_subscribers.jsonfile order, then plugin subscribers in active-plugin load order. No priority system. - Session-less safe. Dispatch happens from web requests, webhook endpoints,
and CLI scheduled tasks. Neither the bus nor any handler reads the session;
everything a handler needs is in the payload (
source_user_idis an explicit payload key, never$_SESSION). - Post-commit only. Where the producing operation runs inside a DB transaction (checkout), dispatch after the transaction commits. Subscribers may assume the fact is final.
- Undeclared signals dispatch anyway. Dispatching a signal missing from the catalog logs a warning and still invokes matching subscribers. The catalog is the contract for consumers enumerating signals, not a dispatch gate.
- Lazy loading. A subscriber's file is
require_onced only when one of its signals actually fires.
Handler cost budget
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.
Debugging
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.
Payload conventions
- Entity references are
<entity>_idintegers: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.- Times are ISO-8601 UTC strings (
gmdate('Y-m-d H:i:s')). - Human-derived convenience fields (
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. - Money is a decimal-string
amountplus a separatecurrency.
Declaring signals: signals.json
signals.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": "/admin/admin_orders"
}
}payloadis 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 undersignal_bus_debug.notifyis consumed only by Notify — see Notifications. A signal with nonotifyblock 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.Registering subscribers
Core: signal_subscribers.json
An 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 topublic_html/, resolved throughPathHelper::getIncludePath()and required lazily on first matching dispatch.class/method— a static method(string $signal, array $payload): void.methoddefaults tohandle.signals— array of exact names or"*"(everything).
Plugins: signalSubscribers in plugin.json
Same 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.
How to add a signal
- Declare it in
signals.json(identity +payloadschema). - Dispatch it where the fact occurs:
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'),
));- Optionally make it notifiable by adding a
notifyblock to its entry — see Notifications.