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–5, deliver as declared
                            β”‚  3. config defaults (⏸ defer)β”‚
                            β”‚  4. user overrides  (⏸ defer)β”‚
                            β”‚  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. 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. Currently a passthrough; preference filtering arrives with the deferred preference matrix.
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,
        ];
    }
}

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. Implement BypassesPreferences only when suppression would break a functional or security requirement (2FA codes, email verification, account-ban notices).
  6. 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     60s;
    }
    

    Clients authorize private channels against POST /api/broadcasting/auth (note: not under /api/v1/) with their Sanctum bearer token.

  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)

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.

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 Every message posted into an Accepted thread Unread awareness; drives the badge count
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.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

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

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 booking (10-min slot hold created) Mentor must confirm or decline β€” 24 h timeout auto-cancels
session.confirmed Mentee In-app + Email High Mentor confirms the request Mentee's go-signal; payment step follows for paid sessions
session.cancelled The other party In-app + Email High Mentor declines, either party cancels, or the 24 h unconfirmed timeout sweeps it (bookings:expire-unconfirmed, hourly) Slot released; recipient must know plans changed
payment.received Mentor In-app + Email High Mentee completes checkout (BookingPaid) Revenue confirmation to the mentor
refund.issued Mentee In-app + Email High A refund is processed Money-movement notices are never optional
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.review_prompt 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 2 min before expires_at (not the scheduler β€” per-minute cron can't hit a 2-minute 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

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)
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

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. Each item: id, type (catalog value above), data (type-specific payload), read_at, created_at. Response also carries unread_count.
PATCH /api/v1/notifications/{id}/read Mark one as read
POST /api/v1/notifications/read-all Mark all as read

Notifications are role-agnostic: the same three 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",
          "data": { "type": "chat.new_message", "thread_id": "…", "sender_id": "…", "sender_name": "…", "preview": "…" },
          "read_at": null,
          "created_at": "2026-07-22T10:15:00+00:00"
        }
      ],
      "links": {},
      "meta": {}
    },
    "unread_count": 3
  }
}

5.2 Planned endpoints

Method Endpoint Milestone Purpose
GET /api/v1/notifications/preferences ⏸ deferred Full type Γ— channel matrix with effective values (defaults merged with the user's overrides)
PUT /api/v1/notifications/preferences ⏸ deferred Persist sparse overrides β€” only rows that differ from defaults are stored
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 Private-channel authorization for Reverb (Sanctum bearer). Channel: users.{id}.notifications

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.
    });

5.4 Preference semantics (⏸ deferred)

  • Defaults live in config/notifications.php β€” a transcription of the catalog above. The DB stores only the user's deviations, so a user who never touched settings costs zero rows.
  • Users see three switches per type: In-app / Email / Push. Real-time mirrors In-app and SMS is 2FA-only, so neither is exposed as a toggle.
  • Types marked bypass (Β§1) ignore preferences entirely and are not editable in the matrix.
  • Email is silently dropped for users with an unverified address; Push is dropped when the user has no registered device tokens.

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 πŸ”œ
⏸ Preference matrix + unsubscribe endpoints 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