Notification Systems: Channels, Queues, Templates, and Preferences

Design a multi-channel notification system in PHP that queues sends, retries failures with backoff, renders content from templates, respects per-user preferences, and tracks delivery status.

Almost every application eventually needs to tell a user something happened: an order shipped, a password was reset, a payment failed. The naive version of this — call the email provider's API inline, right where the event occurs — works for a demo and quietly falls apart in production, where a slow SMS provider can stall a checkout, a transient outage can silently drop a notification forever, and hard-coded message strings become impossible to translate or maintain. This lesson builds a notification system that queues, retries, templates, respects preferences, and tracks delivery the way a real production system needs to.

Multi-Channel Notifications and a Shared Abstraction

Most applications need to reach users through more than one channel — email for anything that benefits from a durable, detailed record; SMS for time-sensitive, must-see alerts (like a one-time passcode); push notifications for mobile-app engagement. Each channel has a completely different underlying API (an email provider's SMTP/API client, an SMS gateway like Twilio, a push service like FCM/APNs), but from the application's point of view, sending 'your order has shipped' is one logical event that might fan out to one or more of these channels depending on what the user has and prefers.

The clean way to model this is a Notification — a small, channel-agnostic object describing what happened and to whom — paired with one Channel implementation per delivery method, each exposing a common interface (send(Notification $n, User $recipient): DeliveryResult). The application code that triggers a notification ($notifier->notify($user, new OrderShippedNotification($order))) never needs to know which channels are eventually invoked; that decision is made by configuration or by each notification declaring which channels it supports.

This separation pays off the first time you need to add a channel (say, Slack notifications for internal alerts) — you write one new class implementing the channel interface, and every existing notification that opts into it works without being rewritten.

• Common channels: email (durable, detailed), SMS (urgent, short), push (app engagement). • Model a Notification as channel-agnostic data; one Channel class per delivery method. • A shared channel interface (e.g. send(Notification, User): DeliveryResult) decouples triggering code from delivery mechanics. • Adding a new channel later means one new class, not rewriting every notification.

Never Send Synchronously: Queue Every Notification

Sending a notification inline, in the same request that triggered it, ties your application's response time to the slowest external provider in the call chain. If the SMS gateway is having a bad minute, every checkout that triggers an order-confirmation text now waits on that gateway before the customer sees a success page — a completely unrelated concern leaking into the critical path of the request that matters most to the business. The fix is straightforward: the triggering code enqueues a job describing the notification to send, and a separate worker process (or queue consumer) picks it up and performs the actual send, entirely decoupled from the original request's response time.

This also isolates failure: if the notification send fails, it fails inside a background worker where it can be retried, logged, and monitored, rather than turning into a user-facing HTTP error or, worse, a caught-and-ignored exception that silently drops the notification. Queue systems in the PHP world — a database-backed queue table, Redis-backed queues, or a message broker like RabbitMQ/SQS behind a library such as Laravel's queue system or Symfony Messenger — all provide this same fundamental shape: enqueue fast, process asynchronously.

The one thing worth being deliberate about is what goes into the queued job payload: store IDs (user ID, order ID) and let the worker re-fetch fresh data at send time, rather than serializing a full snapshot of objects that might be stale by the time the job actually runs minutes later.

• Sending inline ties request latency to the slowest notification provider — never do this on a critical path like checkout. • Enqueue a job describing the notification; a worker sends it asynchronously. • Failures happen in the background where they can be retried/logged, not as user-facing errors. • Queue payloads should carry IDs, not full object snapshots, to avoid acting on stale data.

Retries with Backoff: Don't Lose a Notification to a Blip

External notification providers fail transiently more often than most other dependencies — a momentary DNS hiccup, a rate limit, a provider's own brief outage. If a queued job fails once and is simply discarded, a routine, minutes-long provider blip becomes a permanently lost password-reset email or a customer who never learns their payment failed. The standard fix is automatic retries: when a send attempt throws or returns a failure, the job is re-queued for another attempt rather than dropped, up to a bounded maximum number of attempts.

Retrying immediately in a tight loop makes things worse under a real outage (hammering an already-struggling provider, and burning through your retry budget in seconds). Exponential backoff spaces retries out — wait 30 seconds, then 2 minutes, then 10 minutes, then give up — so a short outage is bridged by patient retrying, while a truly broken integration eventually stops consuming worker capacity. Most PHP queue libraries support this natively (e.g. specifying a backoff array or a callback on the job class) rather than requiring you to implement scheduling by hand.

After exhausting all retries, the job should land in a visible 'failed' state — a dead-letter queue or failed_jobs table — rather than vanishing. This is what turns 'we occasionally lose notifications and don't know it' into 'we have a dashboard of notifications that need manual attention,' which is the difference between silent data loss and an operationally manageable problem.

• Transient provider failures are common; a single failed attempt should not permanently lose the notification. • Automatic retries re-queue a failed send instead of discarding it. • Exponential backoff spaces out retries (e.g. 30s, 2m, 10m) instead of hammering a struggling provider. • Exhausted retries should land in a visible failed-jobs state, not disappear silently.

Templates: Reusable, Localizable Content

Building notification content by concatenating strings inline ('Hi ' . $user->name . ', your order ' . $order->id . ' shipped') scales badly the moment you need the same message in more than one place, in more than one language, or with a design update that touches every notification's footer at once. A template — a reusable piece of content with named variable slots (Hi {{name}}, your order {{orderId}} shipped) — separates the *shape* of a message from the *data* filling it in, so the wording lives in one place per notification type and per locale.

Templates also make localization tractable: rather than branching application logic per language, you maintain one template file (or database row) per notification-type/locale pair, and the sending code picks the right one based on the recipient's locale preference, passing in the same variables regardless of which language is chosen. This is far more maintainable than sprinkling if ($user->locale === 'es') branches through business logic.

For email specifically, templates commonly need both a plain-text and an HTML version (some clients or user preferences require plain text), and a templating engine (Twig, Blade, or even a simple sprintf/strtr-based renderer for smaller apps) handles variable substitution, escaping (critical for HTML templates, to avoid injecting unescaped user data into a rendered email), and sometimes conditional blocks for optional content.

• Keep message content in reusable templates, not inline string concatenation. • Templates separate message shape (structure + variables) from message data. • One template per notification-type/locale pair makes localization tractable without branching logic. • HTML email templates need proper escaping of interpolated data, same as any HTML output.

User Preferences: Opt-In, Opt-Out, and Respecting the Choice

Users need control over which notifications they receive and through which channel — some want SMS for security alerts but not marketing, some want email digests instead of real-time pings, and some jurisdictions legally require opt-in consent before certain notification types (particularly marketing SMS/email) can be sent at all. A notification preferences system typically stores, per user, a matrix of notification-type × channel × enabled/disabled, with sensible defaults (security-critical notifications like password resets are usually non-optional; marketing and digest notifications are usually opt-in or easily opt-out-able).

Critically, the preference check has to happen before dispatch, inside the notification system itself, not left to each feature team to remember to check individually — otherwise it's only a matter of time before some code path bypasses it and a user who opted out of SMS gets texted anyway, which is both a UX failure and, for some notification categories, a compliance violation (e.g. CAN-SPAM, TCPA in the US, or GDPR consent requirements in the EU). Centralizing the check in the Notifier/dispatch layer means every notification, current and future, automatically respects it.

It's worth distinguishing hard opt-outs (never send this again, full stop — required for things like unsubscribe links) from softer preferences (send push instead of email) — the former needs to be honored even if it means a user misses an otherwise-useful message, because ignoring an explicit unsubscribe request is both bad practice and, in many places, illegal.

• Store per-user, per-notification-type, per-channel preferences with sane defaults. • Security-critical notifications (e.g. password reset) are typically non-optional; marketing is opt-in/out. • Check preferences centrally in the dispatch layer, not per feature — so nothing can bypass it. • Honor hard opt-outs (unsubscribe) absolutely — legally required in many jurisdictions (CAN-SPAM, TCPA, GDPR).

Delivery Tracking: Sent, Delivered, Failed, Opened

Enqueuing a send and walking away isn't the end of the story — providers report back on what actually happened to the message, and capturing that status is what turns 'we sent it' into 'we know whether it arrived.' At minimum, record when a notification was queued, when the send attempt was made, and whether that attempt succeeded or failed at the API level (the provider accepted or rejected the request). This alone lets you build a support-facing view of 'did this user's password-reset email actually get sent?'

For email in particular, most providers (SendGrid, Postmark, SES, Mailgun) go further via webhooks: they'll asynchronously notify your application of delivery (accepted by the recipient's mail server), bounces (hard bounce = permanently undeliverable address; soft bounce = temporary), spam complaints, and opens/clicks (via tracking pixels/links, with caveats around client-side privacy protections that increasingly suppress open tracking). Wiring up a webhook endpoint that updates your notification's status record from 'sent' to 'delivered' or 'bounced' closes the loop and is essential for things like automatically suppressing future sends to addresses that hard-bounced.

