Notifications & Email Module Guide¶
Last reviewed: 2026-06-23
This guide is a how-to/reference for maintainers of the notifications and email subsystem in Project NEXUS. It covers the three delivery channels (in-app bell, email, push/FCM), the recipient-locale invariant that every notification path must honour, the dispatcher flow, the queue, tenant scoping, the frontend inbox, and the regression tests that protect this surface.
Audience & supported workflows¶
Use this guide when:
- adding a new notification type or email template;
- changing how existing notifications are dispatched (frequency, deduplication, channel routing);
- debugging a missing or duplicated notification;
- adding a new event listener that sends notifications.
Supported notification workflows:
- In-app bell — a row written to
notificationsthat the React and accessible frontend inbox reads. - Email digest — a row queued in
notification_queueat the member's chosen frequency (instant / daily / monthly / off), flushed by a scheduled job. - Web push (VAPID) — sent via
WebPushServicetopush_subscriptionsrows for browser subscribers. - Mobile push (FCM) — sent via
FCMPushServiceto Capacitor mobile app device tokens (also recorded inpush_subscriptions). - Muted-user suppression — if the acting user is in the recipient's
user_muted_userslist, all channels are suppressed for that sender/recipient pair.
Tenant & feature-gate rules¶
- Tenant scope is mandatory. All queries against
notifications,notification_queue,notification_settings, andpush_subscriptionsfilter ontenant_id. TheNotificationEloquent model uses theHasTenantScopetrait (global scope), soNotification::query()is automatically tenant-scoped. - The
markAllRead()method inNotificationServicealso adds an explicitAND tenant_id = ?filter as defence in depth. - There is no feature-gate that globally disables notifications; specific channels (email, push) can be toggled per-user through notification preferences (
users.notification_preferences). - Push requires a configured VAPID key (
config('services.vapid.public_key')). When it is missing,WebPushServicereturns false silently.
Channels and the dispatch flow¶
Step-by-step: NotificationDispatcher::dispatch()¶
The primary entry point for standard (discussion / topic / reply / mention) notifications is App\Services\NotificationDispatcher::dispatch(). For social interactions (NotifyTransactionCompleted, NotifyMessageReceived, NotifyConnectionRequest, and similar listeners), the listener calls NotificationDispatcher::send(), fanOutPush(), or the specialised dispatch* methods directly.
dispatch() flow:
- Resolve tenant. Calls
TenantContext::runForTenant()using the recipient'susers.tenant_id, so the block is always executed in the correct tenant context regardless of which worker or HTTP request triggered it. - Mute check. If
$fromUserIdis supplied and that user appears inuser_muted_usersfor the recipient, the entire dispatch is silently skipped (returnstrue). - In-app bell (deduplication). Writes a row via
Notification::createNotification(). A 60-second Cache keynotif_dedup:{tenant}:{user}:{type}:{md5(link)}suppresses duplicate bells within the window. - Frequency resolution. Calls
getFrequencySetting()which walks the hierarchy: thread → group → global. If nothing is set, the tenant-level default inconfiguration.notifications.default_frequencyapplies; if that is also unset, the frequency defaults to'off'. Six critical types are always forced to'instant'regardless of the digest setting:new_message,connection_request,connection_accepted,vol_application_approved,vol_application_declined,vol_hours_approved. - Device push fan-out. Immediately after a fresh bell is written,
fanOutPush()fires (independent of email frequency). Push has its own 60-second dedup key per(user, type, md5(link)). The VAPID send is dispatchedafterResponse()so it never delays the HTTP caller. FCM and web push run in parallel, failure-isolated. Results are recorded topush_logfor delivery observability. - Email queue insert. For
instant, queues an immediate email innotification_queue. Fordaily/monthly, queues for the batch digest run. Foroff, nothing is enqueued. - Rollback on queue failure. If the
notification_queueinsert fails, the bell row is deleted anddispatch()returnsfalse.
Dispatch flow diagram (simplified)¶
Event fires
└─ Listener (ShouldQueue, runs on Redis queue)
└─ LocaleContext::withLocale($recipient, fn())
├─ Notification::createNotification() → notifications table (bell)
├─ NotificationDispatcher::fanOutPush() → WebPushService + FCMPushService
└─ NotificationDispatcher::dispatch() → notification_queue (email)
(or specialist dispatch* methods)
Social and system notifications¶
SocialNotificationService handles likes, comments, comment replies, and shares. Each method fetches the content owner and wraps bell + email rendering in LocaleContext::withLocale() before calling Notification::createNotification() and fanOutPush().
NotificationDispatcher::notifyAdmins() and notifyModerationAdmins() fan out to every role IN (admin, broker, coordinator) member in the current tenant, with each admin's bell and email rendered inside their own LocaleContext::withLocale() closure.
The recipient-locale rule (critical invariant)¶
Every user-facing string in every notification — bell text, email subject, email body, push title — must render in the recipient's preferred_language, not the sender's locale, not the queue worker's default, and not config('app.locale').
App\I18n\LocaleContext::withLocale() is the enforced mechanism. It temporarily switches App::getLocale() for the duration of a callable and restores the prior locale in a finally block, so exceptions cannot leak the switched locale. Nested invocations are safe — each level saves and restores its own snapshot.
What withLocale accepts:
| Input type | Behaviour |
|---|---|
string |
Used directly as locale code ('en', 'ga', …). Empty string → no switch. |
Object with ->preferred_language |
Reads the property as a locale string. |
null |
No-op — callable runs in the current locale. |
Before/after pattern¶
Before (leaks caller or worker locale):
foreach ($admins as $admin) {
$subject = __('emails.report.subject'); // resolves in the worker's default locale
$mailer->send($admin->email, $subject, $body);
}
After (each admin receives in their own preferred_language):
use App\I18n\LocaleContext;
foreach ($admins as $admin) {
LocaleContext::withLocale($admin, function () use ($admin, $mailer, $body) {
$subject = __('emails.report.subject');
$mailer->send($admin->email, $subject, $body);
});
}
Queue workers (listeners implementing ShouldQueue) boot once with the application default locale and never change it across jobs. The preferred_language field must be passed into the job payload, and withLocale() must wrap the entire handle() body. All five production listeners (NotifyTransactionCompleted, NotifyMessageReceived, NotifyConnectionRequest, NotifyConnectionAccepted, and NotifySafeguardingStaff) follow this pattern.
Admin fanouts (e.g. notifyAdmins(), notifyModerationAdmins()) wrap the send inside the per-recipient loop so each recipient's subject and body render in their language:
foreach ($admins as $admin) {
LocaleContext::withLocale($admin, function () use ($admin, ...) {
// bell, push, and email all render here
});
}
Key code & data locations¶
Routes are defined in routes/api.php. Do not maintain a duplicate endpoint table here — read the route file directly for the live list.
| Concern | Route prefix | Controller |
|---|---|---|
| Notification inbox (list, grouped, counts, mark read, delete) | /v2/notifications/* |
App\Http\Controllers\Api\NotificationsController |
| Per-user notification preferences | GET/PUT /v2/users/me/notifications |
App\Http\Controllers\Api\UsersController |
| Per-context digest frequency settings | GET/POST /v2/notifications/settings |
App\Http\Controllers\Api\UsersController |
| Email one-click unsubscribe | GET/POST /v2/notifications/unsubscribe |
App\Http\Controllers\Api\NotificationUnsubscribeController |
| Web push: subscribe / unsubscribe / status | POST /push/subscribe, POST /push/unsubscribe, GET /push/status |
App\Http\Controllers\Api\PushController |
| VAPID public key | GET /push/vapid-key |
App\Http\Controllers\Api\PushController |
Services:
| Service | File | Responsibility |
|---|---|---|
NotificationService |
app/Services/NotificationService.php |
In-app inbox reads: paginated list with cursor, grouped list, unread counts, mark-read, mark-group-read, delete. All queries are tenant-scoped via HasTenantScope. |
NotificationDispatcher |
app/Services/NotificationDispatcher.php |
Central dispatcher: bell creation, frequency resolution, email queue insert, push fan-out, hot/mutual match emails, exchange/broker notification helpers. |
SocialNotificationService |
app/Services/SocialNotificationService.php |
Likes, comments, comment replies, shares — writes bell + sends email under recipient's locale. |
PushNotificationService |
app/Services/PushNotificationService.php |
Manages push_subscriptions rows (subscribe / unsubscribe / count). Delegates sending to WebPushService. |
WebPushService |
app/Services/WebPushService.php |
VAPID browser push; called by fanOutPush(). |
FCMPushService |
app/Services/FCMPushService.php |
Firebase Cloud Messaging for Capacitor mobile app; called by fanOutPush(). |
EmailDispatchService |
app/Services/EmailDispatchService.php |
Low-level send wrapper (SendGrid / SMTP). All notification email sends go through EmailDispatchService::sendRaw(). |
Models:
| Model | Table | Notes |
|---|---|---|
App\Models\Notification |
notifications |
Bell rows. Has HasTenantScope (global scope). SoftDeletes. Appends read_at, body, title for frontend compatibility — the underlying columns are is_read (bool), message (string). |
App\Models\PushLog |
push_log |
Delivery observability record per fan-out. Records web push outcome, FCM sent/failed counts, errors. Best-effort; never affects delivery. |
Database tables (do not query directly from new code — use the services and models above):
| Table | Purpose |
|---|---|
notifications |
In-app bell rows per tenant per user. |
notification_queue |
Pending email digest rows. Columns: user_id, tenant_id, activity_type, content_snippet, link, frequency, email_body, status. |
notification_settings |
Per-user per-context frequency preferences (context_type: global, group, thread; frequency: instant, daily, monthly, off). |
push_subscriptions |
Browser VAPID and mobile (FCM) push endpoint rows, keyed on (user_id, endpoint). Tenant-scoped. |
push_log |
Delivery observability for push fan-outs (one row per fan-out call). |
transaction_notification_deliveries |
Idempotency ledger for NotifyTransactionCompleted — records delivery status per (transaction_id, user_id, event, channel) to prevent duplicate emails on queue re-delivery. |
user_muted_users |
Muted-sender list. dispatch() skips all channels when the acting user appears here for the recipient. |
Listeners (all implement ShouldQueue, run on the Redis queue):
| Listener | Event | What it sends |
|---|---|---|
NotifyTransactionCompleted |
TransactionCompleted |
Bell + email to receiver (credit received); confirmation email to sender (credit sent); review-request email to both parties. Idempotency via transaction_notification_deliveries. |
NotifyMessageReceived |
MessageSent |
Bell + email to message recipient. Idempotency via Cache. |
NotifyConnectionRequest |
ConnectionRequested |
Bell + email to connection target. Idempotency via Cache. |
NotifyConnectionAccepted |
ConnectionAccepted |
Bell + email to original requester. |
NotifySafeguardingStaff |
SafeguardingFlagRaised |
Bell to all safeguarding-role users. |
NotifyAdminOfNewRegistration |
UserRegistered |
Bell to admins on new registration. |
NotifyAdminOfNewListing |
ListingCreated |
Bell to admins on new listing. |
NotifyAdminOfNewGroup |
GroupCreated |
Bell to admins on new group. |
NotifyAdminOfNewCommunityEvent |
CommunityEventCreated |
Bell to admins on new event. |
NotifyAdminOfNewVolunteerOpportunity |
VolOpportunityCreated |
Bell to admins on new opportunity. |
SendWelcomeNotification |
UserRegistered |
Welcome bell to new member. |
NotifyGroupChatroomMessage |
GroupChatroomMessageSent |
Bell to group chatroom participants. |
NotifyGroupMemberJoined |
GroupMemberJoined |
Bell to group organisers. |
NotifyJobAlertSubscribers |
ListingCreated |
Email to users with matching job alert subscriptions. |
Notification type categories¶
NotificationService groups types into named categories used for inbox filtering and unread counts. The canonical category map is in NotificationService::TYPE_CATEGORIES:
messages, connections, reviews, transactions, social, events, groups, listings, jobs, safeguarding, system, security, ideation.
Types that do not match any category are counted in other.
Security & privacy invariants¶
- Tenant isolation.
Notification::query()carries theHasTenantScopeglobal scope.markAllRead()adds a redundant explicitAND tenant_id = ?filter. Never call rawDB::table('notifications')without atenant_idfilter. - Mute suppression.
dispatch()checksuser_muted_usersbefore creating a bell or queuing an email. System-generated notifications (no$fromUserId) are never suppressed. - Opt-out.
email_transactions,email_messages, andemail_reviewspreferences onusers.notification_preferencesare checked before sending the corresponding email type. The/v2/notifications/unsubscriberoute supports one-click unsubscribe compliance (Gmail / Yahoo Feb-2024 bulk-sender rules). - Push subscription ownership.
PushNotificationService::subscribe()andunsubscribe()are keyed on(user_id, endpoint)and always writetenant_id. The VAPID public key is served unauthenticated (GET /push/vapid-key) but subscriptions require auth. - Bell field hiding.
Notification::$hiddenexcludestenant_idfrom JSON serialization so it is never exposed to the client. - No hardcoded locale strings. Every
__('emails.*')or__('notifications.*')call must reference a key in the translation files underlang/. This is enforced byscripts/check-i18n.sh(runs in CI pre-push). Never inline English strings in notification or email code.
Email template builder¶
All HTML emails are built with App\Core\EmailTemplateBuilder. Chain methods (theme(), title(), previewText(), greeting(), paragraph(), blockquote(), infoCard(), button(), divider(), highlight(), render()) produce consistent, brand-themed HTML. Use htmlspecialchars() on user-supplied values before passing to paragraph() — it is the only raw-HTML sink.
Frontend entry points¶
| Surface | File |
|---|---|
| Full notification inbox page (React) | react-frontend/src/pages/notifications/NotificationsPage.tsx |
| Notification flyout / bell icon (React) | react-frontend/src/components/layout/NotificationFlyout.tsx |
| Notification preferences in Settings (React) | Via GET/PUT /v2/users/me/notifications (UsersController) |
| Per-context digest frequency (React) | Via GET/POST /v2/notifications/settings |
| FCM registration hook (Capacitor) | react-frontend/src/hooks/usePushNotifications.ts |
| Global notification state + unread counts | react-frontend/src/contexts/NotificationsContext.tsx via useNotifications() |
| Real-time bell updates | react-frontend/src/contexts/PusherContext.tsx (Pusher WebSocket) |
The NotificationsPage uses the grouped endpoint (GET /v2/notifications/grouped) which collapses repeated (type, link) pairs into a single item with group_count, actors, and remaining_count for "Alice and 3 others liked your post" display.
Test commands & regression tests¶
# PHP tests — run all notification-related tests
vendor/bin/phpunit --filter Notification --colors=always
# The canonical locale-contract regression test
vendor/bin/phpunit tests/Laravel/Feature/I18n/EmailLocaleIntegrationTest.php --colors=always
# Transaction notification idempotency
vendor/bin/phpunit tests/Laravel/Feature/Listeners/NotifyTransactionCompletedTest.php --colors=always
# Full Laravel test suite (includes listeners)
vendor/bin/phpunit --testsuite=Laravel --colors=always
# i18n key baseline check (run after any lang/ change)
npm run check:i18n:baseline
Key regression tests¶
| Test file | What it guards |
|---|---|
tests/Laravel/Feature/I18n/EmailLocaleIntegrationTest.php |
LocaleContext::withLocale() causes __() to resolve in the recipient's locale; restores outer locale after return and on exception; nested invocations each see their own locale. |
tests/Laravel/Feature/Listeners/NotifyTransactionCompletedTest.php |
Bell + email sent to receiver in receiver's locale; confirmation email sent to sender in sender's locale; idempotency guard prevents duplicate delivery on queue re-delivery. |
Failure modes & recovery¶
| Symptom | Likely cause | Recovery |
|---|---|---|
| Notifications appear in English regardless of user language | LocaleContext::withLocale() missing or wrapping the wrong scope (subject line rendered before the wrap, or a queue job not wrapping handle()). |
Grep for the email or bell key being rendered; verify it is inside a withLocale() closure that reads preferred_language from the recipient. |
| Duplicate emails sent | Queue re-delivered a job (listener $tries > 1, or retry_after lower than job execution time). |
Check listener $tries and $timeout. For transaction emails, the transaction_notification_deliveries idempotency table prevents duplicates for that listener specifically. For message and connection notifications, Cache-based claim guards are in place. |
| Bell created but no email sent | Frequency is 'off' (user's default or explicit setting). Check notification_settings and users.notification_preferences. Critical types force 'instant' — only non-critical discussion types respect 'off'. |
Instruct the user to turn on their email digest in notification settings. |
| Push not delivered | VAPID key missing from .env, or push_subscriptions row expired / unregistered. |
Check push_log for error details. Verify VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are set. User may need to re-subscribe in Settings. |
| Push double-fired | fanOutPush() called twice for the same event (e.g. dispatch() plus a direct call in the same path). |
The 60-second Cache dedup key prevents double push for identical (user, type, link) within the window. If firing more than 60 seconds apart, the caller is responsible for suppressing the duplicate. |
| Email appears from wrong tenant | TenantContext not restored after an async job. All listeners call TenantContext::restoreAfterScopedListener() in finally. If a custom job forgets this, the worker's context leaks to subsequent jobs. |
Add restoreAfterScopedListener() in the finally block of the listener's handle() method. |
notification_queue rows stuck in pending |
Digest cron job not running, or database connection failure during flush. | The queue is flushed by App\Services\CronJobRunner on the scheduler. Check app/Console/Kernel.php for the scheduled cron entry and verify the Laravel scheduler is running (php artisan schedule:run). Rows expire to failed automatically after 7 days. Sent / failed rows are cleaned up after 30 days. |
Related documentation¶
- ARCHITECTURE.md — runtime boundaries and infrastructure topology.
- MODULES.md — module map and guide checklist.
routes/api.php— authoritative endpoint list (do not duplicate here).app/I18n/LocaleContext.php— source of truth for the locale-switching contract.tests/Laravel/Feature/I18n/EmailLocaleIntegrationTest.php— regression test for the locale contract.