amjadiqbal / laravel-notification-inbox
Catch every non-mail Laravel notification in local and staging instead of sending it: web push, FCM, APNs, Expo, Slack, Microsoft Teams, Telegram, Discord, Twilio and Vonage SMS, and broadcast. Realistic previews in one inbox, plus test assertions on the real payloads.
Package info
github.com/amjadiqbal/laravel-notification-inbox
pkg:composer/amjadiqbal/laravel-notification-inbox
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/notifications: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Laravel Notification Inbox: catch and preview push, Slack, Teams, Telegram, Discord and SMS notifications
Mailpit catches your mail. Nothing catches the rest. In local and staging, this package stops every non-mail Laravel notification before it is sent (web push, FCM, APNs, Expo, Slack, Microsoft Teams, Telegram, Discord, Twilio and Vonage SMS, broadcast) and shows each one in a single inbox as it would look on the other end: a push alert on a phone lock screen, a Slack Block Kit message, a Teams Adaptive Card, a Telegram bubble with its buttons, a Discord embed, an SMS thread with its segment count.
It builds every message with the channel's own toX() method and route, exactly as the real channel
would, so your tests can assert against the real payloads, and a notification that would crash in
production crashes here first.
Docs and demo: https://amjad.com.pk/laravel/packages/laravel-notification-inbox
composer require amjadiqbal/laravel-notification-inbox --dev
That is the whole setup. Send a notification, then open /notification-inbox.
Installing with
--devkeeps it off production servers that runcomposer install --no-dev. If your staging server installs without dev packages and you want the inbox there, require it normally: the environment allow-list keeps it off in production.
Contents
- What it catches
- Safety: it cannot catch production by accident
- The inbox
- Testing against real payloads
- Configuration
- Your own channels
- Personal data and retention
- FAQ
What it catches
| Channel | Package | Preview |
|---|---|---|
| Web push | laravel-notification-channels/webpush |
Phone lock-screen alert with actions |
| FCM, FCM topic | laravel-notification-channels/fcm |
Phone alert with image |
| APNs, APNs VoIP | laravel-notification-channels/apn |
Phone alert with subtitle and app badge |
| Expo | laravel-notification-channels/expo |
Phone alert with badge |
| OneSignal, Pushover | laravel-notification-channels/onesignal, /pushover |
Phone alert |
| Google Chat | laravel-notification-channels/google-chat |
The payload as JSON (no look-alike yet) |
| Slack | laravel/slack-notification-channel (Block Kit and legacy attachments) |
Slack message: header, sections, fields, context, dividers, images, buttons |
| Microsoft Teams | laravel-notification-channels/microsoft-teams |
Adaptive Card (and the legacy MessageCard) |
| Telegram | laravel-notification-channels/telegram |
Chat bubble with Markdown or HTML, media, inline keyboard |
| Discord | laravel-notification-channels/discord |
Message with embeds, fields and buttons |
| Twilio, Vonage, MessageBird, Plivo, AWS SNS | their channel packages | SMS thread with GSM-7 / UCS-2 encoding and segment count |
| Broadcast | Laravel | Channels, event name, data and the matching Echo listener |
| Anything else | your channel | The payload as JSON, or map it to a preview |
mail and database are not caught: mail already has Mailpit, Herd and friends, and the database
channel only writes to your own database. Both lists are configurable.
Non-mail notification channels are caught instead of sent. Laravel fires NotificationSending before
it even resolves a channel's driver, and the inbox cancels the send right there, so the channel's HTTP
client, SDK and credentials are never touched. Queued notifications are caught in the worker the same way.
What still runs is your own code: the inbox calls your toX() and routeNotificationFor…() methods to
build each message, exactly as the channel would, so anything those methods do themselves (an HTTP call to
shorten a link, say) still happens. Mail and database notifications are passed through and sent as usual.
Safety: it cannot catch production by accident
A notification catcher left on in production silently swallows every message your users should get. So:
- Environment allow-list. It only runs in the environments you list. The default is
localonly. Add staging withNOTIFICATION_INBOX_ENVIRONMENTS=local,staging. - Production needs two switches. Listing
productionalone does nothing (and logs a warning when artisan runs there). It also needsNOTIFICATION_INBOX_ALLOW_PRODUCTION=true. With both set, it logs a warning on every request,php artisan aboutsays so in red, and the inbox shows a red banner. - The dashboard is behind a gate.
viewNotificationInboxonly opens inlocalby default. Where the inbox is off, its routes do not exist at all (404). - It fails closed. While the inbox is on, a notification is never sent, even if storing it fails (that is logged instead).
- It never halts other listeners. For channels it lets through, its
NotificationSendinglistener returnsnull, so your own listeners and other packages keep working. - It runs first. Laravel stops at the first
NotificationSendinglistener that returns something, so the inbox registers its listener while the app is still registering services, ahead of every listener added in a service provider'sboot()and the app's own. Only a package that adds a listener in its ownregister(), earlier than this one, could still decide first; if such a listener returnstrueorfalse, that wins. - Looking is not deleting. Delete and Clear all need a second gate,
manageNotificationInbox, which also only opens inlocalby default.
To open the dashboard on staging, define the gate in AppServiceProvider::boot():
use Illuminate\Support\Facades\Gate; Gate::define('viewNotificationInbox', fn ($user) => $user?->is_admin); Gate::define('manageNotificationInbox', fn ($user) => $user?->is_admin); // delete and clear
The inbox
Open /notification-inbox (configurable).
- New notifications appear without a reload.
- Filter by channel, or search the text, recipient and notification class.
- Each notification has three tabs: Preview, Payload (the data the channel would have sent) and Details (notification and message class, notifiable, route, queued or not, notification id).
- "Also sent via" links the same notification on its other channels.
- Previews flag real problems: an SMS that splits into several billed segments, or switched to UCS-2 because of one emoji; a push with no title or body; a Slack message with no fallback text; a Telegram or Discord message over the length limit.
- If a
toX()method throws, the inbox shows the exception instead of a preview, because the real channel would have failed too.
Delete one, or clear all. Light and dark mode follow your system. There is no JavaScript build: one Blade view with inline CSS, so there is nothing to publish.
Testing against real payloads
Notification::fake() never calls your toX() methods, so a broken Slack block or a missing chat id
passes your tests. NotificationInbox::fake() lets Laravel build every message for real, keeps it in
memory, and gives you assertions on the result:
use AmjadIqbal\NotificationInbox\Facades\NotificationInbox; it('tells the customer their order shipped', function () { NotificationInbox::fake(); $user->notify(new OrderShipped($order)); NotificationInbox::assertCaptured(OrderShipped::class) ->assertCapturedOn('telegram', fn ($n) => str_contains($n->body(), 'A-1042')) ->assertCapturedFor($user) ->assertCapturedFor('+447700900123') // a route: phone number, chat id, channel id ->assertCapturedContaining('Track parcel', 'slack') ->assertNoCaptureErrors(); // every toX() built without throwing });
| Assertion | Passes when |
|---|---|
assertCaptured(Notification::class, ?callback) |
at least one matches |
assertCaptured(Notification::class, 2) or assertCapturedTimes(...) |
exactly that many, across channels |
assertNotCaptured(Notification::class, ?callback) |
none match |
assertNothingCaptured(), assertCapturedCount(3) |
totals |
assertCapturedOn('slack', ?callback), assertNotCapturedOn(...) |
by channel: alias, short name or channel class |
assertCapturedFor($user or '+1555…', ?callback) |
by notifiable model or route |
assertCapturedContaining('text', ?channel) |
the title or body contains the text |
assertNoCaptureErrors() |
no toX() threw |
Callbacks receive a CapturedNotification with title(), body(), text(), buttons(), preview()
(the normalised fields), payload (the raw data), route, channel, notifiable and more. To dig in
yourself, NotificationInbox::captured(OrderShipped::class) returns a collection and
NotificationInbox::first(...) the first match. A failing assertion lists what was captured.
fake() works in any environment, including testing, and never writes to disk.
Configuration
Everything works without publishing the config. To change it:
php artisan vendor:publish --tag=notification-inbox-config
| Key | Env | Default |
|---|---|---|
enabled |
NOTIFICATION_INBOX_ENABLED |
true |
environments |
NOTIFICATION_INBOX_ENVIRONMENTS (comma-separated) |
local |
allow_production |
NOTIFICATION_INBOX_ALLOW_PRODUCTION |
false |
intercept |
['*']: every channel, or a list of aliases, short names or classes |
|
pass_through |
['mail', 'database']: always sent normally |
|
channels |
your own channel definitions, below | |
storage.path |
NOTIFICATION_INBOX_PATH |
storage/notification-inbox |
retention_hours |
NOTIFICATION_INBOX_RETENTION_HOURS |
72 |
max_messages |
NOTIFICATION_INBOX_MAX_MESSAGES |
1000 |
prune_lottery |
[1, 50]: about one capture in fifty also prunes; [0, 1] never |
|
redact |
exact key names masked before storing: auth, authorization, p256dh, api_key, apikey |
|
redact_containing |
keys containing any of these are masked: password, passcode, secret, token, otp, code, private_key, webhook |
|
mask_codes_in_text |
NOTIFICATION_INBOX_MASK_CODES |
false; true masks 4 to 8 digit numbers inside message text |
dashboard.enabled |
NOTIFICATION_INBOX_DASHBOARD |
true |
dashboard.path |
NOTIFICATION_INBOX_DASHBOARD_PATH |
notification-inbox |
dashboard.middleware |
['web'] (the gates are always added) |
|
dashboard.poll_seconds |
3; 0 turns live updates off |
php artisan about shows whether the inbox is catching notifications in the current environment.
Your own channels
An unknown channel is caught too. The inbox guesses the Laravel convention from its class name
(App\Channels\WhatsAppChannel calls toWhatsApp() and routes with whatsApp) and shows the payload as
JSON. To get a look-alike preview, tell it how your channel works:
// config/notification-inbox.php 'channels' => [ App\Channels\WhatsAppChannel::class => [ 'label' => 'WhatsApp', 'method' => 'toWhatsApp', // what your channel calls on the notification 'route' => 'whatsapp', // the routeNotificationFor() key it reads 'preview' => 'sms', // push, slack, teams, telegram, discord, sms, broadcast or json ], ],
Personal data and retention
Captured notifications contain whatever your notifications contain: phone numbers, one-time codes, password-reset links. So:
- Each one is stored as a JSON file in
storage/notification-inbox, with a.gitignoreso they never end up in git. Nothing goes into your database and there is no migration. - The notifiable is stored as its type, key and a label (
User #42), never its attributes. - Before writing, keys named in
redactand keys containing a word fromredact_containing(reset_token,otp_code,client_secret…) are masked, and webhook routes (Slack, Teams, Discord) keep only their host, because the path of a webhook URL is its secret. - One-time codes inside the message text ("Your code is 482913") are stored as they are unless you set
NOTIFICATION_INBOX_MASK_CODES=true, which masks every 4 to 8 digit number in the text (order numbers and prices too, which is why it is off by default). Turn it on for shared staging servers. NotificationInbox::fake()keeps everything in memory and unredacted, so tests can assert on it.- Pruning is opportunistic: about one capture in fifty (
prune_lottery) also deletes notifications older thanretention_hoursand the oldest beyondmax_messages. A quiet inbox is therefore not pruned on its own, so on a shared staging server schedule the prune command:
// routes/console.php Schedule::command('notification-inbox:prune')->hourly();
| Command | Does |
|---|---|
php artisan notification-inbox:prune |
deletes everything older than retention_hours (or --hours=6) |
php artisan notification-inbox:clear |
deletes everything |
FAQ
Does it replace Mailpit? No. Mail passes straight through to whatever catches it today. This covers every other channel.
Telescope already records notifications. Telescope records that a notification was sent, and still sends it. This stops it and shows what would have arrived.
Do I need the channel packages installed? Only the ones your app uses. The inbox catches the send before Laravel resolves the channel, so you can preview a channel whose credentials are not configured yet.
Can it resend a caught notification for real? Not in this version. Rebuilding a notification safely outside its original request needs its constructor arguments, which are not stored.
Does it work with queued notifications and Horizon? Yes. The worker builds the message and the inbox catches it there; the Details tab shows it was queued.
Need this built into your app, or something custom?
I build Laravel packages and notification flows: push, SMS, Slack and Teams alerts, Telegram and Discord bots, and the queues behind them. If you need a custom channel, or this wired into your app, get in touch.
Support
- 🐛 Bug or feature request: open an issue
- 🔒 Security issue: please report privately via GitHub's security tab
More packages
Part of a family of open-source packages. See all of them.
| laravel-mysql-advisor | MySQL and MariaDB health checks with the SQL to fix each one |
| kiln | Deploy-time OPcache control for Laravel |
| refresher | Cache your test DB schema, skip migrate:fresh on every test run |
| laravel-driver-js | Product tours and onboarding for Laravel |
Changelog
See CHANGELOG.md.
License
MIT. See LICENSE.md. Slack, Microsoft Teams, Telegram, Discord, Twilio, Vonage, Firebase, Apple Push Notification service and Expo are trademarks of their owners. This package is independent, not affiliated with any of them, and its previews are approximations drawn without their logos.
