The notification dispatcher: how one action fans out to channels

The idea: one door, three rooms

A MAM app sends notifications constantly — a welcome email on signup, an SMS with a password-reset code, a push when a chat message arrives, an order-status email, a broadcast to thousands of users. Those land on three very different transports: email goes out through wp_mail(), SMS through an external provider, push through APNs and FCM. Each transport has its own templates, its own queueing rules, its own failure modes.

The temptation is to let every plugin reach for whichever transport it needs. MAM Suite deliberately does the opposite. There is one entry point — the mam_notification_send_message action — and every notification, from every plugin, goes through it. A caller fires the action with a description of what to send and to whom; it never decides how. The dispatcher decides how.

This matters because the “how” is the part that keeps changing and the part that must stay consistent across an app’s whole surface. Per-recipient opt-outs, per-notification-type channel toggles, the queue-vs-immediate decision, app-store badge injection, history logging — none of that belongs scattered across thirty calling sites. It belongs in one place. The dispatcher is that place.

The class that owns this is MAM_Notification_Dispatcher, in mam-main/includes/notifications-manager/dispatcher/. It replaced an older mam_notification_message_sender in Phase 2.2; the legacy class was deleted in the same change, so there is no parallel path.


Firing the action: what a caller actually sends

A caller hands the dispatcher a plain associative array and fires the action:

do_action( 'mam_notification_send_message', array(
    'message_type' => 'mam-my-plugin-event_name', // registry slug
    'recipient_id' => 123,                          // WP user id
    'email'        => 'override@example.com',        // optional
    'phone'        => '+15551234567',                // optional
    'replacements' => array(
        'order_id'        => '42',
        'tracking_number' => 'ABC123',
    ),
    'send_now'     => true,                          // hint: prefer immediate
    'data'         => array( /* push payload */ ),
) );

Notice what is not in there. There is no “send an email” or “send a push” instruction. The caller names a notification type (message_type) and supplies the data that type’s templates need (replacements). Which channels actually fire is not the caller’s call — it comes from the type’s registration plus the admin’s per-type settings. Earlier versions did expose per-message send_text / send_email / send_pn flags; those were found to be inert and removed. If you are reading old code that still sets them, they do nothing.

The two common targeting modes are by user (recipient_id, and the dispatcher looks up the address/phone) and by address (pass email and/or phone directly). You can mix them — supply a recipient_id for push, and the email channel will fall back to that user’s account email when email is absent.


The value objects: giving the loose array a shape

The array shape above is the frozen contract. Around 30 fire sites across MAM Suite — plus copies of older plugins living on customer sites — pass arrays like this, and that shape cannot change without breaking them.

Internally, though, an untyped associative array is awkward to pass around three sender classes and a fistful of shared utilities. So the dispatcher normalises the array into a typed value object at its boundary:

  • MAM_Notification_Message — an immutable description of one dispatch request. It is built from the legacy array via MAM_Notification_Message::from_array(), which is tolerant of missing keys (each channel validates only what it actually needs). It exposes typed accessors — message_type(), recipient_id(), email(), phone(), replacements(), is_send_now(), is_silent(), data() — so downstream code reads fields by name and type instead of fishing through $array['maybe_present_key'].

  • MAM_Notification_Result — the mirror image on the way out. It records, per channel, whether the send was sent, queued, failed, or skipped, plus a human-readable reason. It answers questions like any_failed() or channels_with_status('skipped').

The pattern here is array on the outside, value object on the inside. Sibling plugins keep firing arrays through a stable hook; the dispatcher converts once, at the door, and everything past the door works with typed objects.

One asymmetry worth internalising: because the entry point is a WordPress action, callers do not get the MAM_Notification_Result back — WordPress discards action return values. The result is for code inside the dispatch, and for a planned post-dispatch hook. If you need delivery feedback today, you call the dispatcher’s dispatch() method directly rather than firing the action.


Fanning out: how the dispatcher routes per channel

Once normalised, routing happens per message in dispatch_one(). The dispatcher resolves the message_type slug against the notification-type registry — the mam_notification_list filter, which 68+ subscribers contribute to. Each registry entry declares the type’s slug, its default-on channels, and its per-channel templates. If the slug isn’t found in the registry, nothing fires.

