Off by default. Switch it on before boot:
NOTIFICATIONS_ROUTES_ENABLED=on// config/notifications.php
'routes' => [
'enabled' => env('NOTIFICATIONS_ROUTES_ENABLED', 'off'),
'prefix' => 'notifications',
'middleware' => ['api'],
'auth_middleware' => ['auth:sanctum'],
],Every endpoint acts on $request->user() only — there is no recipient parameter. No user, even with auth_middleware emptied, is a 401. Inputs go through FormRequests, and rate limits are yours to add. Route names start with notifications. To change the routes, publish the file — the copy then replaces the package’s:
php artisan vendor:publish --tag="notifications-routes" # routes/notifications.php replaces the package's fileRoutes
| Method + path | Name | Does |
|---|---|---|
GET /inbox | inbox.index | filter[status]=active|unread|read|archived|all, filter[context], filter[category], filter[type], per_page → paginated InboxItemResource. |
GET /inbox/counts | inbox.counts | InboxCountsResource: unread, total, contexts[{key, label, unread}]. |
POST /inbox/read-all | inbox.read-all | Body context?, category?, type? → {"marked": n}. |
POST /inbox/{item}/read | inbox.read | Mark read → {"data": item}. |
DELETE /inbox/{item}/read | inbox.unread | Mark unread → {"data": item}. |
POST /inbox/{item}/archive | inbox.archive | Archive → {"data": item}. |
DELETE /inbox/{item}/archive | inbox.unarchive | Restore from the archive → {"data": item}. |
POST /inbox/{item}/acknowledge | inbox.acknowledge | Acknowledge the item’s subject and type → {"acknowledged": n}; 404 when the user can no longer see the subject; {"acknowledged": 0} for an item without a subject. |
GET /preferences | preferences.show | PreferencesResource: types with channels (enabled, default, locked, locked_reason), digest, quiet_hours, timezone. |
PATCH /preferences | preferences.update | channels[<type>][<channel>]=bool, categories[<category>][<channel>]=bool, digest{frequency,time}, quiet_hours{start,end}|null, timezone|null — all or nothing. |
GET /types | types.index | TypeResource list for a settings screen: key, category, label, description, channels, mandatory, priority, digestible. |
GET /notifications/inbox?filter[status]=unread&filter[context]=company:3&per_page=20
GET /notifications/inbox/counts
POST /notifications/inbox/read-all {"context": "company:3", "category": "requests"}
POST /notifications/inbox/12/read
DELETE /notifications/inbox/12/read
POST /notifications/inbox/12/archive
DELETE /notifications/inbox/12/archive
POST /notifications/inbox/12/acknowledge
GET /notifications/preferences
PATCH /notifications/preferences {"channels": {"comment.posted": {"mail": false}}, "quiet_hours": {"start": "22:00", "end": "07:00"}}
GET /notifications/types{item} is resolved inside the user’s inbox, never by global route-model binding: another user’s id or a malformed id is a 404:
{ "message": "The notification was not found.", "code": "inbox_item_not_found" }POST /inbox/{item}/acknowledge re-checks access first: a user who lost access to the subject — or whose item’s subject is gone or type unregistered — gets the same 404, so an old item never silences an escalation the others rely on.
PATCH /preferences
All or nothing: the request builds one PreferencesUpdate and calls preferences($user)->update(). Category toggles: false mutes, true clears the mute; quiet_hours: null and timezone: null clear them:
{
"channels": { "comment.posted": { "mail": false } },
"categories": { "contracts": { "mail": false } },
"digest": { "frequency": "daily", "time": "08:00" },
"quiet_hours": { "start": "22:00", "end": "07:00" },
"timezone": "Europe/Bratislava"
}Every part is checked before anything is written; a refused part is a Laravel validation error under its field and nothing is saved:
{ "message": "The notification \"security.alert\" is required and cannot be switched off. (and 1 more error)",
"errors": { "channels.security.alert.mail": ["The notification \"security.alert\" is required and cannot be switched off."],
"categories.junk": ["The notification category \"junk\" is not used by any registered type."] } }| Field | Refused when |
|---|---|
channels.<type> | An unregistered type, or a channel other than inbox / mail. |
channels.<type>.<channel> | A mandatory type switched off. |
categories.<category> | A category no registered type uses, or an unknown channel. |
quiet_hours / timezone | A bad time or an unknown timezone; null clears them. |
Errors
| Status | Meaning |
|---|---|
| 401 | No authenticated user — even with auth_middleware emptied. |
| 404 inbox_item_not_found | Another user’s item, a malformed id, or an acknowledge the user may no longer make. |
| 422 | Laravel validation errors; domain exceptions with a translated message and a stable code — mandatory_notification, invalid_quiet_hours, invalid_timezone, unknown_type, unknown_notification_category, invalid_data. |
An inbox item
{
"id": 12, "type": "request.awaiting_approval", "category": "requests", "title": "…", "body": null,
"url": "https://app.test/requests/9", "count": 10, "data": {"reference": "PR-9"},
"actor": {"id": 4, "type": "User"}, "subject": {"id": 9, "type": "PurchaseRequest"},
"context": {"key": "company:3", "label": "Acme s.r.o.", "id": 3, "type": "Company"},
"priority": {"value": "normal", "label": "Normal"}, "read_at": null, "archived_at": null,
"first_event_at": "2026-11-09T10:00:00+00:00", "last_event_at": "2026-11-09T10:04:31+00:00"
}Show your open-source love
This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.
More ways to support, including cryptoBy donating, you agree to our donation terms.
Want this built into your product?
We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.