This tracking data has a second life beyond individual troubleshooting: aggregated over time, delivery/bounce/open rates are how you detect a notification type that's silently failing for a whole segment of users (a broken template, a misconfigured sender domain hurting deliverability) long before enough individual complaints pile up to notice otherwise.

• Record status transitions: queued → sent (accepted by provider) → delivered/bounced/failed. • Email providers commonly push delivery/bounce/complaint/open events via webhooks. • Hard bounces should suppress future sends to that address; soft bounces can retry. • Aggregated delivery metrics reveal silently broken notification types before complaints do.

A Notification and Channel Interface

channels() lets a notification declare which channels it supports; preferences further filter this down per user. DeliveryResult is a small value object holding success/failure and a provider message ID for later status lookups.

<?php
interface Notification
{
    public function type(): string;             // e.g. "order.shipped"
    public function templateData(): array;       // variables for the template
    public function channels(): array;            // e.g. ["email", "sms"]
}

interface Channel
{
    public function name(): string;
    public function send(Notification $notification, User $recipient): DeliveryResult;
}

Code Examples

The job re-fetches the user fresh (rather than trusting a possibly-stale snapshot), checks per-channel preferences before every send, renders content from a locale-aware template, records delivery status per channel, and throws on failure so the queue's backoff policy (30s, 2m, 10m, 30m, 1h) handles retrying rather than losing the notification.

