pxlrbt / laravel-notification-preview
Browse, preview and test-send every notification and mailable in your Laravel app – mail as HTML and text, other channels as their payload.
Package info
github.com/pxlrbt/laravel-notification-preview
pkg:composer/pxlrbt/laravel-notification-preview
Requires
- php: ^8.2
- composer-runtime-api: ^2.0
- illuminate/contracts: ^11.0|^12.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.0
- orchestra/testbench: ^10.0
- pestphp/pest: ^3.0
- pestphp/pest-plugin-laravel: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Browse, preview and test-send every notification and mailable in your Laravel app.
Features
- Discovers every notification and mailable in the directories you point it at.
- Builds each one for you; supply your own data with resolvers and named variants.
- Renders the HTML and the plain-text side of a mail, at desktop, tablet and mobile widths.
- Shows every other channel as the JSON payload it would hand its provider.
- Switches locale, edits scalar arguments in place and sends a real test mail to any address.
- Never registers its routes in production; gate it anywhere else with an auth closure.
Installation
composer require pxlrbt/laravel-notification-preview --dev php artisan vendor:publish --tag=notification-preview-config
The routes register themselves and are always disabled in the production
environment. Open the preview at /dev/notifications.
Configuration
// config/notification-preview.php return [ 'enabled' => env('NOTIFICATION_PREVIEW_ENABLED', true), // The kinds of classes to look for. Turn either off to skip it entirely. 'notifications' => env('NOTIFICATION_PREVIEW_NOTIFICATIONS', true), 'mailables' => env('NOTIFICATION_PREVIEW_MAILABLES', true), 'url_prefix' => env('NOTIFICATION_PREVIEW_URL_PREFIX', 'dev/notifications'), 'middleware' => ['web'], // Directories, scanned recursively. 'paths' => [ app_path('Notifications'), app_path('Mail'), ], // Class names and namespaces to hide. 'exclude' => [], // null falls back to the directories inside lang_path() 'locales' => null, 'test_email' => env('NOTIFICATION_PREVIEW_TEST_EMAIL'), ];
paths takes directories only — namespaces come from Composer's PSR-4 map. Point
it at a whole domain directory if you like; abstract classes and anything that is
neither a notification nor a mailable are skipped.
Both mailable styles work: envelope()/content() and the older build().
Other channels
Mail is the only channel rendered as a message. Every other one is shown as the JSON payload it would hand its provider. Opt them in:
'channels' => ['mail', 'database', 'smsapi'],
Each notification gains one tab per channel its via() declares. Names match
driver strings and channel classes alike, so smsapi also covers an
SmsapiChannel::class.
Authorization
By default there is no check: outside production, anybody who can reach the URL can open the preview. Narrow that down with an auth closure:
use pxlrbt\LaravelNotificationPreview\Facades\NotificationPreview; // Local machines only. NotificationPreview::auth(fn () => app()->isLocal()); // Or specific people, wherever they are. NotificationPreview::auth(fn (?Request $request) => $request?->user()?->isAdmin());
Anyone the closure turns down gets a 403.
Hiding classes
exclude takes fully qualified class names and namespaces. A namespace hides
everything below it:
'exclude' => [ App\Notifications\Internal\DebugPing::class, // one class 'App\Notifications\Internal', // the whole namespace ],
Argument resolution
For each constructor parameter the preview tries these sources, in order, and takes the first one that answers:
| # | Source | Applies to |
|---|---|---|
| 1 | A value edited in the UI | Scalars, enums and dates |
| 2 | A registered resolver | The parameter reference first, then its type |
| 3 | The parameter's default value | Anything with a default |
| 4 | A value derived from the type | See below |
| 5 | A faked scalar derived from the parameter name | $email, $url, $name and $*Id get plausible values, everything else gets 'Sample text' |
Step 4 derives from the type itself:
| Type | Value |
|---|---|
| Enum | Its first case |
| Date | now() |
| Model | Model::factory()->make(), falling back to Model::query()->first() |
| Collection | Empty |
| Anything else | Resolved from the container |
Supplying your own data
Resolvers
Register these in a service provider, keyed by type:
use pxlrbt\LaravelNotificationPreview\Facades\NotificationPreview; NotificationPreview::resolve(Order::class, fn () => Order::factory()->make(['id' => 1]));
Untyped parameters cannot be matched by type, so key them by parameter instead. A key registered against a parent class covers every subclass:
NotificationPreview::resolve( StateNotification::class.'::$model', fn () => Order::factory()->make(), );
The notifiable that notifications are rendered against works the same way:
NotificationPreview::notifiable(fn () => User::factory()->make(['id' => 1]));
Per-notification configuration
for() returns one object holding everything the viewer shows about a single
notification — its label, its group and its variants:
use pxlrbt\LaravelNotificationPreview\Facades\NotificationPreview; use pxlrbt\LaravelNotificationPreview\Variant; NotificationPreview::for(OrderCancelled::class) ->label('Cancellation') ->group('Orders') ->variants([ Variant::make('by-customer', fn () => (new OrderCancelled($order))->setReason('customer')), Variant::make('by-us', fn () => (new OrderCancelled($order))->setReason('internal')), ]);
Variants are for notifications that need more than constructor arguments — setters, a specific state, a particular payload. A variant returns the finished notification and skips argument resolution entirely.
Labels are derived when you do not set them: from the class name for the
notification, from its key for a variant, so by-customer shows as
"By Customer". label() and group() both take a string or a closure, and a
closure defers a translation until the preview is rendered rather than resolving
it while your service provider boots:
NotificationPreview::for(OrderCancelled::class) ->label(fn () => __('notifications.order_cancelled')) ->group(fn () => __('notifications.groups.orders')); Variant::make('by-customer', $factory)->label(fn () => __('variants.by_customer'));
Variants can pin their own notifiable:
NotificationPreview::for(OrderShipped::class)->variants([ Variant::make('to-the-buyer', fn () => new OrderShipped($order)) ->notifiable(fn () => User::factory()->make(['id' => 2])), ]);
Configuration on the notification itself
To keep the preview data next to the notification, add a static preview()
method. Same object, no interface to implement:
use pxlrbt\LaravelNotificationPreview\Preview; use pxlrbt\LaravelNotificationPreview\Variant; class OrderShipped extends Notification { public static function preview(Preview $preview): void { $preview ->label('Shipping confirmation') ->group('Orders') ->variants([ Variant::make('express', fn () => new self(Order::factory()->express()->make())), Variant::make('standard', fn () => new self(Order::factory()->make())), ]); } }
Both sources merge, and anything registered through for() wins — per property,
and per variant key. A class that declares a label and a service provider that
sets only a group end up with both.
Registering and excluding classes
NotificationPreview::register(SomePackage\Notifications\Welcome::class); // Same matching rules as the `exclude` config key. NotificationPreview::exclude(InternalDebugNotification::class); NotificationPreview::exclude('App\Notifications\Internal');
Testing
composer test
composer analyse
composer format
