Notification System
The platform-wide notification engine (BRD Β§12, Phase 10) β one pipeline through which every notification in the application is typed, resolved against user preferences, queued by priority, and delivered across up to five channels (in-app, email, real-time websocket, mobile push, SMS).
- Status legend: β live Β· π planned (milestone noted) β this document describes the full architecture; pieces land milestone by milestone and are marked accordingly.
- Design rule: every notification class extends
App\Notifications\BaseNotificationand is identified by a case ofApp\Enums\Notification\NotificationType. This is CI-enforced by an architecture test β a notification that bypasses the pipeline cannot merge.
1. Architecture
ββββββββββββββββββββββββββββββ
$user->notify(new X(...)) β X extends BaseNotification β
ββββββββββββββββ¬ββββββββββββββ
β via($notifiable) β final, cannot be overridden
βΌ
ββββββββββββββββββββββββββββββ
β NotificationChannelResolver β
β 1. defaultChannels() of X β
β 2. BypassesPreferences? ββββΆ skip 3β4, deliver as declared
β 3. category switched off? ββββΆ drop entirely (via() β [])
β 4. channel switched off? ββββΆ strip that driver β
β 5. availability guards(βΈ defer)β
ββββββββββββββββ¬ββββββββββββββ
β resolved driver strings
ββββββββββββββββ¬ββββββββββββββΌβββββββββββββββ¬βββββββββββββββ
βΌ βΌ βΌ βΌ βΌ
database mail broadcast fcm vonage
(in-app β
) (email β
*) (Reverb β
) (push βΈ defer) (SMS β
)
Key classes
| Piece | Path | Role |
|---|---|---|
BaseNotification |
app/Notifications/BaseNotification.php |
Abstract base. Declares type() + defaultChannels(); via() is final and delegates to the resolver; toDatabase() is final and stamps the sender_* block onto every payload. Carries Queueable, SerializesModels, deleteWhenMissingModels. |
NotificationType |
app/Enums/Notification/NotificationType.php |
String-backed identity of every notification (chat.new_message, session.booked, β¦). The value is what's persisted in the notifications.data->type column and what clients switch on. |
NotificationChannel |
app/Enums/Notification/NotificationChannel.php |
Int-backed channel enum β Laravel driver string: Databaseβdatabase, Mailβmail, Pushβfcm, Broadcastβbroadcast, Smsβvonage. |
NotificationPriority |
app/Enums/Notification/NotificationPriority.php |
High / Medium / Low β queue name (notifications-high / -default / -low). |
NotificationChannelResolver |
app/Services/Notification/NotificationChannelResolver.php |
The single decision point for what actually gets delivered. Applies the user's notification settings on top of the notification's declared channels β see Settings API. |
NotificationFilter |
app/Enums/Notification/NotificationFilter.php |
String-backed identity of the inbox tabs β read, unread, mention, communities. Owns both what each tab means for read_at (unreadOnly()) and which NotificationTypes it covers (types()). |
NotificationSenderResolver |
app/Services/Notification/NotificationSenderResolver.php |
Resolves a sender's avatar (media library β legacy avatar_url column) and backfills the sender_* block on rows stored before it existed β batched, one query per page. |
NotificationModuleResolver |
app/Services/Notification/NotificationModuleResolver.php |
Backfills module_id on lesson-scoped course rows stored before the key existed, so the client can route to /courses/:course/modules/:module/lessons/:lesson without fetching the curriculum. Batched, one query per page. |
NotificationSlugResolver |
app/Services/Notification/NotificationSlugResolver.php |
Keeps course_slug / community_slug truthful next to course_id / community_id: backfills where the key is missing and refreshes it where the entity has been renamed. Batched, one query per entity per page. |
NotificationInboxService |
app/Services/Notification/NotificationInboxService.php |
Read/write side of a user's own inbox: filtered pagination, unread count, mark-read, resolve, delete. Every query starts from $user->notifications(). |
UserSettingService |
app/Services/Setting/UserSettingService.php |
Reads and writes a user's notification switches, and answers the two questions the resolver asks: is this type allowed, is this channel allowed. Registered as a singleton so one fan-out costs one lookup per user, not one per notification. |
ResolvesBookingCounterparty |
app/Notifications/Concerns/ResolvesBookingCounterparty.php |
Derives a session notification's sender from the recipient, so a notification that fans out to both parties shows the right face to each. |
BypassesPreferences |
app/Notifications/Contracts/BypassesPreferences.php |
Marker interface: this notification can never be suppressed by user preferences. |
DevicePlatform |
app/Enums/Notification/DevicePlatform.php |
iOS / Android / Web β for push device-token registration (βΈ deferred with FCM). |
Writing a new notification
class PaymentReceivedNotification extends BaseNotification
{
public function __construct(
public readonly SessionBooking $booking,
) {}
public function type(): NotificationType
{
return NotificationType::PaymentReceived;
}
/** @return array<int, NotificationChannel> */
protected function defaultChannels(): array
{
return [NotificationChannel::Database, NotificationChannel::Mail];
}
public function toArray(object $notifiable): array
{
return [
'type' => $this->type()->value,
'booking_id' => $this->booking->id,
];
}
/** The mentee who paid β becomes the sender_* block in the payload. */
protected function sender(object $notifiable): ?User
{
return $this->booking->mentee?->user;
}
}
Rules:
- Add the
NotificationTypecase first (with alabel()), then the class. - Place the class in its feature folder β
app/Notifications/{Booking|Chat|Community|Feed|Course|Mentor|Admin}/. - Never write a
via()method β declaredefaultChannels()instead. The base class's finalvia()guarantees preference resolution applies. - Eloquent models in the constructor are safe as
public readonlyβSerializesModelsround-trips them as identifiers (verified on PHP 8.3 against a real Redis queue). - Override
sender()when a person triggers the notification β the base class adds thesender_*block to the payload for you; leave it alone for system notifications. - Implement
BypassesPreferencesonly when suppression would break a functional or security requirement (2FA codes, email verification, account-ban notices). - Security-critical auth notifications must not implement
ShouldQueueβ if the queue worker is down, a queued 2FA code means nobody can log in. Everything else queues. β
Bypass list (current)
| Notification | Why it can't be suppressed |
|---|---|
Auth\VerifyEmailNotification |
Account can't function without a verified address. |
Auth\TwoFactorCodeNotification |
Login is impossible without the code. |
Auth\SmsTwoFactorCodeNotification |
Same, SMS variant. |
Admin\Account{Suspended,Banned,Reinstated} β
|
Mail-only by necessity β a banned user cannot log in to read an in-app notice. Every moderation notice must carry the admin's recorded reason. |
2. Channels
| Channel | Driver | What it is | Status |
|---|---|---|---|
| In-app | database |
Row in the notifications table (UUID morphs), read via the notifications API. The primary channel β everything user-facing lands here. |
β live (queued) |
mail |
MailMessage built inside the notification (no separate Mailable classes). One shared branded layout; markdown templates only for structurally rich emails. |
β live across the catalog; branded layout in L5 | |
| Real-time | broadcast |
Laravel Reverb websocket frame on the user's private channel users.{id}.notifications. Not user-configurable β mirrors the Database channel: if a notification stores in-app, it also broadcasts. Payload mirrors the REST list-item shape so clients can unshift() frames into an already-fetched list. |
β live |
| Push | fcm |
Custom FCM HTTP-v1 channel (no heavy SDK) with a device_tokens registry, reactive invalidation on UNREGISTERED, and a 270-day idle prune. |
βΈ deferred β needs a mobile app first |
| SMS | vonage |
Vonage text message. Used exclusively for 2FA codes today. | β live |
Queues & priority β
The queue backend is the database driver (jobs table) β no Redis, no Horizon. Every
non-auth notification implements ShouldQueue; BaseNotification::viaQueues() derives the queue
from type()->priority()->queue(). Workers drain queues in strict order, which is the priority
mechanism β there is no per-class configuration:
| Priority | Queue | Typical contents |
|---|---|---|
| High | notifications-high |
Booking lifecycle, payments/refunds, session reminders, mentor approval, appointment requests, hold-expiring, all admin moderation |
| Medium | notifications-default |
Chat messages/requests, calendar share, review prompt, lesson published |
| Low | notifications-low |
Community post fan-out, feed comments/reactions |
| β | broadcasts |
Reverb frames β a separate queue so a mail backlog can never delay real-time delivery (run it under its own worker if that ever matters) |
End-to-end delivery flow
What actually happens between notify() and the user's screen β verified live against a real
websocket subscriber:
sending process (request / listener / command / job)
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
$user->notify($n)
ββ via($user) β NotificationChannelResolver β channels decided HERE, once,
β ['database', 'mail', 'broadcast'] never re-decided by the worker
ββ ONE row in `jobs` PER CHANNEL, queued per viaQueues():
database β notifications-high|default|low
mail β notifications-high|default|low
broadcast β broadcasts
queue worker (php artisan queue:work --queue=β¦)
ββββββββββββββββββββββββββββββββββββββββββββββββ
database job β INSERT into `notifications` table (the bell / list API)
mail job β SMTP via toMail() (MAIL_MAILER)
broadcast job β fires BroadcastNotificationCreated
ββ chained job on `broadcasts` β HTTP POST to Reverb
reverb server (php artisan reverb:start)
βββββββββββββββββββββββββββββββββββββββββ
pushes the frame to every socket subscribed to
private-users.{id}.notifications β client unshift()s it into the list
β οΈ The #1 "it's not working" cause: the worker isn't running. notify() only queues β
with no queue:work process nothing is stored, mailed, or broadcast; the jobs silently
accumulate in the jobs table. Check SELECT COUNT(*) FROM jobs first, always.
The worker command (everywhere β dev, server, container):
php artisan queue:work --queue=notifications-high,notifications-default,notifications-low,broadcasts,default --tries=3 --timeout=60
Failed jobs land in the failed_jobs table: php artisan queue:failed to inspect,
php artisan queue:retry all to replay. Upgrade path: when queue volume ever justifies it,
Redis + Horizon slot in with an env flip (QUEUE_CONNECTION=redis) and a config file β nothing
in the notification code changes.
3. Setup
3.1 Windows (development)
No installation needed. The queue uses the database driver (the jobs table ships with the
default migrations), so local development is just the artisan processes:
php artisan serve
php artisan queue:work --queue=notifications-high,notifications-default,notifications-low,broadcasts,default
php artisan reverb:start # websockets
php artisan schedule:work # reminder commands & prunes (L4+)
Notes:
composer run devalready includes aqueue:listenprocess β fine for casual dev (queue:listenpicks up code changes without restarts;queue:workis the faster production-style loop but must be restarted after edits).- The test suite pins
QUEUE_CONNECTION=syncβ tests never need a worker running. - Optional Redis: a local Redis (e.g. Docker
redis:7-alpineon6379) withREDIS_CLIENT=predis(predisis installed; Windows has nophpredisextension) lets you exercise the production upgrade path, but nothing requires it.
composer.jsonpinsconfig.platform(php 8.3.23,ext-pcntl,ext-posix) so Composer can resolve dependencies on Windows whilelaravel/horizonremains installed (unused β it's kept for the future Redis/Horizon upgrade). Do not remove this block.
3.2 Linux (production server)
-
Queue worker β one systemd unit running
queue:workon the database driver. No Redis, no Horizon (both are a deliberate deferral β see Upgrade path below):# /etc/systemd/system/meetyy-worker.service [Unit] Description=Meetyy Queue Worker After=network.target [Service] User=www-data WorkingDirectory=/var/www/meetyy-api ExecStart=/usr/bin/php artisan queue:work --queue=notifications-high,notifications-default,notifications-low,broadcasts,default --tries=3 --timeout=60 --max-time=3600 Restart=always RestartSec=5 [Install] WantedBy=multi-user.target--max-time=3600makes the worker exit hourly so systemd restarts it with fresh code and no memory creep. On every deploy also runphp artisan queue:restartβ workers finish their current job, then reload the new code. -
Reverb β β a second long-running service (
php artisan reverb:start --port=8080) behind the reverse proxy. Nginx must proxy websocket upgrades:location ^~ /app { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; # must outlive Reverb's 60s ping, or idle sockets churn proxy_buffering off; }Clients authorize private channels against
POST /api/broadcasting/auth(note: not under/api/v1/) with their Sanctum bearer token. Client-side integration β Echo setup, the chat presence channel, reconnection β lives in Realtime & WebSockets. -
Scheduler β single cron entry:
* * * * * cd /var/www/meetyy-api && php artisan schedule:run >> /dev/null 2>&1 -
Mail β real SMTP credentials (
MAIL_MAILER=smtp). Local/dev stays onMAIL_MAILER=log. -
FCM (βΈ deferred) β a Firebase service-account JSON on disk, path in
FCM_CREDENTIALS; the access token is minted and cached (55 min) by the app, no SDK involved.
Upgrade path (when queue volume justifies it): install Redis, set
QUEUE_CONNECTION=redis + REDIS_CLIENT=phpredis, publish config/horizon.php, and swap the
worker unit's ExecStart to php artisan horizon. laravel/horizon is already a dependency and
viaQueues() already emits the queue names Horizon would supervise β no notification code
changes.
3.3 Deploying on Coolify (Nixpacks β the live setup)
Production runs on Coolify as a single Nixpacks-built container configured by
nixpacks.toml at the repo root: supervisord runs nginx, php-fpm, and the Laravel processes
side by side. The notification stack added three things to that file β if you are debugging
production delivery, this is where to look:
| Supervisor program | Command | Why |
|---|---|---|
worker-laravel (Γ4) |
queue:work --queue=notifications-high,notifications-default,notifications-low,broadcasts,default --tries=3 --timeout=60 --max-time=3600 |
β οΈ The --queue= list is load-bearing: without it only default drains and every notification silently piles up in jobs |
worker-scheduler |
schedule:work |
Session/follow-up reminders, prunes β and the pre-existing crons (slots:generate, bookings:auto-complete, β¦) which previously had no runner in this container |
worker-reverb |
reverb:start --host=0.0.0.0 --port=8081 |
The websocket server; nginx proxies location ^~ /app to 127.0.0.1:8081 with Upgrade headers (also in nixpacks.toml). Pinned to numprocs=1 β channel state is in-memory, so a second process would see half the subscribers |
Environment variables to set in Coolify (server side; the container talks to Reverb over loopback β clients come in through the public domain):
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=<generate fresh β do not reuse the dev values>
REVERB_APP_KEY=<fresh>
REVERB_APP_SECRET=<fresh>
REVERB_HOST=127.0.0.1 # serverβreverb POST target inside the container
REVERB_PORT=8081
REVERB_SCHEME=http
Frontend/mobile clients connect to wss://<public-domain>/app/<key> (Coolify Traefik β nginx
β 8081) and authorize via POST /api/broadcasting/auth with their Sanctum token.
After each deploy, verify from the container terminal:
supervisorctl status # all workers RUNNING
php artisan queue:monitor notifications-high,notifications-default,broadcasts # sizes ~0, not growing
php artisan queue:failed # should be empty
Queued jobs live in the database, so they survive container replacement; workers get
SIGTERM + stopwaitsecs=3600, finishing their current job before the new image takes over.
3.4 Environment reference
| Key | Dev (Windows) | Production | Notes |
|---|---|---|---|
QUEUE_CONNECTION |
database |
database |
phpunit pins sync β tests never need a worker. Upgrade to redis later if volume demands |
CACHE_STORE |
file |
file (or redis later) |
|
BROADCAST_CONNECTION |
reverb |
reverb |
phpunit pins null |
REVERB_APP_ID/KEY/SECRET/HOST/PORT |
local defaults (in .env) |
real values | |
MAIL_MAILER |
log |
smtp |
|
FCM_PROJECT_ID / FCM_CREDENTIALS |
unset (push off) | unset until push lands | βΈ deferred; push silently skips when no device tokens |
VONAGE_KEY/SECRET/SMS_FROM |
set if testing SMS 2FA | set | live today |
REDIS_* |
optional | optional | only for the future Redis/Horizon upgrade |
4. Notification catalog
Every notification the platform sends or will send. Trigger = the exact moment it fires; Why = the product reason it exists. Priorities per BRD Β§12.
Complete type index
All 47 cases of App\Enums\Notification\NotificationType, grouped by domain prefix. The type
value is also the wire event name clients subscribe to (broadcastAs() β see Β§5.3); there is
no generic notification.created frame. Priority decides the queue (high β
notifications-high, and so on). The per-domain tables further down add recipients, channels and
exact triggers.
Auth β auth.* (3)
| Type | Label | Priority | What it means |
|---|---|---|---|
auth.verify_email |
Verify Email Address | High | Confirm your address before the account is usable |
auth.two_factor_code |
Two-Factor Code | High | Emailed login OTP |
auth.two_factor_sms |
Two-Factor SMS Code | High | Same code over SMS (Vonage) |
Chat & messaging β chat.* (4)
| Type | Label | Priority | What it means |
|---|---|---|---|
chat.message_request |
New Message Request | Medium | Opening message of a Pending thread β accept or decline the handshake |
chat.new_message |
New Message | Medium | A message in an Accepted thread that went unread for chat.notification_gap_hours β see Deferred chat notices |
chat.calendar_share |
Availability Shared | Medium | A mentor shared their availability inside the thread |
chat.appointment_request |
New Appointment Request | High | A mentee picked a slot from a shared calendar β confirm before the hold lapses |
Community β community.* (8)
| Type | Label | Priority | What it means |
|---|---|---|---|
community.post_created |
New Community Post | Low | The owner published to a community you belong to |
community.post_commented |
New Comment on Your Post | Low | Someone commented on your community post |
community.post_mentioned |
You Were Mentioned in a Post | Medium | @you in a community post body |
community.comment_mentioned |
You Were Mentioned in a Comment | Medium | @you in a community comment; outranks post_commented |
community.pricing_change |
Community Pricing Change | High | Contractual 30-day notice that a free community goes paid (effective_at) |
community.level_up |
Level Up | Low | Your XP crossed a level threshold |
community.join_request_approved |
Join Request Approved | Medium | Cleared to enter β requires_payment says checkout first or straight in |
community.join_request_rejected |
Join Request Declined | Medium | Turned down; not a ban, they may apply again |
Public feed β feed.* (6)
| Type | Label | Priority | What it means |
|---|---|---|---|
feed.connection_request |
New Connection Request | Medium | Someone wants to connect β accept prompt |
feed.connection_accepted |
Connection Request Accepted | Medium | Your request was accepted; mutual features unlock |
feed.post_commented |
New Comment on Your Post | Low | Someone commented on your feed post |
feed.post_mentioned |
You Were Mentioned in a Post | Medium | @you in a feed post body |
feed.comment_mentioned |
You Were Mentioned in a Comment | Medium | @you in a feed comment or reply; outranks post_commented |
feed.post_reacted |
New Reaction on Your Post | Low | Someone reacted to your feed post |
Bookings, sessions & money β session.* Β· payment.* Β· refund.* Β· slot_hold.* Β· booking.* Β· availability.* (11)
| Type | Label | Priority | What it means |
|---|---|---|---|
session.booked |
New Session Booking Request | High | Mentor: a mentee requested a session β confirm or decline |
session.confirmed |
Session Confirmed | High | Mentee: the mentor accepted; payment step follows for paid sessions |
session.cancelled |
Session Cancelled | High | The session is off and the slot is released |
session.completed |
Session Completed β Leave a Review | Medium | Review prompt to both parties after auto-completion |
session.reminder_24h |
Session Tomorrow | High | Day-before nudge, still early enough to reschedule |
session.reminder_1h |
Session Starting Soon | High | Last-mile nudge before start |
payment.received |
Payment Received | High | Mentor: the mentee's checkout cleared |
refund.issued |
Refund Issued | High | Mentee: money is coming back |
slot_hold.expiring |
Slot Hold Expiring | High | 2 minutes left to finish checkout before the held slot releases |
booking.followup_reminder |
Select Your Follow-Up Sessions | High | A package still has unscheduled follow-up dates |
availability.booked_slots_affected |
Booked Sessions Affected | High | An availability/vacation edit overlaps already-confirmed bookings |
Every session payload carries
recipient_role(mentor/mentee), itsrecipient_role_label, andis_hostβ the flag a client branches on. One notification instance fans out to both parties, so the payload alone would not say what the row means:is_host: trueis a session the reader has to run,falseis one they attend under their mentor. Derived per recipient byBookingParticipantRole::forBooking()at send time (the chatchat.appointment_requestrow carries it too), and backfilled on read for rows stored before the keys existed β see Β§5.1 The recipient's side of a session.availability.booked_slots_affectedis the exception: it is mentor-only and names no single booking.
Mentor lifecycle & verification β mentor.* (7)
| Type | Label | Priority | What it means |
|---|---|---|---|
mentor.registered |
Mentor Registration | Medium | Admins: a new mentor application arrived |
mentor.approved |
Mentor Approved | High | Application accepted β availability and bookings unlocked |
mentor.rejected |
Mentor Rejected | High | Application declined, with the admin's reason |
mentor.document_approved |
Verification Document Approved | High | A submitted verification document passed review |
mentor.document_rejected |
Verification Document Rejected | High | Document failed review; admin_notes says what to fix before re-upload |
mentor.suspended |
Mentor Account Suspended | High | Booking surface withdrawn by an admin, with a reason |
mentor.reinstated |
Mentor Account Reinstated | High | Suspension lifted |
Courses β course.* (7)
| Type | Label | Priority | What it means |
|---|---|---|---|
course.lesson_published |
New Lesson Published | Medium | A course you're enrolled in gained a lesson |
course.assignment_submitted |
Assignment Submitted | Medium | Author: a learner turned work in for grading |
course.assignment_graded |
Assignment Graded | Medium | Learner: your submission was graded |
course.notice_published |
Course Notice Published | High | The author posted an announcement on a course you're taking |
course.commented |
New Course Comment | Low | Author: somebody commented on your course |
course.comment_mentioned |
Mentioned in a Course Comment | Medium | You were named with @username in a course discussion |
course.reviewed |
New Course Review | Low | Author: a learner reviewed your course |
Every
course.*payload carriescourse_slugalongsidecourse_id, because the client routes to/courses/:slugβ an id alone would cost it a lookup before it could open the notification. The lesson-scoped ones (course.lesson_published,course.assignment_submitted,course.assignment_graded) additionally carrymodule_id, since the lesson route runs through the module. See Β§5.1 Ids and slugs for how both are kept true on rows stored before the keys existed.
Account & content moderation β account.* Β· content.* (5)
| Type | Label | Priority | What it means |
|---|---|---|---|
account.suspended |
Account Suspended | High | Account access withdrawn (email only β they can't log in to read it) |
account.banned |
Account Banned | High | Permanent removal of access |
account.reinstated |
Account Reinstated | High | Access restored |
content.moderated |
Content Moderated | High | Your post/comment was hidden or removed, and why |
content.warning |
Content Warning | High | Formal warning without removing the content |
4.1 Auth β app/Notifications/Auth/ β
(all bypass preferences, all synchronous)
| Type | Recipient | Channels | Trigger | Why |
|---|---|---|---|---|
auth.verify_email |
Registering user | Immediately on registration, and on "resend verification" | Prove address ownership before the account is usable; link carries an expiring token | |
auth.two_factor_code |
Logging-in user | On login when email-OTP 2FA is enabled | Second factor; code expires in two-factor.code_expiry_minutes |
|
auth.two_factor_sms |
Logging-in user | SMS | On login when SMS 2FA is enabled | Same, delivered via Vonage |
4.2 Chat β app/Notifications/Chat/ β
in-app (push deferred)
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
chat.message_request |
First-message recipient | In-app (+Push π) | Medium | The instant a new thread's opening message is sent (thread still Pending) | Recipient must accept/decline the handshake before conversation opens (Β§11.1) |
chat.new_message |
Other participant | In-app (+Push π) | Medium | Deferred: chat.notification_gap_hours (default 3) after a message lands in an Accepted thread, and only if the recipient still has not read it |
Surfaces a neglected conversation. Immediate unread awareness is the chat badge's job, not the inbox's β see Deferred chat notices |
chat.calendar_share |
Mentee in the thread | In-app | Medium | Mentor shares availability in chat (Β§11.2 calendar_share message) |
Prompts the mentee to pick a slot without leaving chat |
chat.appointment_request |
Mentor in the thread | In-app (+Push π) | High | Mentee requests a slot from a shared calendar (creates a held booking) | Mentor must confirm within the hold window or the slot releases |
4.3 Community β app/Notifications/Community/ β
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
community.post_created |
All active members | In-app | Low | Community owner publishes a post | Feed engagement; deliberately Low β fan-out to large memberships must never delay booking-critical mail |
community.post_commented |
Post author (never self) | In-app | Low | Someone else comments on the author's post | Engagement loop |
community.post_mentioned |
Members named with @username in a post body (never self) |
In-app | Medium | A community post body mentions them; on edit, only newly added names | Directed at one person, so more actionable than the Low broadcast family. Honours the per-community mentions switch |
community.comment_mentioned |
Members named with @username in a comment (never self) |
In-app | Medium | A community post comment mentions them | Same; takes precedence over community.post_commented β see Β§4.4.1 |
community.pricing_change |
All active members | In-app (+Email π) | High | Owner switches a free community to paid | Contractual 30-day advance notice (BRD Β§17) β members must know before billing starts; the notification stores effective_at |
community.level_up |
The levelling member | In-app | Low | β β member's XP crosses a level threshold (increases only; threshold-resync demotions stay silent) | Gamification reward moment |
community.join_request_approved |
The applicant | In-app | Medium | Owner approves a pending request to a vetted community (CommunitySubscriptionService::approveJoinRequest) |
Approval isn't always admission β the payload's requires_payment tells the client to route to checkout or straight into the community |
community.join_request_rejected |
The applicant | In-app | Medium | Owner turns a pending request down (CommunityMembershipService::rejectJoinRequest) |
A rejection is a fact, not an absence; they may apply again, so the payload carries the slug to return to |
Every
community.*payload carriescommunity_slugalongsidecommunity_id, for the same reason the course family carries its slug: the client routes to/communities/:slug.
4.4 Public Feed β app/Notifications/Feed/ β
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
feed.connection_request |
Requested user | In-app (+Push π) | Medium | User A sends a connection request | "[Name] wants to connect" β the accept prompt |
feed.connection_accepted |
Original requester | In-app | Medium | Recipient accepts | Closes the loop; unlocks mutual-connection features |
feed.post_commented |
Post author (never self) | In-app | Low | Comment on the author's feed post | Engagement |
feed.post_reacted |
Post author (never self) | In-app | Low | Reaction on the author's feed post | Engagement |
feed.post_mentioned |
Users named with @username in a post body (never self) |
In-app | Medium | A feed post body mentions them; on edit, only newly added names | Directed at one person, so more actionable than the Low broadcast family |
feed.comment_mentioned |
Users named with @username in a comment (never self) |
In-app | Medium | A feed comment or reply mentions them | Same; takes precedence over feed.post_commented β see Β§4.4.1 |
4.4.1 Mention de-duplication
One notification per recipient per comment. Where somebody qualifies for more than one, the most specific signal wins:
mentioned > post_commented
So being named in a reply to your own post produces a single *_mentioned notification, never
that plus a *_commented. The rule lives in exactly one place per domain β
FeedCommentNotificationService and CommunityCommentNotificationService β which also own the
20-mention cap and the never-notify-the-actor exclusion.
Mentions are capped at 20 per body, and post edits notify only the names sync() reports as newly
attached, so fixing a typo never re-pings everyone.
No reply notification. A reply five levels deep notifies only the post author; the person being replied to gets nothing unless they were
@mentioned. Deliberate β the client spec did not ask for one. Addingfeed.comment_replied/community.comment_repliedlater is a small, isolated change.
4.5 Booking & sessions β app/Notifications/Booking/ β
The domain events (BookingRequested, BookingConfirmed, BookingPaid, BookingCancelled,
SessionCompleted) have been dispatched by the booking service since Phase 3 β the listeners now attach the
listeners and classes.
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
session.booked |
Mentor | In-app + Email | High | Mentee submits a free booking | Mentor must confirm or decline β the response-window timeout auto-cancels |
session.confirmed |
Mentee | In-app + Email | High | Mentor accepts | Mentee's go-signal; the session is now live |
session.cancelled |
The other party | In-app + Email | High | Mentor declines, either party cancels, or a sweep reaps it (bookings:expire-unconfirmed, every 15 min) |
Slot released; recipient must know plans changed |
payment.received |
Mentor | In-app + Email | High | A paid booking's payment settles (BookingPaid) |
Under pay-first this is the mentor's new-request alert, not a receipt: the booking is waiting on them and refunds itself if ignored (requires_action) |
refund.issued |
Mentee | In-app + Email | High | A refund is confirmed by the gateway | Money-movement notices are never optional; states the 5-working-day ETA |
session.reminder_24h |
Both | In-app + Email | High | Scheduled command, 24 h before starts_at |
Reduce no-shows while there's still time to reschedule |
session.reminder_1h |
Both | In-app + Push | High | Scheduled command, 1 h before starts_at |
Last-mile nudge β push, because the user is likely mobile |
session.completed |
Both | In-app + Email | Medium | Session auto-completes (bookings:auto-complete, every 30 min) |
Capture reviews while the session is fresh |
slot_hold.expiring |
Mentee | In-app | High | Delayed queue job dispatched at hold creation, firing hold_expiry_warning_minutes before expires_at (not the scheduler β per-minute cron can't hit an arbitrary mark) |
Last chance to finish checkout before the held slot releases |
booking.followup_reminder |
Mentee | In-app + Email | High | Scheduled command, 48 h after booking, if package follow-up dates remain unselected | Follow-up bundles lose value if never scheduled |
Pay-first silence. A paid booking is invisible to the mentor until its payment settles, so nothing about it reaches them before then β no
session.booked, and nosession.cancelledif the checkout is abandoned, the mentee changes their mind, or the create is rolled back. Telling them a booking was cancelled would be the first they ever heard of it. The mentee is still told.
Reminder idempotency β
: reminder commands scan self-healing lookback windows and claim a
row in notification_dispatches (unique(user_id, dedupe_key), e.g.
booking:{id}:reminder_24h) before sending β overlapping runs or two workers can never
double-send, and scheduler downtime is caught up on the next run.
4.6 Mentor lifecycle & availability β app/Notifications/Mentor/ β
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
mentor.registered |
All admins | In-app + Email | Medium | A user completes mentor onboarding (event exists; listener is currently a stub) | Admins must review the application β today nobody is told it arrived |
mentor.approved |
The mentor | In-app + Email | High | Admin approves the application | The mentor can now publish availability and take bookings |
mentor.rejected |
The mentor | In-app + Email | High | Admin rejects, with a recorded reason | Unexplained rejections are not acceptable (admin BRD Β§9.3) |
mentor.document_approved |
The mentor | In-app + Email | High | Admin approves a verification document (VerificationDocumentReviewService::review) |
Document-by-document progress on verification; one class emits both outcomes, branching on the document's status |
mentor.document_rejected |
The mentor | In-app + Email | High | Admin rejects a verification document | Carries admin_notes so the mentor knows what to fix before re-uploading |
availability.booked_slots_affected |
The mentor | In-app + Email | High | Mentor sets vacation / edits availability over slots that already hold confirmed bookings (Β§2.1/Β§2.6) | Bookings are preserved, not cancelled β the mentor must honour or explicitly cancel them |
4.7 Courses β app/Notifications/Course/ β
| Type | Recipient | Channels | Priority | Trigger | Why |
|---|---|---|---|---|---|
course.lesson_published |
Enrolled users | In-app + Email | Medium | Author publishes a new lesson in an enrolled course (Β§12) | Brings learners back; core retention loop |
course.assignment_submitted |
Course author | In-app | Medium | Learner submits an assignment | Grading queue awareness |
course.assignment_graded |
The learner | In-app + Email | Medium | Author grades a submission | Learner unblocked to continue |
course.notice_published |
Enrolled users | In-app | High | Author publishes a notice (Course API Β§35) | An announcement is the one course event a learner is expected to act on before the next session β hence High, unlike the *_commented family |
course.commented |
Course author | In-app | Low | Somebody comments on their course | Discussion awareness; broadcast-ish, so Low |
course.comment_mentioned |
The named user | In-app | Medium | @username in a course comment |
Directed at one person, same tier as the feed and community mention types |
course.reviewed |
Course author | In-app | Low | A learner reviews their course β first review only | An edit is the same learner revising the same opinion; notifying again would let one person ring the author's bell at will. Gated by the user_feedback switch β the first type to give that toggle teeth |
Notice fan-out is chunked. A popular course's roster is not a page, so
CourseNoticeServicewalks it withchunkById(200)rather than loading every enrolled user into memory to build oneNotification::send().
4.8 Admin moderation β app/Notifications/Admin/ β
classes (dispatch arrives with the admin module)
All carry a mandatory reason recorded by the acting admin, and dispatch only after the
database transaction commits β a failed moderation action must never produce a notification.
| Type | Recipient | Channels | Trigger | Why |
|---|---|---|---|---|
mentor.suspended |
Mentor | In-app + Email | Admin suspends an active mentor | Loses booking surface immediately; reason attached |
mentor.reinstated |
Mentor | In-app + Email | Admin lifts a suspension | Restores the mentor's surface |
account.suspended |
User | Email only | Admin suspends an account | User can't log in to read in-app β mail-only is a functional requirement, and bypasses preferences |
account.banned |
User | Email only | Admin bans an account | Same |
account.reinstated |
User | Email only | Admin reinstates | Same |
content.moderated |
Content author | In-app + Email | Admin hides/removes reported content (hide/remove triage) |
Author is told what happened and why; dismiss sends nothing |
content.warning |
Content author | In-app | Admin issues a warning (warn triage) |
Lightest sanction; visible next login |
5. API
5.1 Live endpoints β
All require Authorization: Bearer {token}. Envelope: { "status", "message", "data" }.
| Method | Endpoint | Purpose |
|---|---|---|
GET |
/api/v1/notifications?per_page=20 |
Paginated list, newest first. Filterable by read state (?unread/?read) and by topic (?filter=mention, ?filter=communities) β see below. Each item: id, type (catalog value above), type_label, data (type-specific payload, always including the sender block), read_at, created_at. Response also carries unread_count, which stays global even when the list is filtered. |
PATCH |
/api/v1/notifications/{id}/read |
Mark one as read |
POST |
/api/v1/notifications/read-all |
Mark all as read |
PATCH |
/api/v1/notifications/{id}/resolve |
Record the outcome of an actionable notification (connection request, chat request) β body action: accepted|declined. Stored as data.resolution and marks the row read, so the inline buttons stay resolved after a reload instead of inviting a second answer. |
DELETE |
/api/v1/notifications/{id} |
Remove one row from the caller's inbox permanently |
Notifications are role-agnostic: the same endpoints serve mentees, mentors and admins, scoped
to $request->user(). They previously lived under /api/v1/mentee/notifications/*; that prefix has
been removed.
{
"status": "success",
"message": "Notifications retrieved successfully.",
"data": {
"notifications": {
"data": [
{
"id": "9c9e2f9a-β¦",
"type": "chat.new_message",
"type_label": "New Message",
"data": {
"type": "chat.new_message",
"thread_id": "β¦",
"preview": "β¦",
"sender_id": "β¦",
"sender_name": "Marcus Williams",
"sender_avatar_url": "https://β¦/avatar.jpg"
},
"read_at": null,
"created_at": "2026-07-22T10:15:00+00:00"
}
],
"links": {},
"meta": {}
},
"unread_count": 3
}
}
Filters
The inbox filters on two independent axes, and they combine.
Read state β ?unread / ?read:
| Query | Result |
|---|---|
| (none) | Everything, newest first |
?unread Β· ?unread=1 Β· ?unread=true |
Unread only |
?unread=0 Β· ?read Β· ?read=1 |
Read only |
?read=0 |
Unread only (mirror of ?unread) |
?unread is a bare flag on purpose β no value needed. unread and read are mirror images, so
sending both is a 422 rather than a silent winner, and any value that is not a boolean
(?unread=maybe) is a 422 too. per_page accepts 1β100 and defaults to 20.
Topic β ?filter=, backed by NotificationFilter:
| Query | Result |
|---|---|
?filter=mention |
Every notification that names the caller with @username β feed.post_mentioned, feed.comment_mentioned, community.post_mentioned, community.comment_mentioned, course.comment_mentioned |
?filter=communities |
The whole community.* family β posts, comments, mentions, pricing changes, level-ups and join-request decisions |
?filter=read Β· ?filter=unread |
The read-state axis, so a client can drive every tab through one parameter |
Membership is decided by NotificationFilter::types(), not by pattern-matching the type string, and
a test holds it to the catalog: every community.* type must be in communities, every
*_mentioned type in mention. The two tabs deliberately overlap β a community mention appears
in both. Matching happens on the catalog value inside data->type, never on the stored PHP class
name.
The axes combine freely: ?filter=mention&unread is the unread mentions tab. What does not
combine is two answers to the same question β ?filter=read&unread is a 422, as is an unknown
filter value. unread_count stays global no matter which filter is applied.
Ids and slugs
Wherever a payload names a course or a community by id, it names it by slug too β the clients route
to /courses/:slug and /communities/:slug, so an id alone would cost them a lookup before they
could open the row:
| Payload key | Companion key |
|---|---|
course_id |
course_slug |
community_id |
community_slug |
Both are written at send time, so the websocket frame carries them as well. They are then
re-resolved on read by NotificationSlugResolver: a row stored before the key existed gets it
backfilled, and a course or community that has since been renamed has its stale slug refreshed β
a stale slug is a 404, not a cosmetic issue. A record that has been deleted keeps whatever slug the
payload was stored with. Resolution costs one query per entity per page, never one per row.
Lesson-scoped course notifications carry module_id on the same terms, resolved by
NotificationModuleResolver; a lesson that no longer exists reports module_id: null so the shape
stays uniform.
{
"type": "course.assignment_graded",
"lesson_id": "01a02cca-β¦",
"lesson_title": "First assignment",
"module_id": "01a01df7-β¦",
"course_id": "01a01df4-β¦",
"course_slug": "intro-to-product-design",
"awarded_marks": 40,
"passed": false,
"sender_id": "019f5f0a-β¦",
"sender_name": "Alex Thompson",
"sender_avatar_url": "https://β¦/avatar.jpg"
}
The recipient's side of a session
Session notifications are the only ones written for two people at once β a reminder, a cancellation and a completion prompt all reach mentor and mentee from a single instance. So every payload that names a booking also names the reader's side of it:
| Key | Value |
|---|---|
recipient_role |
mentor, mentee, or null when the reader is neither party |
recipient_role_label |
Mentor / Mentee, for display |
is_host |
true = the reader runs this session, false = they attend it |
BookingParticipantRole::forBooking() derives it from the recipient against the booking's two
users, the same way ResolvesBookingCounterparty derives the sender, so one queued instance stays
correct for both. slot_hold.expiring has no booking yet and is always the mentee mid-checkout.
Written at send time, so the websocket frame carries the keys as well, and re-resolved on read
by NotificationRoleResolver: a row stored before the keys existed is backfilled from its
booking_id against the recipient β one query per page, and none at all when the page's rows
already carry the role. A booking that has since been deleted leaves the keys present and null.
The sender block
Every notification carries sender_id, sender_name and sender_avatar_url inside data,
whatever its type β clients render one avatar component and never branch per type. The keys are
present even for system notifications (2FA codes, moderation actions, level-ups), where they are
null and the client falls back to a type icon.
BaseNotification::sender() is what fills the block: notifications triggered by a person override
it ($this->actor, $this->commenter, β¦), session notifications derive it from the recipient via
ResolvesBookingCounterparty β the mentee sees the mentor, the mentor sees the mentee β and the
rest inherit the null default. Avatars resolve media-library-first, falling back to the legacy
users.avatar_url column.
The block is written by the final toDatabase()/toBroadcast() on the base class, so it cannot be
forgotten in a new notification, and it is re-resolved on read: rows stored before the block existed
are backfilled from their actor_id/commenter_id/reactor_id, and a user who changes their
picture updates it on every notification they ever sent (one batched query per page, not per row).
5.2 Planned endpoints
| Method | Endpoint | Milestone | Purpose |
|---|---|---|---|
GET |
/api/v1/settings/notifications |
β live | The user's notification switches with their effective values and UI copy β see Settings API |
PUT |
/api/v1/settings/notifications |
β live | Sparse partial update β a row is only written once something is actually changed |
GET |
/notifications/unsubscribe?β¦signature=β¦ |
βΈ deferred | Signed one-click email unsubscribe (30-day expiry); rejects bypass-protected types with 422; redirects to the frontend settings page |
POST |
/api/v1/notifications/device-tokens |
βΈ deferred | Register/refresh an FCM token (token, platform, device_name). Upsert by token β re-registering under a new user reassigns the physical device |
DELETE |
/api/v1/notifications/device-tokens |
βΈ deferred | Deregister on logout so the device stops receiving push |
POST |
/api/broadcasting/auth |
β live | Channel authorization for Reverb (Sanctum bearer). Channels: users.{id}.notifications (private) Β· chat.threads.{id} (presence β see Realtime & WebSockets) |
5.3 Connecting from the frontend (Echo)
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 8080,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
authEndpoint: '/api/broadcasting/auth', // note: NOT /api/v1
auth: { headers: { Authorization: `Bearer ${token}` } },
});
echo.private(`users.${userId}.notifications`)
.notification((frame) => {
// frame = { id, type, type_label, data, read_at, created_at }
// β same shape as a GET /notifications list item: unshift() it
// into the list and increment the local unread counter.
});
One connection carries everything. The same echo instance also serves the chat thread
channel β messages, read receipts, and typing indicators β so an app never opens a second socket:
echo.join(`chat.threads.${threadId}`) // presence: join(), NOT private()
.listen('.chat.message.sent', appendMessage) // the leading dot is required
.listen('.chat.message.read', markSeen)
.listen('.chat.typing', showTyping);
Note the different subscribe verbs and the different receive verbs: notifications are a
private channel read with .notification(), chat is a presence channel read with
.listen(). Mixing them up authorizes fine and then delivers nothing.
A new chat message produces two frames for a recipient who has that thread open β the
chat.message.sent above and a chat.new_message notification here. That is intended: one
renders the bubble, the other drives the inbox and badge. Suppress the toast while the thread is
focused.
Full chat client β presence roster, typing debounce, receipts, reconnection: Realtime & WebSockets.
5.4 Preference semantics β
The shipped model is category-based, not the type Γ channel matrix originally sketched here: the settings screen offers one switch per kind of notice, which is far fewer decisions for a user than 51 types Γ 3 channels.
- Defaults and the category β type mapping both live on
App\Enums\Setting\NotificationSettingKeyβ one enum case per switch, each declaring its UI copy, its default, and theNotificationTypecases it gates. Nothing else hard-codes a type list. - A user who has never touched the screen has no row: reads fall back to the enum defaults,
and
user_settingsis only written when something is actually changed. - Two kinds of switch:
- Category switches (
session_updates,earnings_withdrawals,platform_alerts,user_feedback) suppress their types outright, on every channel βvia()returns[]. - Channel switches (
email_updates) strip one driver and leave the rest intact. A type gated by more than one switch needs all of them on.
- Category switches (
notification_soundis a client rendering hint and gates nothing server-side.- Types absent from every mapping (chat, feed, community posts, courses) have no switch on this screen and are always delivered. Communities keep their own per-community preferences.
- Types marked bypass (Β§1) ignore settings entirely and are not editable.
- Real-time mirrors In-app and SMS is 2FA-only, so neither is exposed as a toggle. Push arrives with FCM.
Full request/response contract: Settings API.
6. Testing
-
phpunit.xmlpinsQUEUE_CONNECTION=sync,BROADCAST_CONNECTION=null,MAIL_MAILER=array,CACHE_STORE=arrayβ the suite never needs Redis, Reverb, or Docker running. -
Fake with
Notification::fake(); assert channels via the second closure argument:Notification::assertSentTo($user, PaymentReceivedNotification::class, fn ($n, array $channels) => in_array('mail', $channels, true)); -
Architecture guarantees (
tests/Feature/Notification/NotificationArchTest.php): every class inApp\NotificationsextendsBaseNotificationand is suffixedNotification. -
Push (deferred) is tested with
Http::fake(['fcm.googleapis.com/*' => β¦]); broadcast (L3) withEvent::fake([BroadcastNotificationCreated::class])plus a channel-authorization test proving user A gets 403 for user B's channel.
7. Delivery milestones
| Milestone | Contents | Status |
|---|---|---|
| L1 | Enums, BaseNotification, resolver (passthrough), 14 classes converted, arch tests |
β shipped |
| L2 | ShouldQueue rollout, priority queues on the database driver, single chat fan-out job |
β shipped |
| L3 | Reverb websockets, private user channels, broadcast payloads | β shipped |
| L4 | Full BRD Β§12 event coverage β booking/session/reminder/course/mentor + admin Β§9 classes, dedupe table, scheduled commands | β shipped |
| L5 | Mail branding, rich markdown templates, canonical /api/v1/notifications surface |
π |
| L6 | User settings module, category preference switches, resolver enforcement | β shipped |
| βΈ | Signed one-click email unsubscribe | deferred β add before large-scale email sending |
| βΈ | FCM push channel, device-token registry + endpoints | deferred β needs a mobile app |
| βΈ | Redis + Horizon queue upgrade | deferred β env flip + config when volume demands |
Deferred chat notices
chat.new_message is the one type that is not written when its trigger happens.
A message used to create an inbox row immediately, which meant an active conversation filled the
notification list with rows for messages the recipient had already read in the chat itself. So the
decision is deferred: nothing is written when the message lands, a check is queued for
constants.chat.notification_gap_hours (default 3), and only a thread still unread by then
earns a row.
09:00 Message arrives
β no inbox row
β DeliverDeferredMessageNotification queued for 12:00
09:05 More messages in the same thread
β no extra job (one check per thread per recipient per gap)
12:00 Check fires
thread read, or replied to? β write nothing
still unread? β one `chat.new_message` row,
pointing at the NEWEST unread message
Reading counts as responding. The gate exists to keep the inbox to conversations the recipient has not dealt with; someone who opened the thread has already seen the message. Sending a reply marks the thread read too, so a reply is covered by the same check.
A burst collapses into one row. Twenty messages inside the gap produce a single notification pointing at the newest unread message β not twenty.
Nothing is lost. A recipient who was online when the message arrived and never opened it is still notified by the same check. That is the advantage of deferring the decision over simply skipping online recipients at send time, which would drop the notice permanently.
What is not deferred
| Type | Why it stays immediate |
|---|---|
chat.message_request |
First contact. The recipient cannot even reply until they accept the handshake, so sitting on it for three hours would stall the conversation before it starts. |
chat.calendar_share |
Actionable β the mentee is being asked to pick a slot. |
chat.appointment_request |
Time-sensitive: the slot is on a hold that lapses. |
For clients
The chat unread badge is unchanged and remains the immediate signal. unread_count on the
thread, the chat.message.sent websocket frame, and the thread list all still update the instant a
message lands. Only the notification inbox waits.
So do not treat the notification list as the source of truth for "do I have new messages" β it never was, and now it deliberately lags. Read unread state from the chat endpoints (messaging-api.md) and the realtime frames (realtime-websockets.md).
Re-tuning the gap
// config/constants.php
'chat' => [
'notification_gap_hours' => 3,
'notification_debounce_slack_minutes' => 15,
],
The slack keeps the debounce key alive slightly longer than the job it guards, so a message arriving near the end of the gap does not schedule a second, near-duplicate check.
Requires a queue worker. The deferral is a delayed job, so
chat.new_messagerows only ever appear where a worker is consuming the queue. Note that the test suite runsQUEUE_CONNECTION=sync, and the sync driver ignores->delay()β a test that sends a message and asserts a notification arrived proves nothing about the deferral.