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.

KeyMeaning
label, title, descriptionAdmin heading, user-facing title, help text
iconThe 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)
sectionSettings tab and preferences group. id defaults to the sanitized label
channelsonsite and email. A channel left out is not offered
defaultsAdmin switch defaults, and user, what a user gets before choosing
lockedAlways on. Users cannot turn it off
essentialAccount email: users cannot turn it off, and "All emails" does not stop it
linkcallable($data, $userId) returning a URL or false
variables, resolve, sampleEditor 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
groupmode group or once, and a key callable
emaildelay seconds, unread_only, immediate
templatesbell, 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.

OptionMeaning
keyGroups repeats. One notice per user, type and key
modegroup or once. Defaults to the definition, else group when key is set
count, amountWhat this send adds. Default count is 1
publishPing the bell. Default true
immediateSend the email now instead of queueing it

Grouping

ModeNo notice yetUnread noticeRead notice
noneInsert, or refresh an unread one with the same data
groupInsertcount and amount add up, text refreshesBack to unread, revision + 1, counts restart
onceInsertData refreshes, nothing elseLeft 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

HookKindArguments
xpac_notifications_registeractionManager $manager
xpac_notifications_register_deliveriesactionRegistry $registry
xpac_notifications_initactionnone, on init 200
xpac_notifications_should_sendfilterbool $send, int $userId, string $type, array $data
xpac_notifications_storedactionint $id, int $userId, string $type, array $data, string $how
xpac_notifications_readactionint[] $ids, int $userId, string[] $types
xpac_notifications_email_variablesfilterarray $variables, string $type, array $data
xpac_notifications_{$type}_urlfilterstring|false $url, array $data, int $userId
xpac_notifications_template_tagsfilterarray $tags, string $type
xpac_notifications_block_emailsfilterbool $on
xpac_notifications_abilitiesfilterstring[] $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.

AbilityWhat it does
list-typesTypes with their section, channels, switches and how each is emailed
update-typeTurn a type's onsite or email on or off. Runs straight away; the Agent's Undo puts the switches back
get-template, update-templateRead 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-testSend a test email to the signed-in administrator
list-deliveriesRecent deliveries, filtered by status or channel
retry-deliveriesQueue failed sends again. Asks the administrator first
add-preferences-pageDraft 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.

On this page