For a matched type, the dispatcher then evaluates each channel independently. The decision for each is the same two-part question: do we have a target for this channel, and is the channel turned on for this type?

  • SMS fires when there’s a phone number — either passed in phone, or the recipient’s billing_phone user meta — and SMS is enabled for the type (either the admin’s per-type option is on, or the type’s registration lists sms in its default_on).
  • Email fires when there’s an email address — passed in, or resolved from the recipient’s WordPress account — and email is enabled for the type the same way.
  • Push fires when there’s a recipient_id and push is enabled for the type.

These three checks are independent. A single fire can light up all three channels, exactly one, or none. The “is it on?” half of each check reads a per-type, per-channel option — tsl_notification_text_status_<slug> for SMS, tsl_notification_email_status_<slug> for email, tsl_notification_pn_status_<slug> for push — that admins toggle in Mobile App Manager → Notifications → Settings, with the registry’s default_on array as the fallback when the option is unset. This is the whole reason the caller doesn’t get to choose channels: the choice is a property of the notification type and the site’s configuration, not of the calling code.

Each channel that passes both checks is handed off to its own sender — MAM_Email_Sender, MAM_Sms_Sender, or MAM_Pn_Sender — which owns the transport, the template resolution, the queue write, and the history row for that channel.


The one special case: system chat

There is a single deliberate bypass. When message_type is system_chat, the dispatcher skips registry resolution entirely and fires mam_notification_send_pn directly with a push-only payload (the chat data blob travels in data). This is the chat manager’s hot path, where forcing a chat ping through full type resolution would be needless overhead.

Treat this as the only sanctioned reason to touch mam_notification_send_pn yourself. For everything else, fire mam_notification_send_message and let the dispatcher route — that’s how you keep opt-outs, per-type toggles, and history logging working.


Queue vs. immediate: a per-batch decision

The dispatcher also decides when a send happens, and it decides this once per batch, not per message. The rule:

$use_cron_if_available = count( $messages ) > 10;
$use_global_cron       = get_option( 'mam_notification_use_cron' ) == 1;
$use_cron              = $use_cron_if_available && $use_global_cron;

Both conditions must hold. A handful of messages always sends immediately. A large batch queues for cron only if the site has the global cron option enabled. The consequence to remember: a site with cron off sends everything synchronously — a 1,000-message custom-notification job will run inside the request that triggered it and can overrun PHP’s timeout. Any site with regular bulk sends should turn cron on.

When a channel queues, it writes to the channel’s queue table (email to its own queue; SMS and push share one queue, distinguished by a flag) and a cron processor drains them in fixed batches on each tick. The mechanics of that drain — batch sizes, retry counts — live in the queue-and-cron subsystem, not the dispatcher. The dispatcher’s job ends at the decision and the handoff.


Why it’s shaped this way

Step back and the design is one idea applied consistently: callers declare intent; the dispatcher owns policy. A plugin author firing a notification shouldn’t need to know whether the recipient opted out, whether this type has SMS enabled, whether the site batches via cron, or what an app-store badge looks like in an email. They name a type, supply the data, and fire one action. Everything that must stay consistent across the app — and everything that an admin must be able to control without touching code — is concentrated in one class.

That concentration is also what makes the frozen array contract tolerable. The shape on the wire is plain and unchanging, so old callers keep working; the typed value objects live behind the door, where the team can refactor freely.


How this connects to the rest of the system

  • The notification-type registry (mam_notification_list) is the dispatcher’s routing table — it decides which channels a type supports and supplies templates. See Notification types registry and Recipe: Register a notification type.
  • The three sender classes own transport and templating per channel. See Notification channels: email, SMS, push.
  • The queue and cron subsystem drains what the dispatcher queues. See Notification queue and cron.
  • Opt-out and history are applied by the senders and shared utilities as each message goes out. See Notification history and opt-out.
  • The hook itself — its array shape, its dual action/filter registration, its fire sites — has a dedicated reference. See Hook: mam_notification_send_message.
Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!