kirchdev / laravel-notification-delivery
The layer Laravel's notifications leave out: stored notifications with read state, per-recipient channel preferences, and a gate chain that decides delivery before via() runs.
Package info
github.com/kirchDev/laravel-notification-delivery
pkg:composer/kirchdev/laravel-notification-delivery
Requires
- php: ^8.4
- illuminate/broadcasting: ^13.0
- illuminate/bus: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/database: ^13.0
- illuminate/events: ^13.0
- illuminate/notifications: ^13.0
- illuminate/queue: ^13.0
- illuminate/support: ^13.0
- spatie/laravel-package-tools: ^1.93.1
Requires (Dev)
- kirchdev/laravel-device-sessions: ^0.2 || ^0.6
- larastan/larastan: ^3.9
- laravel/pint: ^1.29
- orchestra/testbench: ^11.0
- pestphp/pest: ^5.0
- pestphp/pest-plugin-laravel: ^5.0
Suggests
- kirchdev/laravel-device-sessions: Enables the presence-aware suppression policy.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-06 18:09:08 UTC
README
๐ laravel-notification-delivery
The layer Laravel's notifications leave out โ stored notifications with read state, per-recipient channel preferences, and a gate chain that decides delivery before via() runs.
Notification::send($user, new MemberInvitedNotification($organisation)); $user->unreadNotificationCount(); // 1 โ from a store Laravel never kept
That's it. The notification is stored, the bell updates, the toast fires if the recipient wants it, and the mail goes out unless they said otherwise โ all decided before via() ever runs.
๐ค Why
Every application that needs a notification bell grows this layer itself, usually twice and never quite the same way.
This package is that layer and nothing else: every notification stays an Illuminate\Notifications\Notification, every channel stays a Laravel channel, Notification::send() stays the entry point. Only the decision changes hands.
๐ฆ Install & run
composer require kirchdev/laravel-notification-delivery php artisan vendor:publish --tag=notification-delivery-migrations php artisan migrate
Important
Publish the config first (--tag=notification-delivery-config) and set notification-delivery.keys.* + table_names.* before migrating โ the migrations read config at run time, and keys.notifiable_morph_key_type must match the key type of the models you notify. The publish step is required exactly once: the package never auto-loads its migrations, so your DDL stays reviewable, in your repository, and on your deploy pipeline's terms.
Warning
Replace Notifiable on your recipient โ don't add to it. Laravel's Notifiable is RoutesNotifications plus HasDatabaseNotifications, and the latter defines notifications() against Laravel's own table, which this package never writes. Left in place it is either a trait method collision or a relation that silently always comes back empty.
use Illuminate\Notifications\RoutesNotifications; use KirchDev\NotificationDelivery\Concerns\HasNotificationDelivery; class User extends Authenticatable { use HasNotificationDelivery; use RoutesNotifications; }
Declaring a notification type
A type is a stable string key on an enum, never a class name: classes move, and moved classes leave historical rows pointing nowhere.
enum OrganisationNotification: string implements NotificationType { case MemberInvited = 'organisation.member.invited'; // Static: the group belongs to the enum, not to the case. public static function group(): NotificationGroup { return Group::Organisation; } // One match, so the parts cannot drift apart and a missing case throws // UnhandledMatchError instead of silently yielding no channels. public function definition(): NotificationDefinition { return match ($this) { self::MemberInvited => new NotificationDefinition( default: [CoreChannel::Live, CoreChannel::Mail], locked: [CoreChannel::Inbox], ), }; } }
The notification itself extends TypedNotification and implements type() and payload(). Do not override via() โ that is the gate chain's method.
โจ Features
- ๐ฅ A real inbox โ stored notifications with read/unread state, so an offline recipient still finds it later. Keyed by a stable type string, not a class name: classes move, and moved classes leave historical rows pointing nowhere.
- ๐๏ธ Preferences that resolve three ways โ per type, per group, or the type's own default. Sparse storage means a new type ships with a sensible default and no backfill.
- ๐ช A gate chain before
via()โ availability, locked channels, the recipient's choice, then your own suppression policy. Four gates, in order, each able to stop the chain. - ๐งฉ Channels are an interface, not an enum โ the package ships
inbox,liveandmail; you add push, SMS or anything else with your own enum. Nothing here is closed. - โณ Deferred delivery โ
via()cannot wait, so a held-back channel leaves it and a job re-checks later, delivering only if the notification is still unread. - ๐ก One broadcast, two jobs โ the same event carries the data sync and the interruption, so switching the toast off never leaves the bell stale.
- ๐งฐ Config-driven schema โ models, table names, morph key column and key types (
id/uuid/ulid) all overridable. - ๐งช Library-grade โ Pest 5 + Testbench, no host app needed.
๐ฌ The three channels
| Channel | Does | Configurable | Quietable |
|---|---|---|---|
inbox |
Writes the row and fires NotificationBroadcasted. The truth. |
never | no |
live |
The toast in an open tab โ sets announce on the payload. |
per type | no |
mail |
Laravel's own mail channel. | per type | yes |
Quietable is gate 4's applicability test below. live is deliberately not โ the tab being open is the whole point of it, so holding it back for somebody who is present is the opposite of what presence means. Push, its sibling that interrupts through the OS, is.
๐ฆ The gate chain
Per notification and channel, in order. Each gate can stop the chain.
| # | Gate | Owner |
|---|---|---|
| 1 | Does the type know this channel, and can this recipient resolve it? | package |
| 2 | Is the channel locked? Then send, skipping gates 3 and 4. | package |
| 3 | Has the recipient switched it off? No row means undecided, not off. | package |
| 4 | Does the SuppressionPolicy say this is a bad moment? |
you |
Gate 4 defaults to NeverSuppress, because deciding what "quiet" means would force the package to know about presence, working hours and time zones. Installing kirchdev/laravel-device-sessions upgrades it to a presence-aware policy; binding the contract yourself replaces both.
Tip
"Inbox always, live when online, otherwise mail" needs no escalation logic at all. The inbox is locked and always runs, live sets announce and the open tab decides whether a toast appears, and a deferred mail is discarded as soon as read_at is set. Whoever saw the toast has read it; whoever was away has not.
๐ Inbox & preferences
The package ships no routes โ every operation is a plain action you call from your own controllers, so the response shape stays yours:
$inbox = app(ListNotifications::class)->execute($user, limit: 20); $unread = app(CountUnreadNotifications::class)->execute($user); app(MarkNotificationAsRead::class)->execute($user, $id); // scoped to $user โ an id from a request cannot reach another inbox app(UpdateNotificationPreference::class)->execute($user, $type, CoreChannel::Mail, false);
A preference resolves three tiers deep, and you decide which of them your UI offers:
1. row for (recipient, type, channel) โ wins
2. row for (recipient, group:โฆ, channel) โ otherwise
3. definition()->default โ otherwise
Tip
Passing null instead of false clears a preference rather than switching it off โ it puts the recipient back on the type's default, and is what a "reset" button wants.
The full action set, and why storage stays sparse
Six actions in all, under KirchDev\NotificationDelivery\Actions. Beyond the four above:
$inbox = app(ListNotifications::class)->execute($user, limit: 20, unreadOnly: true); app(MarkAllNotificationsAsRead::class)->execute($user); $grid = app(ListNotificationPreferences::class)->execute($user); // the settings page, with where each value came from
Every action takes the recipient first and scopes to their rows, so an id coming from a request cannot reach somebody else's notification.
The type column carries either a type key or a group:-prefixed one โ one prefix, not a second schema. Group-level is enough to start with; thirty individual switches are not a settings page anyone reads, and refining later costs a UI row and no migration.
Only deviations are stored, so a type added next month takes effect immediately, with its own default, for every recipient who never said anything about it โ and no backfill exists to forget to run.
๐ก Events & broadcasting
InboxChannel fires one event per stored notification:
Event::listen(function (NotificationBroadcasted $event) { $event->notification->publicId; // the stored row's key $event->notification->announce; // whether this one should interrupt });
It broadcasts on Laravel's own private channel convention (App.Models.User.1, or whatever the recipient answers from receivesBroadcastNotificationsOn()), so an application already broadcasting notifications keeps its channel authorisation.
Why live is one flag on one event, and not a second broadcast
The broadcast carries two things: the data sync โ counter and list, which must always run or the bell shows stale numbers until the next reload โ and the interruption, the toast that jumps into view unasked. Only the second is a delivery somebody would want to switch off ("put it in my inbox, but don't interrupt me").
So InboxChannel fires NotificationBroadcasted unconditionally, and the gate chain for live decides only whether announce: true sits on the payload. Splitting them into two events would mean a recipient who switched the toast off also stopped receiving counter updates.
Separately, NotificationDefinition carries broadcast: false for a bulk send that must not fan out ten thousand WebSocket events. That switches off the sync itself, and is a property of the type, never of a recipient's choice.
๐งฉ Extending it
Everything host-facing is a contract โ rebind it, never extend the shipped class:
| Contract | Default | Controls |
|---|---|---|
SuppressionPolicy |
NeverSuppress / device-sessions |
Gate 4 โ when a channel is a bad idea right now |
Channel |
CoreChannel |
Which channels exist, and how each reaches Laravel |
NotificationType |
yours | The type key, its group, its channel definition |
NotificationGroup |
yours | The bucket the middle preference tier stores against |
Adding push, SMS or a third-party channel
Channel is an interface precisely so the package's list is not the end of it:
enum PushChannel: string implements Channel { case Web = 'web_push'; public function laravelChannel(): string { return \NotificationChannels\WebPush\WebPushChannel::class; } public function isAvailableFor(object $notifiable): bool { return $notifiable->pushSubscriptions()->exists(); } // userConfigurable(), isQuietable(), sortOrder() โฆ }
Then list it in notification-delivery.channels.enums.
This package does not build push, deliberately. laravel-notification-channels/webpush already ships the channel, the subscription table, a HasPushSubscriptions trait, VAPID key generation and the cleanup of expired endpoints; โฆ/fcm and โฆ/apn cover native push. There is no subscription table here โ that belongs to whichever push package you choose.
isAvailableFor() earns its place on the interface rather than being a push special case: Laravel silently skips a channel when routeNotificationFor() returns nothing, which is fine for delivery and wrong for a settings page โ it would otherwise offer a Telegram switch to somebody who never stored a Telegram id.
Important
Resolve models through config('notification-delivery.models.*') โ never new, ::query() or ::find() on KirchDev\NotificationDelivery\Models\*. An application without a morph map stores the class it resolved, so a row written through the packaged class names a different class than every row beside it and quietly stops matching.
๐๏ธ Data model
| Table | Holds |
|---|---|
delivered_notifications |
notifiable_type, morph key, type, payload, read_at, timestamps |
notification_preferences |
notifiable morph, type, channel, enabled |
Three schema decisions worth knowing before you extend it
- The recipient is a morph, not a
user_id. An invitation sent to an address with no account yet still needs a row, and aNOT NULL user_idhas none for it. - There is no tenant column. Filtering an inbox by the active tenant produces a bell that hides things depending on where the reader happens to be standing. The tenant belongs in
payload, where the message text needs it anyway โ and the package then needs no notion of organisations at all. Disagree? Published migrations are yours; add the column. - The table is not called
notifications. Laravel's own database channel claims that name, and leaving it free is what lets you keep using that channel alongside this package.
๐งน Pruning
php artisan notification-delivery:prune # windows from config โ both null by default
php artisan notification-delivery:prune --days=365 --read-days=90
Two windows, because read notifications go stale faster than unread ones. Both default to null: never delete. A record of who was invited when can be the reason somebody still has access years later, and that call is yours, not a package default's. The command ships unscheduled โ wire it into your scheduler with Schedule::command('notification-delivery:prune')->dailyAt('03:20').
โ๏ธ Configuration
Four keys are the ones you actually set:
'keys' => ['primary_key_type' => 'id', 'notifiable_morph_key_type' => 'id'], 'channels' => ['enums' => [CoreChannel::class, App\Enums\PushChannel::class]], 'discovery' => ['types' => [App\Enums\Notification\OrganisationNotification::class]], 'suppression' => ['policy' => null, 'presence_window' => 300, 'defer' => 120],
channels.enums and discovery.types are what a settings page enumerates โ sending needs neither; suppression.policy of null means auto (never suppress, or the presence-aware policy when device-sessions is installed). The rest โ models, table names, the morph key column and the two retention windows โ is documented inline in config/notification-delivery.php.
๐ Translation runs on two tracks
Unavoidably: the inbox translates in the frontend, so a language switch takes effect without a reload; mail translates server-side, in the recipient's locale. payload() therefore returns a translation key and its arguments, never a rendered string.
Same key on both sides โ and write the test that asserts every type has an entry on both. Without it, drift is certain and only the recipient notices.
๐งช Testing
composer install && composer test
Pest 5 + Testbench with in-memory SQLite โ no host app required. The full gate (Pint, Larastan, the Node tooling) is in CONTRIBUTING.md.
๐ค Contributing
PRs welcome โ see CONTRIBUTING.md. Conventional Commits required (enforced via commitlint). Husky runs Pint + Larastan + oxlint + oxfmt on git commit, so you can mostly forget about style.
Tip
Run pnpm check:fix (Node tooling) and composer pint:fix (PHP) before pushing โ CI will catch what husky missed.
๐ฃ๏ธ Versioning
Semantic Versioning via release-please โ see CHANGELOG.md.