Laravel Database Notifications: Build an In-App Bell with Inertia and Vue
By ERAG··12 min read
Most SaaS apps need a small bell in the header. Someone accepted an invite, an export finished, a payment failed. Laravel database notifications are the simplest way to build it: each notification is a row in a notifications table, every user has a notifications relationship, and marking one as read is a single method call.
The official docs explain the pieces, but they stop before the part you actually ship: the unread badge, the dropdown, the "View all" page, and what happens when your users speak different languages or live in different tenant databases. We'll build all of it here, in the order we'd do it on a real project.
We're using Laravel 13, PHP 8.4 and Inertia v3 with Vue 3. The PHP side doesn't care about the frontend, so the same backend works for React or Svelte.
How Laravel database notifications work
A notification class decides which channels it goes to in via(). When one of those channels is database, Laravel calls toDatabase() (or toArray()) and stores the result as JSON in the data column.
Each row looks like this:
| Column | What it holds |
|---|---|
id | A UUID, generated by Laravel when the notification is sent |
type | The notification class name, or whatever databaseType() returns |
notifiable_type, notifiable_id | Who it belongs to (usually App\Models\User and the user ID) |
data | Your array, stored as JSON |
read_at | null until the user reads it |
created_at, updated_at | Normal timestamps |
The Notifiable trait on the default User model gives you three relationships: notifications, unreadNotifications and readNotifications. All three are sorted newest first.
Create the Laravel notifications table
Laravel ships a command that writes the migration for you:
php artisan make:notifications-table
php artisan migrateThe generated table uses $table->morphs('notifiable'), which expects integer IDs. If your users have UUID or ULID primary keys, change that line to uuidMorphs('notifiable') or ulidMorphs('notifiable') before you migrate. Fixing it later means a data migration, so check now.
morphs() also adds a composite index on notifiable_type and notifiable_id. That index is what keeps the unread count fast later on.
Write the notification class
Generate a class with php artisan make:notification InvoicePaid. For an in-app bell, the important decision is the shape of data. Every notification your app sends should store the same keys, or the frontend ends up with a switch on the type for every new feature.
We use five keys: a title, an optional message, a link, an icon name and the parameters for the text.
<?php
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Notifications\Notification;
class InvoicePaid extends Notification
{
public function __construct(public Invoice $invoice) {}
public function via(object $notifiable): array
{
return ['database'];
}
public function toArray(object $notifiable): array
{
return [
'title' => 'notifications.invoice_paid.title',
'message' => 'notifications.invoice_paid.message',
'url' => route('invoices.show', $this->invoice, absolute: false),
'icon' => 'receipt',
'params' => ['number' => $this->invoice->number],
];
}
public function databaseType(object $notifiable): string
{
return 'invoice-paid';
}
}Three details in there are deliberate.
The title and message are translation keys, not sentences. We'll translate them when the bell renders, not when the row is written. More on that below.
The URL is relative. An absolute URL bakes in whatever host sent it. A relative one works on every domain the app answers on.
databaseType() stores a short, stable name instead of the full class name. Rename or move the class later and old rows still make sense.
toArray or toDatabase?
Both work for the database channel. The difference only matters once you add a second channel:
| Method | Used by | When to define it |
|---|---|---|
toArray() | database and broadcast | One shape for both channels |
toDatabase() | database only | You want a different payload in the table than over websockets |
toBroadcast() | broadcast only | Same idea for the realtime payload |
If you plan to push notifications live with Reverb later, starting with toArray() is fine. We cover that step in real-time notifications with Laravel Reverb.
Send the notification from a service
You can call $user->notify(new InvoicePaid($invoice)) for one user, or the Notification facade for many:
use Illuminate\Support\Facades\Notification;
Notification::send($admins, new InvoicePaid($invoice));We keep these calls inside services, next to the business logic that triggers them, not in controllers. The controller that marks an invoice as paid calls InvoiceService::markPaid(), and that method sends the notification. If that pattern is new to you, read the service layer pattern in Laravel.
The class above doesn't implement ShouldQueue, so the row is written during the request. For the database channel that's usually what you want: it's one insert, and the user sees the notification on the next page load without depending on a queue worker. Queue it when the notification also goes to mail or Slack.
Read unread notifications and the count
Reading is plain Eloquent:
$user->unreadNotifications()->count(); // number for the badge
$user->notifications()->limit(8)->get(); // newest 8 for the dropdown
$user->unreadNotifications; // collection of unread rowsNote the difference between unreadNotifications() (a query, so count() runs SELECT COUNT(*)) and unreadNotifications (loads every unread row into memory). For the badge, always count with the query.
We wrap both in one service method that also turns each row into the shape the frontend needs:
<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Notifications\DatabaseNotification;
class NotificationService
{
public function summary(User $user): array
{
return [
'unread' => $user->unreadNotifications()->count(),
'latest' => $user->notifications()
->limit(8)
->get()
->map(fn (DatabaseNotification $n) => $this->present($n))
->all(),
];
}
public function present(DatabaseNotification $notification): array
{
$data = $notification->data;
$params = $data['params'] ?? [];
return [
'id' => $notification->id,
'title' => __($data['title'] ?? '', $params),
'message' => isset($data['message']) ? __($data['message'], $params) : null,
'url' => $data['url'] ?? null,
'icon' => $data['icon'] ?? 'bell',
'read' => $notification->read_at !== null,
'created_at_human' => $notification->created_at?->diffForHumans(),
];
}
}Share the unread notifications count as an Inertia prop
The bell lives in the layout, so it needs data on every page. That's what shared props in HandleInertiaRequests are for:
public function share(Request $request): array
{
return [
...parent::share($request),
'notifications' => fn () => $request->user()
? app(NotificationService::class)->summary($request->user())
: null,
];
}Wrap it in a closure. Inertia only runs a closure when the prop is part of the response, so a partial reload like router.reload({ only: ['users'] }) skips both queries. Guests get null and no queries at all. The Inertia shared data docs also warn that everything shared goes out with every response, which is why we send only 8 items and a number, never the full list.
Build the Laravel notification bell in Vue
Now the component. It reads the shared prop with usePage(), shows the badge, and lists the latest items:
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import { computed, ref } from 'vue';
type Item = {
id: string;
title: string;
message: string | null;
url: string | null;
read: boolean;
created_at_human: string;
};
const page = usePage<{ notifications: { unread: number; latest: Item[] } | null }>();
const open = ref(false);
const unread = computed(() => page.props.notifications?.unread ?? 0);
const latest = computed(() => page.props.notifications?.latest ?? []);
const badge = computed(() => (unread.value > 99 ? '99+' : String(unread.value)));
function openItem(item: Item) {
open.value = false;
router.patch(`/notifications/${item.id}/read`, { visit: item.url ? 1 : 0 }, { preserveScroll: true });
}
function markAllAsRead() {
router.post('/notifications/read-all', {}, { preserveScroll: true });
}
</script>
<template>
<div class="relative">
<button type="button" class="relative rounded-md p-2" aria-label="Notifications" @click="open = !open">
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
<span v-if="unread > 0" class="absolute -top-1 -right-1 rounded-full bg-red-500 px-1 text-[10px] font-semibold text-white">
{{ badge }}
</span>
</button>
<div v-if="open" class="absolute right-0 mt-2 w-80 rounded-lg border bg-white shadow-lg dark:bg-neutral-900">
<div class="flex items-center justify-between px-4 py-3">
<p class="text-sm font-semibold">Notifications</p>
<button v-if="unread > 0" class="text-xs" @click="markAllAsRead">Mark all as read</button>
</div>
<button
v-for="item in latest"
:key="item.id"
class="block w-full px-4 py-3 text-left hover:bg-neutral-50 dark:hover:bg-neutral-800"
:class="{ 'font-medium': !item.read }"
@click="openItem(item)"
>
<p class="text-sm">{{ item.title }}</p>
<p v-if="item.message" class="text-xs text-neutral-500">{{ item.message }}</p>
<p class="mt-1 text-xs text-neutral-400">{{ item.created_at_human }}</p>
</button>
<p v-if="latest.length === 0" class="px-4 py-6 text-center text-sm text-neutral-500">You're all caught up.</p>
<Link href="/notifications" class="block border-t px-4 py-2 text-center text-sm">View all</Link>
</div>
</div>
</template>Swap the inline SVG for your icon library and the hand-made dropdown for your UI kit's dropdown component. The data flow stays the same. In React, read the same prop with usePage().props.notifications; in Svelte 5, use page.props.notifications from @inertiajs/svelte.
There's no client-side state for the count. After any action, Inertia redirects back, the shared prop is computed again, and the badge is correct. It also updates on every normal navigation. If you need it to change while the user sits on one page, you can poll with Inertia's usePoll(30000, { only: ['notifications'] }) or push updates with websockets.
Mark notification as read
Four routes cover the bell and the full page:
Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index');
Route::patch('/notifications/{id}/read', [NotificationController::class, 'read'])->name('notifications.read');
Route::post('/notifications/read-all', [NotificationController::class, 'readAll'])->name('notifications.read-all');
Route::delete('/notifications/{id}', [NotificationController::class, 'destroy'])->name('notifications.destroy');
});The service methods always start from the user's own relationship. That's the security part: $user->notifications()->findOrFail($id) can never load somebody else's row, even if a user edits the ID in the request.
public function markAsRead(User $user, string $id): ?string
{
$notification = $user->notifications()->findOrFail($id);
$notification->markAsRead();
return $notification->data['url'] ?? null;
}
public function markAllAsRead(User $user): void
{
$user->unreadNotifications()->update(['read_at' => now()]);
}markAllAsRead() uses one UPDATE query instead of loading every unread row and calling markAsRead() on each. $user->unreadNotifications->markAsRead() also works, but it loads the collection first.
The controller stays thin:
public function read(Request $request, string $id): RedirectResponse
{
$url = $this->notifications->markAsRead($request->user(), $id);
return $request->boolean('visit') && $url ? redirect()->to($url) : back();
}The notifications page with pagination
The dropdown shows 8 items. Everything else lives on /notifications, with an All / Unread filter kept in the query string:
public function index(Request $request): Response
{
$unreadOnly = $request->string('filter')->value() === 'unread';
return Inertia::render('notifications/Index', [
'items' => $request->user()->notifications()
->when($unreadOnly, fn ($query) => $query->whereNull('read_at'))
->paginate(15)
->withQueryString()
->through(fn ($n) => $this->notifications->present($n)),
'filter' => $unreadOnly ? 'unread' : 'all',
]);
}Call the prop items, not notifications. Page props are merged over shared props, so a page prop called notifications would replace the bell's data on this one page and break the badge. In production you'd move that query into the service as well, like markAsRead().
Translate at render time, not when you store
This is the decision people regret most. If you store "Jane accepted your invitation" in English, a user who switches to German still sees English forever. Store the key and the parameters, then call __() when you present the row, as present() does above.
The language file holds the text:
// lang/en/notifications.php
return [
'invoice_paid' => [
'title' => 'Invoice paid',
'message' => 'Invoice :number has been paid.',
],
];Because present() runs during the request, __() uses the current user's locale. Old notifications follow the user's language too. Our post on translations in Laravel and Inertia covers setting that locale per user.
Prune old notifications
Laravel keeps every row forever. A busy app can collect millions of read notifications nobody will open again. Schedule a cleanup in routes/console.php:
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Support\Facades\Schedule;
Schedule::call(fn () => DatabaseNotification::query()
->whereNotNull('read_at')
->where('created_at', '<', now()->subDays(90))
->delete()
)->daily();We only delete read notifications here. Deleting unread ones silently feels like a bug to the user. Pick the rule that fits your product.
Multi-tenant apps: a notifications table per tenant
With a database per tenant (for example with stancl/tenancy), the notifications belong next to the users. Put the migration in your tenant migrations folder as well as the central one, then run php artisan tenants:migrate. Inside a tenant request the default connection already points at the tenant database, so $user->notifications() reads the right table with no extra code.
Two things still need care:
- Pruning. The scheduled job above runs in the central context. Loop over tenants with
tenancy()->runForMultiple(null, fn () => ...)to clean each tenant database. See tenant migrations and seeders for the migration side. - Queued notifications. If you do queue them, the job has to restore the right tenant before it writes the row. We explain how in queued jobs in a multi-tenant Laravel app.
Mistakes we see with database notifications
- Loading the collection for the badge.
$user->unreadNotifications->count()loads every unread row. UseunreadNotifications()->count(). - Storing translated text. It freezes the language at send time. Store keys and parameters.
- Absolute URLs in
data. They break across domains, and they point at the wrong host after a domain change. Store relative paths. - Finding notifications by ID alone.
DatabaseNotification::find($id)lets any signed-in user mark anyone's notification as read. Always go through$user->notifications(). - A page prop that shadows the shared prop. Name page props differently from shared ones.
- Orphaned rows.
morphs()creates no foreign key, so deleting a user leaves their notifications behind. Delete them in the same service method that deletes the user. - Wrong morph type for UUID users. Integer
notifiable_idcolumns and UUID users don't mix. UseuuidMorphs()from the start.
Frequently asked questions
How do I create the notifications table in Laravel 13?
Run php artisan make:notifications-table and then php artisan migrate. If your notifiable models use UUID or ULID keys, change morphs('notifiable') to uuidMorphs or ulidMorphs in the generated migration first.
How do I mark a notification as read in Laravel?
Call markAsRead() on a DatabaseNotification, for example $user->notifications()->findOrFail($id)->markAsRead(). To mark all of them, run $user->unreadNotifications()->update(['read_at' => now()]), which is a single query.
How do I show the unread notifications count with Inertia?
Share it from HandleInertiaRequests as a closure that returns $user->unreadNotifications()->count(). The layout reads it with usePage(), and the count refreshes on every Inertia visit.
Do database notifications need a queue worker?
No. A notification that only uses the database channel and doesn't implement ShouldQueue is written during the request. You only need a worker when you queue the notification or add channels like mail.
How do I delete old Laravel notifications?
Schedule a query in routes/console.php that deletes rows from DatabaseNotification older than a cutoff, for example 90 days. In a multi-tenant app, run it once per tenant database.
Database notifications in the SaaS Laravel kits
All three SaaS Laravel starter kits ship this feature since kit v1.4.0, in Modules/Notification. ActivityNotification uses the database channel with one data shape (title, message, url, icon, level, params). NotificationService sends with send(), sendToCurrentUser() or sendToSuperAdmins(), and it builds the summary for the shared notifications prop: the unread count and the 8 newest items. The header bell shows a badge capped at 99+, and /notifications has All / Unread filters, mark as read, delete and clear all, 15 per page.
Titles and messages can be translation keys, and they're translated when the notification is shown, not when it's sent. The docs recommend relative URLs. The notifications table exists in the central database and in every tenant database. The kits don't schedule pruning or push notifications live. Both are left to you, and the docs show the pruning snippet. Full details are in the Notifications docs.