Notifications
Declare a notification type, send to one user or thousands, group repeats into one notice, and hook into the queue.
Notifications owns the notice table, the bell, the email queue
and each user's preferences. Your plugin declares a type, sends, and supplies the wording.
Check Api::active() first: the classes can load on a site where the plugin is not running.
Declare a type
Extend Notifier and return the types from notificationTypes(). The settings group, the
registry entry and the user preference are all built from that one declaration. Build it
before init 105, when xpac_notifications_register fires; one built later attaches at once.
use XPACGroup\Plugin\Notifications\Notifier;
final class MyNotifications extends Notifier
{
public const THING_HAPPENED = 'my_thing_happened';
protected function notificationTypes(): array
{
return [
self::THING_HAPPENED => [
'label' => __('Something happened', '{XPAC}'),
'section' => ['id' => 'my-plugin', 'label' => __('My plugin', '{XPAC}'), 'priority' => 50],
'channels' => ['onsite' => true, 'email' => true],
'defaults' => ['onsite' => true, 'email' => true, 'user' => true],
'link' => static fn(array $data, int $userId) => get_permalink($data['postId']),
'variables' => ['postTitle' => ['label' => __('Post title', '{XPAC}'), 'sample' => 'Hello']],
'resolve' => static fn(array $data, int $userId) => ['postTitle' => get_the_title($data['postId'])],
'group' => ['mode' => 'group', 'key' => static fn(array $data) => 'post:' . $data['postId']],
'templates' => [
'bell' => __('{postTitle} has news', '{XPAC}'),
'subject' => __('News on {postTitle}', '{XPAC}'),
],
],
];
}
}Only label is required. A template path, if you give one, must be absolute and exist.
| Key | Meaning |
|---|---|
label, title, description | Admin heading, user-facing title, help text |
icon | The bell's glyph while the onsite template draws no thumbnail: a name from assets/packages/notifications/bell-icons.json, or glyph and tone (slate, blue, violet, pink, red, amber, green, teal) |
section | Settings tab and preferences group. id defaults to the sanitized label |
channels | onsite and email. A channel left out is not offered |
defaults | Admin switch defaults, and user, what a user gets before choosing |
locked | Always on. Users cannot turn it off |
essential | Account email: users cannot turn it off, and "All emails" does not stop it |
link | callable($data, $userId) returning a URL or false |
variables, resolve, sample | Editor tags, their values, and sample data for previews. A value with no tag in variables still reaches provider templates; a type without variables offers every key resolve returns. A variable whose value is a picture URL can carry picture, the picture's name, and the admin can then pick it as the bell's icon |
group | mode group or once, and a key callable |
email | delay seconds, unread_only, immediate |
templates | bell, subject, preheader (the inbox preview line), body (its blocks go in the template's Content block), action (the button label), and the PHP fallbacks onsite and email |
Without Notifier, declare from anywhere on xpac_notifications_register with
Api::define($type, $definition). It returns false when the type is taken or invalid.
Send
Inside a Notifier, sendNotice() and sendMany() send the types it owns. From anywhere
else, use Api:
use XPACGroup\Plugin\Notifications\Api;
Api::send($userId, 'my_thing_happened', ['postId' => 12], ['key' => 'post:12', 'mode' => 'group']);
Api::sendMany('my_thing_happened', $userIds, ['postId' => 12], ['key' => 'post:12']);send() returns the row id, or null when nothing was stored: the admin turned both channels
off, the user opted out, or a filter refused. sendMany() returns stored, skipped and
rows. $recipients is a list of user ids, or user id => ['data' => [], 'count' => 1, 'amount' => null]
for per-user values. Work is done in batches of 500 with a fixed number of queries.
| Option | Meaning |
|---|---|
key | Groups repeats. One notice per user, type and key |
mode | group or once. Defaults to the definition, else group when key is set |
count, amount | What this send adds. Default count is 1 |
publish | Ping the bell. Default true |
immediate | Send the email now instead of queueing it |
Grouping
| Mode | No notice yet | Unread notice | Read notice |
|---|---|---|---|
| none | Insert, or refresh an unread one with the same data | ||
group | Insert | count and amount add up, text refreshes | Back to unread, revision + 1, counts restart |
once | Insert | Data refreshes, nothing else | Left alone |
Only a new or revived notice gets deliveries. A bump rings the bell and sends no second email.
Templates read the totals as $_count and $_amount; {count} and {amount} are template tags.
Api::markReadByKey($userId, $types, $key) marks a user's grouped notices read when they
open the thing, and fires xpac_notifications_read.
Fan-out for many users
For a recipient list too big to build in a request, register a source and queue a key. The
job runs on Action Scheduler hook xpac_notifications_fanout.
Api::registerFanoutSource('messaging', function (string $key, array $state, int $cursor, int $size): array {
$ids = members_after($state['threadId'], $cursor, $size);
return [
'type' => 'incoming_group_message',
'recipients' => $ids,
'data' => ['threadId' => $state['threadId']],
'options' => ['key' => $key, 'mode' => 'group'],
'cursor' => count($ids) < $size ? null : end($ids),
'state' => $state,
];
});
Api::fanout('messaging', 'thread:12', ['threadId' => 12]);fanout() returns queued, debounced, dirty or inactive. A second call while a run
exists marks it dirty, and the run goes once more after it ends, so a message that arrived
mid-run is counted. cursor is null when done. On a rerun, $state carries rerun => true.
A run that throws goes on from the batch that threw, after the delivery backoff, and is given
up after five attempts. A run untouched for 15 minutes with no job waiting is restarted by
the next send or the hourly cron.
Should it send
add_filter('xpac_notifications_should_send', function (bool $send, int $userId, string $type, array $data): bool {
return $send && !is_user_suspended($userId);
}, 10, 4);The filter runs per recipient before storing, and again before each delivery.
Preferences
Api::wants($userId, $type, $channel) answers onsite or email. Stored keys are <type>,
<type>:onsite, <type>:email and _all:email. They are read in this order: locked, "All
emails" off, channel, type, the declared default.
Hooks
| Hook | Kind | Arguments |
|---|---|---|
xpac_notifications_register | action | Manager $manager |
xpac_notifications_register_deliveries | action | Registry $registry |
xpac_notifications_init | action | none, on init 200 |
xpac_notifications_should_send | filter | bool $send, int $userId, string $type, array $data |
xpac_notifications_stored | action | int $id, int $userId, string $type, array $data, string $how |
xpac_notifications_read | action | int[] $ids, int $userId, string[] $types |
xpac_notifications_email_variables | filter | array $variables, string $type, array $data |
xpac_notifications_{$type}_url | filter | string|false $url, array $data, int $userId |
xpac_notifications_template_tags | filter | array $tags, string $type |
xpac_notifications_block_emails | filter | bool $on |
xpac_notifications_abilities | filter | string[] $abilities |
stored receives how: created, bumped, revived, refreshed or kept. Every hook,
generated from the code, is under Hooks.
Email goes through Api::mail() and the xpac_notifications_email filter, so a provider
can take it over. Delivery channels register on xpac_notifications_register_deliveries;
SendGrid, Brevo, Webhooks and Zapier do.
Abilities
Category xpac-notifications, registered when the Agent allows each tool. The
xpac_notifications_abilities filter can take a tool out.
| Ability | What it does |
|---|---|
list-types | Types with their section, channels, switches and how each is emailed |
update-type | Turn a type's onsite or email on or off. Runs straight away; the Agent's Undo puts the switches back |
get-template, update-template | Read a type's subject, on-site text and icon, body and tags, or change the subject, on-site text and icon. The change asks the administrator first, since it has no undo |
send-test | Send a test email to the signed-in administrator |
list-deliveries | Recent deliveries, filtered by status or channel |
retry-deliveries | Queue failed sends again. Asks the administrator first |
add-preferences-page | Draft page holding the Notification Preferences block |
The Agent also offers three starters: a review of what is on and what failed, sharper email subjects, and a preferences page.
Moving from Community Platform
A site that ran Community Platform's notifications moves its notices, read marks, settings, user
choices, block names and cron events once. The tables are made on any request; the moves run in
cron, in WP-CLI or on the first wp-admin page an administrator loads, and any other request
schedules them a minute out, so no visitor waits for them. The old REST
paths under cp/ keep answering, and the old wrapper CSS classes are still printed, for themes
that use them. The old xp_cp_notification_* hooks and Community's notifier base class are gone:
declare types with notificationTypes() and use the xpac_notifications_* hooks above.