NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

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 file

Routes

Method + pathNameDoes
GET /inboxinbox.indexfilter[status]=active|unread|read|archived|all, filter[context], filter[category], filter[type], per_page → paginated InboxItemResource.
GET /inbox/countsinbox.countsInboxCountsResource: unread, total, contexts[{key, label, unread}].
POST /inbox/read-allinbox.read-allBody context?, category?, type? → {"marked": n}.
POST /inbox/{item}/readinbox.readMark read → {"data": item}.
DELETE /inbox/{item}/readinbox.unreadMark unread → {"data": item}.
POST /inbox/{item}/archiveinbox.archiveArchive → {"data": item}.
DELETE /inbox/{item}/archiveinbox.unarchiveRestore from the archive → {"data": item}.
POST /inbox/{item}/acknowledgeinbox.acknowledgeAcknowledge 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 /preferencespreferences.showPreferencesResource: types with channels (enabled, default, locked, locked_reason), digest, quiet_hours, timezone.
PATCH /preferencespreferences.updatechannels[<type>][<channel>]=bool, categories[<category>][<channel>]=bool, digest{frequency,time}, quiet_hours{start,end}|null, timezone|null — all or nothing.
GET /typestypes.indexTypeResource 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."] } }
FieldRefused 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 / timezoneA bad time or an unknown timezone; null clears them.

Errors

StatusMeaning
401No authenticated user — even with auth_middleware emptied.
404 inbox_item_not_foundAnother user’s item, a malformed id, or an acknowledge the user may no longer make.
422Laravel 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 crypto

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