MeetyyAPI
Documentation / API Reference / Notifications

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\BaseNotification and is identified by a case of App\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:

  1. Add the NotificationType case first (with a label()), then the class.
  2. Place the class in its feature folder β€” app/Notifications/{Booking|Chat|Community|Feed|Course|Mentor|Admin}/.
  3. Never write a via() method β€” declare defaultChannels() instead. The base class's final via() guarantees preference resolution applies.
  4. Eloquent models in the constructor are safe as public readonly β€” SerializesModels round-trips them as identifiers (verified on PHP 8.3 against a real Redis queue).
  5. Override sender() when a person triggers the notification β€” the base class adds the sender_* block to the payload for you; leave it alone for system notifications.
  6. Implement BypassesPreferences only when suppression would break a functional or security requirement (2FA codes, email verification, account-ban notices).
  7. 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)
Email 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 dev already includes a queue:listen process β€” fine for casual dev (queue:listen picks up code changes without restarts; queue:work is 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-alpine on 6379) with REDIS_CLIENT=predis (predis is installed; Windows has no phpredis extension) lets you exercise the production upgrade path, but nothing requires it.

composer.json pins config.platform (php 8.3.23, ext-pcntl, ext-posix) so Composer can resolve dependencies on Windows while laravel/horizon remains installed (unused β€” it's kept for the future Redis/Horizon upgrade). Do not remove this block.

3.2 Linux (production server)

  1. Queue worker β€” one systemd unit running queue:work on 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=3600 makes the worker exit hourly so systemd restarts it with fresh code and no memory creep. On every deploy also run php artisan queue:restart β€” workers finish their current job, then reload the new code.

  2. 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.

  3. Scheduler β€” single cron entry:

    * * * * * cd /var/www/meetyy-api && php artisan schedule:run >> /dev/null 2>&1
    
  4. Mail β€” real SMTP credentials (MAIL_MAILER=smtp). Local/dev stays on MAIL_MAILER=log.

  5. 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), its recipient_role_label, and is_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: true is a session the reader has to run, false is one they attend under their mentor. Derived per recipient by BookingParticipantRole::forBooking() at send time (the chat chat.appointment_request row 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_affected is 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 carries course_slug alongside course_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 carry module_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 Email 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 Email 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 carries community_slug alongside community_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. Adding feed.comment_replied / community.comment_replied later 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 no session.cancelled if 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 CourseNoticeService walks it with chunkById(200) rather than loading every enrolled user into memory to build one Notification::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 the NotificationType cases 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_settings is 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.
  • notification_sound is 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.xml pins QUEUE_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 in App\Notifications extends BaseNotification and is suffixed Notification.

  • Push (deferred) is tested with Http::fake(['fcm.googleapis.com/*' => …]); broadcast (L3) with Event::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_message rows only ever appear where a worker is consuming the queue. Note that the test suite runs QUEUE_CONNECTION=sync, and the sync driver ignores ->delay() β€” a test that sends a message and asserts a notification arrived proves nothing about the deferral.