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β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:
- 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). - 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 60s; }Clients authorize private channels against
POST /api/broadcasting/auth(note: not under/api/v1/) with their Sanctum bearer token. -
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) |
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 | 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 | 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.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 |
π |
| βΈ | 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 |