if (!$preferences->isEnabled($user->id, $this->notificationType, $channelName)) { continue; }

The webhook closes the loop between 'we sent it' and 'what actually happened' — hard bounces feed a suppression list so future notifications skip that address entirely, rather than repeatedly failing against a known-dead recipient. In production this endpoint would also verify the provider's webhook signature before trusting the payload.

<?php
declare(strict_types=1);

final class SendNotificationJob
{
    public int $maxAttempts = 5;
    public array $backoffSeconds = [30, 120, 600, 1800, 3600];

    public function __construct(
        private int $userId,
        private string $notificationType,
        private array $templateData
    ) {}

    public function handle(
        UserRepository $users,
        NotificationPreferences $preferences,
        TemplateRenderer $templates,
        array $channelsByName // ['email' => EmailChannel, 'sms' => SmsChannel, ...]
    ): void {
        $user = $users->find($this->userId);
        if ($user === null) {
            return; // user deleted since the job was queued; nothing to send
        }

        foreach (['email', 'sms', 'push'] as $channelName) {
            if (!$preferences->isEnabled($user->id, $this->notificationType, $channelName)) {
                continue; // user opted out of this type/channel combination
            }
            if (!isset($channelsByName[$channelName])) {
                continue; // no address/token on file for this channel
            }

            $channel = $channelsByName[$channelName];
            $content = $templates->rend
…
<?php
// POST /webhooks/email-provider
$payload = json_decode(file_get_contents('php://input'), true);

match ($payload['event']) {
    'delivered' => NotificationLog::updateStatus($payload['message_id'], 'delivered'),
    'bounce' => (function () use ($payload) {
        NotificationLog::updateStatus($payload['message_id'], 'bounced');
        if ($payload['bounce_type'] === 'hard') {
            SuppressionList::add($payload['recipient_email']);
        }
    })(),
    'open' => NotificationLog::markOpened($payload['message_id']),
    default => null,
};

http_response_code(200);

Suppression Lists at Scale

Large email senders like e-commerce platforms maintain automated suppression lists driven directly by provider bounce/complaint webhooks — an address that hard-bounces or files a spam complaint is automatically excluded from all future sends, which protects sender reputation and inbox deliverability for every other customer far more effectively than manual list cleanup ever could.

$emailClient->send($user->email, $subject, $body); // called directly inside the checkout controller

Queue::push(new SendNotificationJob($user->id, 'order.confirmed', ['orderId' => $order->id]));

Sending synchronously inside a request handler ties the user-facing response time to the notification provider's latency — a slow or degraded provider directly slows down or breaks an unrelated critical action like checkout.

try { $channel->send($content, $user); } catch (\Throwable $e) { /* swallow */ } throw new NotificationSendException(...); // let the queue's retry/backoff policy handle it

Silently catching and discarding a send failure permanently loses that notification on the very first transient error; letting the exception propagate lets the queue's retry mechanism give it several more chances with backoff.

$body = 'Hi ' . $user->name . ', your order ' . $order->id . ' shipped on ' . date('Y-m-d');

$body = $templates->render('order.shipped', $user->locale, ['name' => $user->name, 'orderId' => $order->id]);

Inline string concatenation for message content can't be localized, can't be updated centrally, and tends to duplicate the same wording (and its bugs) across every place a similar notification is sent.

Why should notifications be queued rather than sent synchronously in the triggering request? A. Queuing is required by PHP's language spec B. A slow or degraded notification provider would otherwise directly slow down or break the user-facing request that triggered it C. Queued notifications are automatically translated D. Synchronous sends cannot include template variables

B. A slow or degraded notification provider would otherwise directly slow down or break the user-facing request that triggered it — Sending inline ties the request's response time to the notification provider's latency; queuing decouples the two so a slow SMS or email provider never stalls something like checkout.

What problem does exponential backoff on retries solve? A. It makes notifications send faster overall B. It spaces out retry attempts so a struggling provider isn't hammered and retry budget isn't exhausted instantly, while still eventually giving up C. It replaces the need for a queue entirely D. It encrypts the notification payload

B. It spaces out retry attempts so a struggling provider isn't hammered and retry budget isn't exhausted instantly, while still eventually giving up — Exponential backoff spaces retries increasingly further apart, bridging short transient outages without overwhelming an already-struggling provider, and capping total attempts so a truly broken integration doesn't retry forever.

Where should a user's notification preferences be checked? A. In each individual feature's code, wherever a notification might be triggered B. Centrally in the notification dispatch layer, so every notification type automatically respects them C. Only in the email channel, not SMS or push D. Preferences only need to be checked once at account signup

B. Centrally in the notification dispatch layer, so every notification type automatically respects them — Checking preferences centrally in the dispatch/queue-job layer guarantees every current and future notification respects a user's choice, rather than relying on every feature team to remember to check individually.

What should happen when an email provider reports a hard bounce via webhook? A. Nothing; hard bounces can be ignored B. The address should be added to a suppression list so future sends to it are skipped C. The notification should be retried immediately D. The user's account should be deleted

B. The address should be added to a suppression list so future sends to it are skipped — A hard bounce means the address is permanently undeliverable; suppressing future sends to it avoids repeated failed attempts and protects sender reputation/deliverability.

Modify the SendNotificationJob example so that if a channel's send() throws an exception (rather than returning a failed DeliveryResult), the job still records a NotificationLog entry with status 'failed' before re-throwing, so a crash mid-send doesn't leave the delivery-tracking table silently incomplete.

<?php try { $result = $channel->send($content, $user); } catch (\Throwable $e) { NotificationLog::record( userId: $user->id, type: $this->notificationType, channel: $channelName, status: 'failed', providerMessageId: null, ); throw $e; }

Wrap the $channel->send(...) call in a try/catch, log the failure in the catch block, then rethrow the caught exception so the queue's retry policy still applies.