mirovit/nova-notifications
mirovit/nova-notifications integrates Laravel notifications with Nova, adding a notification bell or inbox to your admin panel so you can send, display, and manage user notifications. It provides a clean, native Nova experience with simple setup for Laravel apps.
1. Install and publish
composer require mirovit/nova-notifications
php artisan vendor:publish --tag=nova-notifications # if shipped; check the provider's publishes() calls
This is a Nova tool, not a resource, so it does not appear in the sidebar by default — it renders as a bell in the Nova header. There is almost certainly nothing to migrate: it reads Laravel's stock notifications table, so it is a no-op if you already use database notifications.
2. Satisfy the backend prerequisites
php artisan notifications:table
php artisan migrate
Confirm the model you authenticate against uses the trait and that Nova's guard resolves to it:
use Illuminate\Notifications\Notifiable;
class User extends Authenticatable
{
use Notifiable;
}
If Nova uses a different model (e.g. Admin), the tool will query notifications for that model — so whichever class is behind config('nova.guard') is the one that must be Notifiable.
3. Register the tool
// app/Nova/NovaServiceProvider.php
use Mirovit\NovaNotifications\NovaNotifications;
public function tools()
{
return [
new NovaNotifications(),
];
}
4. Where to look first in the source
The package is small; read it in this order before you write any application code:
src/NovaNotifications.php — the Laravel\Nova\Tool subclass. This is where boot(), route registration, and any menu/asset wiring lives. If the bell does not appear, the answer is here.routes/ or the provider's boot() — the tool's HTTP endpoint. Note its middleware group and how it resolves the current user.src/Http/Controllers/* — the list / mark-as-read actions. This is where the payload-to-Vue contract is defined.resources/js/ + dist/ — the Vue component that renders the dropdown. Read the .vue file to learn the exact payload keys it expects; dist/ is prebuilt and you should not need to touch it.config/ — polling interval, page size, and display toggles, if any.5. First use case: get one notification into the bell
Write a minimal notification that writes a database row with the payload shape the Vue component expects (title, message/body, level, optional url):
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use Illuminate\Contracts\Queue\ShouldQueue;
class OrderFailed extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(public array $payload) {}
public function via($notifiable): array
{
return ['database'];
}
public function toArray($notifiable): array
{
return [
'title' => 'Order failed',
'message' => "Order #{$this->payload['id']} could not be charged.",
'level' => 'error', // confirm allowed values in the Vue component
'url' => "/resources/orders/{$this->payload['id']}",
];
}
}
Seed one and reload Nova:
php artisan tinker
>>> App\Models\User::first()->notify(new App\Notifications\OrderFailed(['id' => 1234]));
If the bell shows nothing, the problem is one of three things — and this is the fastest triage order: via() does not include 'database', the notification was queued and the worker is not running, or the row exists with notifiable_type that does not match your morph alias (see Gotchas).
Adopt a payload DTO so every notification renders. The single highest-value thing you can do is stop writing ad-hoc arrays. Define one shape and enforce it:
final class InboxPayload
{
public function __construct(
public string $title,
public string $message,
public string $level = 'info',
public ?string $url = null,
) {}
public function toArray(): array
{
return [
'title' => $this->title,
'message' => $this->message,
'level' => $this->level,
'url' => $this->url,
];
}
}
Then a thin base class keeps every notification consistent and gives you one place to adapt when you eventually replace the package:
abstract class InboxNotification extends Notification implements ShouldQueue
{
public function via($notifiable): array { return ['database']; }
abstract public function payload($notifiable): InboxPayload;
public function toArray($notifiable): array
{
return $this->payload($notifiable)->toArray();
}
}
Wrap, don't rewrite, existing notifications. You almost certainly already have notification classes firing that store free-form data. Do not touch their mail/Slack channels — add 'database' to via() and normalise the payload only in toArray(). That keeps existing behaviour intact while opting notifications into the inbox one at a time.
Fan out with Notification::send and an explicit audience. The package is a display layer; routing logic belongs in your app.
Notification::send(
User::role('ops')->get(),
new OrderFailed($order->only('id'))
);
For large audiences, prefer the facade over a loop — it batches and queues properly. For role-based fan-out where the audience is large, consider a single ShouldQueue listener that dispatches individual notifications so a failure in one does not drop the batch.
Trigger from observers/events, not controllers. Keeps the notification source of truth next to the domain event:
class OrderObserver
{
public function updated(Order $order): void
{
if ($order->wasChanged('status') && $order->status === 'failed') {
Notification::send($order->account->admins, new OrderFailed($order->only('id')));
}
}
}
Read/unread: mark the notification read on navigation. The package will have a mark-as-read endpoint. The clean end-to-end pattern is a notification whose url points at the tool's mark-as-read route with a redirect back, or a small Nova custom field/action that POSTs to it. Either way, mark-as-read should happen on click, not on list render — otherwise unread badges are meaningless the moment the dropdown opens.
Keep Nova's toasts and this inbox semantically separate. Nova::notify() is a session flash — it is gone on refresh. This inbox is persistent, per-user, database-backed. Pick a convention and document it: toasts for "your action succeeded", inbox for "something happened that you need to see later". Without that line, admins get two bells of information and trust neither.
Multi-tenancy: scope at the query layer, not the UI. If you run per-tenant databases, the tool's controller reads the notifications table on the currently resolved connection. Verify it uses Nova::user() (or the Nova guard) rather than Auth::user(); if it does not, a fork patch is the fix — this is the one place you should not accept "it looks like it works".
Test the contract, not the UI. Use Notification::fake() to assert who got notified, and a feature test against the tool's route to assert the payload renders:
Notification::fake();
$this->actingAs($admin, 'web');
Order::factory()->failed()->create();
Notification::assertSentTo($admin, OrderFailed::class);
Then a second test hitting the tool endpoint, asserting a 200, a per-user row count, and that user B's notifications are not in user A's response. That last assertion is the regression test that matters after every Nova upgrade.
Treat this as a fork candidate, not a dependency. Last release was March 2022, zero dependents, single maintainer. It targets the Nova 4 era; Nova 5's move toward Inertia/Vue 3 for tool front-ends is a plausible break, and PHP 8.3/8.4 + Laravel 11/12 drift is near-certain. Vendor the source into packages/ with a Composer path repository and pin laravel/nova, php, and laravel/framework explicitly. Budget one to two engineer-days per Nova major upgrade with no upstream to lean on.
Nova is a paid, private package. CI cannot resolve laravel/nova without a license credential. Either inject it as a CI secret or mock the Nova user resolver and test the controller directly.
Morph map mismatch is the #1 "silent emptiness" bug. If your app registers a morph map in Relation::morphMap(), notifiable_type stores the alias ('user'), not the FQCN. If the tool queries by class name, the bell renders and shows nothing, with no error. Check the actual rows first:
DB::table('notifications')->select('notifiable_type')->distinct()->get();
Out-of-contract payloads blank the dropdown. Because existing notification classes store free-form arrays, any row whose data lacks the expected keys can throw in the Vue component — and a single bad row can take out the whole list. Mitigations: normalise everything through the DTO above, and patch the controller to filter or default missing keys before serialising. A ?? '' in the backend is cheaper than a white-screen dropdown in production.
On-demand/anonymous notifications never appear. Notification::route('mail', ...) writes rows with a null notifiable_id and a string notifiable_type. They are not addressed to a user and will not — and should not — show up. If you are debugging "missing notification", check this before touching code.
Authorization is a release blocker. Confirm every tool route sits inside Nova's middleware group and scopes to Nova::user(). A route that falls back to the default guard, or that accepts an arbitrary notification ID on mark-as-read without an ownership check, is a cross-user notification leak. Test it explicitly: authenticate as user B and attempt to mark user A's notification read.
Deleted notifiables produce orphans. When a user is deleted, their notification rows survive. Mark-as-read on an orphan, or a notifiable relation that no longer resolves, can 500 the endpoint. Either cascade-delete in a model observer or make the controller tolerant of a missing notifiable.
Plan for table growth and add the right index. notifications grows monotonically. Add a composite index supporting the read path and a pruning job:
// migration
$table->index(['notifiable_type', 'notifiable_id', 'read_at']);
// app/Console/Kernel.php
$schedule->command('notifications:prune', ['--hours' => 720])->daily();
Watch for N+1s if the controller eager-loads per-notification relations, and confirm the endpoint paginates — if it loads every unread row on each dropdown poll, concurrent admins generate cheap but real query load.
Rebuild the front-end, don't trust dist/. Nova tools ship prebuilt assets. Before you commit to a fork, verify npm run build (or the Nova Mix/Vite scaffolding in the package) actually compiles under your Node version. If it does not, your fork is frozen at whatever the checked-in bundle does — which is a hard ceiling on any customisation.
Leave yourself a kill switch. Register the tool behind a config flag so a Nova upgrade that breaks it does not block an unrelated admin-panel deploy:
public function tools()
{
return array_filter([
config('nova.notifications_inbox') ? new NovaNotifications() : null,
]);
}
Debugging checklist, in order: row exists in notifications? → notifiable_type matches your morph map? → via() includes 'database'? → queue worker running? → payload has the keys the Vue component reads? → route reachable under the Nova guard? Most of these take under a minute each once the tool is in place, and they cover essentially every failure mode this package has.
How can I help you explore Laravel packages today?