Settings API
Per-user preferences — everything the Settings screen writes. One table (user_settings), one
row per user, grouped into notification and general settings.
All endpoints require auth:sanctum and are scoped to $request->user(). There is no {user}
segment and no admin variant: a token can only ever read or write its own settings.
1. Design
A row is written only when something changes. A user who has never opened the screen has no row at all — reads fall back to the defaults declared on the key enums. Registration therefore costs no extra insert, and "never configured" stays indistinguishable from "configured to the defaults", which is what the UI wants.
The enums are the single source of truth. Which switches exist, what each defaults to, its
label and description, and — for notification switches — which NotificationType cases it gates,
are all declared once:
| Piece | Path | Role |
|---|---|---|
NotificationSettingKey |
app/Enums/Setting/NotificationSettingKey.php |
One case per notification switch: copy, default, and the NotificationType set it gates |
GeneralSettingKey |
app/Enums/Setting/GeneralSettingKey.php |
One case per general setting: copy, default, validation rules, storage cast |
SettingTheme |
app/Enums/Setting/SettingTheme.php |
system / light / dark |
UserSetting |
app/Models/Setting/UserSetting.php |
The row. Casts are derived from the enums, so a new switch is an enum case plus a migration — never a third place to forget |
UserSettingService |
app/Services/Setting/UserSettingService.php |
Reads, partial writes, and the two questions the notification resolver asks. Singleton — see §4 |
Because labels and descriptions ship from the API, a new switch appears on the settings screen without a frontend release, and the copy can never drift between the two codebases.
2. Notification settings
GET /api/v1/settings/notifications
{
"status": "success",
"message": "Notification settings retrieved successfully.",
"data": {
"notification_settings": {
"notification_sound": true,
"session_updates": true,
"earnings_withdrawals": true,
"user_feedback": true,
"email_updates": true,
"platform_alerts": true
},
"settings": [
{
"key": "notification_sound",
"label": "Enable all notification Sound",
"description": "News about product and feature update.",
"value": true
}
]
}
}
Two shapes of the same data, deliberately: notification_settings is the flat map to bind form
state to, settings is the ordered, pre-labelled list to render the screen from. Use whichever
suits; they never disagree.
PUT /api/v1/settings/notifications
A sparse partial update — only the keys you send are changed. Every key is
sometimes|boolean.
{ "session_updates": false, "email_updates": false }
Responds 200 with exactly the same body shape as the GET. Sending an unknown key is ignored;
sending a non-boolean is a 422.
The switches
| Key | Label | Default | Effect when off |
|---|---|---|---|
notification_sound |
Enable all notification Sound | on | Nothing server-side — a client rendering hint. Delivery is unaffected |
session_updates |
Session Updates | on | Suppresses session.booked · .confirmed · .cancelled · .completed · .reminder_24h · .reminder_1h · slot_hold.expiring · booking.followup_reminder · availability.booked_slots_affected · chat.appointment_request |
earnings_withdrawals |
Earnings and Withdrawals | on | Suppresses payment.received · refund.issued |
user_feedback |
User Feedback | on | Gates course.reviewed — see §5 |
email_updates |
Get Email for any update | on | Strips the mail driver from every non-bypass notification; in-app and real-time are untouched |
platform_alerts |
Platform/System Alerts | on | Suppresses content.moderated · content.warning · mentor.suspended · mentor.reinstated · mentor.approved · mentor.rejected · mentor.document_approved · mentor.document_rejected · community.pricing_change |
Types no switch gates — chat messages, feed activity, community posts and mentions, course
notices — are always delivered from this screen. Communities carry their own per-community
preferences at GET|PUT /api/v1/communities/{community}/notification-preferences.
Types that bypass settings entirely — email verification, both two-factor codes, and the
account.suspended / .banned / .reinstated notices — deliver no matter what is switched off.
Suppression there would break a security or functional requirement, so they are marked
BypassesPreferences and are not editable. See
Notification System §1.
3. General settings
GET /api/v1/settings/general · PUT /api/v1/settings/general
| Key | Type | Default | Rules |
|---|---|---|---|
theme |
string | system |
in:system,light,dark |
locale |
string | en |
2 characters |
show_online_status |
boolean | true |
boolean |
The GET also returns options.theme for the picker.
GET /api/v1/settings
Every group in one read, so the settings screen loads in a single request:
{
"status": "success",
"message": "Settings retrieved successfully.",
"data": {
"settings": {
"notifications": { "session_updates": true, "…": true },
"general": { "theme": "system", "locale": "en", "show_online_status": true }
},
"options": { "theme": { "system": "Match System", "light": "Light", "dark": "Dark" } }
}
}
4. How enforcement actually works
Notification classes never consult settings themselves. BaseNotification::via() is final and
routes every notification in the application through NotificationChannelResolver, which asks
UserSettingService two questions:
allowsType()— is any switch gating thisNotificationTypeoff? If sovia()returns[]and Laravel sends nothing, on any channel.allowsChannel()— is a channel switch (today onlyemail_updates→mail) off? If so that driver is stripped and the rest of the notification delivers normally.
Bypassing notifications and non-User notifiables (on-demand mail) skip both.
UserSettingService is registered as a singleton in AppServiceProvider and memoizes each
user's row for the request. Without that, a 500-recipient fan-out would cost 500 settings
lookups, since the resolver is invoked once per notification. For bulk sends, prefer narrowing the
recipient set up front:
$recipients = app(UserSettingService::class)->filterSubscribed(
$users,
NotificationSettingKey::EarningsWithdrawals,
);
One query for the whole set, and users with no row are kept whenever the switch defaults to on.
5. user_feedback — now live, partially
The User Feedback switch was wired and inert for a while: it persisted and read back
correctly, but gated no types, because the catalog had no review/rating notification. It was left
inert on purpose rather than mapped to an approximate type — session.completed is the prompt to
give feedback, not a notice of receiving it, so gating it there would have suppressed the
wrong thing.
It now gates course.reviewed, which shipped with the course-system contract and is exactly a
notice of receiving feedback. Turning the switch off stops a course author being told about new
reviews on their courses.
Still to come: session.review_received. When that type exists, add it to
NotificationSettingKey::UserFeedback->types() and it is covered with no other change.
6. Tests
tests/Feature/Setting/UserSettingTest.php — defaults without writing a row · label/description
copy · partial updates leaving other switches alone · 422 on a non-boolean · 401 for a guest ·
category suppression end-to-end · email_updates stripping mail while keeping in-app and
real-time · a bypassing notification still delivering with every switch off · types no switch
gates staying on · both groups sharing one row · filterSubscribed() narrowing a recipient set.