One Notification System to Rule Every Channel
Intent vs delivery as the founding seam: a typed notification catalog, a preference engine that resolves instead of looks up, queue-per-channel adapters, digesting, and the build order that avoids the rewrite.
Notification systems are never designed; they accrete. First a mailable, then a push provider, then someone adds in-app toasts, then marketing gets a tool of their own — and two years later "notify the user" means five code paths, duplicate sends, a preferences page that lies, and a compliance question nobody can answer. I've now rebuilt this system enough times that the target architecture is stable. Let me draw it once, properly, so you can build it before the accretion instead of after.
The core separation: intent vs delivery
Every notification mess traces to one conflation: business code deciding how to deliver. The fix is a hard seam. Business code emits an intent — "this user should know their order shipped" — and a notification pipeline owns everything after:
Domain event ──▶ Notification intent ──▶ Preference engine ──▶ Channel router
(OrderShipped) (type, user, payload) (may I? where?) (push? mail? in-app?)
│
┌───────────┬──────────┼─────────┐
▼ ▼ ▼ ▼
OneSignal Mailer in_app (SMS…)
table
In Laravel, the skeleton is pleasantly native — a Notification class per type, with via() as the router hook; in .NET you'll write the dispatcher yourself in an afternoon. The architecture is identical: one entry point, one decision layer, N delivery adapters (each provider behind a port — the genuinely swappable seam where interfaces earn their keep).
The notification catalog: types as first-class citizens
The system's backbone is a registry of notification types, each declaring its category (transactional / activity / marketing), default channels, whether users may mute it, and its collapse/digest behavior:
enum NotificationType: string
{
case OrderShipped = 'order.shipped'; // transactional — unmutable, push+mail
case CommentReply = 'comment.reply'; // activity — mutable, push+in_app, digestible
case WeeklyDigest = 'digest.weekly'; // marketing — mutable, mail only
public function category(): Category { /* ... */ }
public function defaultChannels(): array { /* ... */ }
public function userMutable(): bool { /* ... */ }
}
This enum-with-metadata is deceptively powerful: the preferences UI renders from it (no more page/reality drift), the preference engine enforces from it, compliance questions ("what do we send and why") are answered by it, and adding a notification type becomes a one-file change reviewed like the small API change it is.
The preference engine: resolution, not lookup
Real preference checks are a resolution chain, evaluated in order: legal/compliance gates (marketing consent, KVKK/GDPR flags — non-negotiable), user per-type-per-channel choices (the settings page), capability (is the user even pushable? verified email?), and protective rules — quiet hours in the user's timezone and frequency caps for non-transactional sends. The output isn't a boolean; it's a delivery plan: send via in-app now, push suppressed (quiet hours), mail skipped (user muted). Log that plan — "why didn't I get the email?" tickets die instantly when support can read the resolution trace.
Delivery: queue-everything, adapter-per-channel
Every send is a queued job per channel — provider hiccups retry without touching business flow, channels fail independently (mail down ≠ push down), and each adapter stays dumb: template in, provider API out, result recorded. Two non-obvious rules: the in-app channel is just a table (notifications: user, type, payload, read_at) — it's the one channel you fully own, it never bounces, and it doubles as the user-facing history; and record every send in a unified log (type, user, channel, provider ID, status) — this is where delivery webhooks land, where the idempotency check lives (dedupe on (type, user, entity, channel) within a window — the retry that would double-send hits the unique index and stops), and where analytics start.
Digesting: the feature that saves the channel
The highest-leverage component nobody builds early: batching. Twelve comment replies in an hour should be one "12 people replied" push, not twelve. Mechanically it's modest — digestible types write to a buffer table instead of sending; a scheduled job (Quartz/Laravel scheduler) flushes per user per window, collapsing buffered intents into one templated summary. The judgment call is per-type windows: activity digests hourly, social stuff daily, transactional never. Teams that skip digesting spend the saved week later, on the churn analysis.
Build order, for the team starting today
- The type enum + unified send log + in-app table — one sprint, and everything after hangs off it.
- Channel adapters behind ports (mail you have; push via provider; in-app free).
- Preference engine with the resolution chain — and the settings page rendered from the catalog.
- Digesting for the chattiest type you have.
- Delivery feedback loop + the four health metrics from the lifecycle post.
None of it is hard; all of it compounds. A notification system with a catalog, a resolution trace and a send log is boring to operate and trivially auditable — and "boring and auditable" is precisely what the accreted version never achieves, no matter how many rewrites it survives.
Whether you're at the accretion stage or the blank page, this is a system I can draw onto your stack in a week — the brief form awaits.