Real-time notification bell with Laravel Reverb
A bell in the header that updates live: Laravel notifications pushed through Reverb, an unread count, the newest in a popover and a toast as each arrives.
php artisan bladewell:add popover icon button toast
Uses Popover, Icon, Button and Toast. New to Bladewell? Install it first.
A bell that updates by itself
When something happens for someone, an invoice paid or a comment on their task, a bell in the header shows a count, the newest are listed under it, and a toast says what came in. All of it live, without a reload and without asking the server every few seconds. Laravel Reverb is Laravel's own WebSocket server: Laravel saves each notification and pushes it through Reverb to every tab the person has open.
The bell is Bladewell's popover with an icon trigger, so it opens and closes from the keyboard, and screen readers hear the count and each new notification. This guide was checked against Laravel 13.40, Reverb 1.12 and laravel-echo 2.6, and run in a browser: notifications arriving live, the count past nine, reading one, reading all, and moving with wire:navigate between pages with the bell and pages without it.
Install
install:broadcasting adds Reverb, Echo (the browser side) and its config, the keys in .env, and routes/channels.php. Laravel keeps notifications in a table of their own.
php artisan install:broadcasting --reverb
php artisan make:notifications-table
php artisan migrate
php artisan bladewell:add popover icon button toast
The notification
A notification sent through both database and broadcast is saved for the list and pushed to the browser as it happens. Laravel gives both the same id, so the script can link a notification that arrives live to its saved row. Every notification class you write returns the same three keys, and the bell shows any of them.
namespace App\Notifications;
use App\Models\Invoice;
use Illuminate\Notifications\Notification;
class InvoicePaid extends Notification
{
public function __construct(public Invoice $invoice) {}
// Saved for the bell's list, and pushed to the person's open tabs as it happens.
public function via(object $notifiable): array
{
return ['database', 'broadcast'];
}
// What the bell shows, for this and every other notification: a title, a line, and where clicking it goes. Both
// channels send this; keep it to what the person may see.
public function toArray(object $notifiable): array
{
return [
'title' => 'Invoice paid',
'body' => "{$this->invoice->number} was paid by {$this->invoice->customer_name}.",
'url' => route('invoices.show', $this->invoice, absolute: false),
];
}
// The browser is told this instead of the class name, App\Notifications\InvoicePaid.
public function broadcastType(): string
{
return 'invoice.paid';
}
}
Send it where it happens, with $invoice->user->notify(new InvoicePaid($invoice)). Laravel saves the row straight away and hands the broadcast to your queue, so a queue worker must be running for anything to arrive live.
Laravel broadcasts a person's notifications on their own private channel. Before Echo may listen on it, the browser asks your app, and this rule answers: only the person themselves.
// routes/channels.php, as install:broadcasting writes it: only the person themselves may listen on their channel,
// which is where Laravel broadcasts their notifications.
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
The bell
A Blade component for the signed-in person: the unread count on the trigger, the newest eight in the panel, and a button to mark them all read. It runs two small queries on each page that shows it, both on the index the notifications table comes with. The trigger's label carries the count, "Notifications, 3 unread", since the badge is only a picture of it.
@php
$user = auth()->user();
$unread = $user->unreadNotifications()->count();
$latest = $user->notifications()->latest()->limit(8)->get();
@endphp
{{-- The bell for the signed-in person. The script finds it by data-notifications, listens on data-channel, and puts
new notifications at the top of the list as they arrive. --}}
<div data-notifications data-channel="App.Models.User.{{ $user->id }}">
<x-widget.popover id="notifications" title="Notifications" width="lg" align="end"
:label="$unread ? 'Notifications, '.$unread.' unread' : 'Notifications'">
<x-slot:trigger class="hover:bg-field relative p-2">
<x-widget.icon name="bell" class="size-5" />
<span data-notifications-count data-unread="{{ $unread }}" @class(['bg-error-fill absolute -end-0.5 -top-0.5 grid min-w-5 place-items-center rounded-full px-1 text-xs font-semibold text-white', 'hidden' => ! $unread])>{{ $unread > 9 ? '9+' : $unread }}</span>
</x-slot:trigger>
<ul class="divide-line -mx-2 grid max-h-96 divide-y overflow-y-auto" data-notifications-list>
@foreach ($latest as $notification)
<x-notification-item :notification="$notification" />
@endforeach
</ul>
<p @class(['text-muted py-6 text-center text-sm', 'hidden' => $latest->isNotEmpty()]) data-notifications-empty>You're all caught up.</p>
<form method="POST" action="{{ route('notifications.read-all') }}" @class(['border-line mt-2 border-t pt-3', 'hidden' => ! $unread]) data-notifications-read-all>
@csrf
<x-widget.button type="submit" variant="link" size="sm">Mark all as read</x-widget.button>
</form>
{{-- One empty item, the script's copy for notifications that arrive while the page is open. --}}
<template data-notifications-template>
<x-notification-item />
</template>
</x-widget.popover>
</div>
Each notification is a button that marks it read and opens what it's about. The same component, given nothing, is the empty copy in the bell's <template>, so a notification that arrives live looks exactly like one rendered by the server.
@props([
// A row of $user->notifications. Left out, it's the empty copy in the bell's <template>, which the script fills in.
'notification' => null,
])
{{-- One notification: a button that marks it read and opens what it's about. --}}
<li>
<form method="POST" action="{{ route('notifications.read', $notification?->id ?? '__ID__') }}">
@csrf
<button type="submit" class="hover:bg-field focus-visible:ring-primary flex w-full gap-3 rounded-lg p-2 text-start outline-none focus-visible:ring-2">
<span @class(['bg-primary mt-1.5 size-2 shrink-0 rounded-full', 'invisible' => $notification?->read_at]) data-unread>
<span class="sr-only">Unread:</span>
</span>
<span class="grid gap-0.5">
<span class="text-sm font-medium" data-title>{{ $notification?->data['title'] }}</span>
<span class="text-muted text-sm" data-body>{{ $notification?->data['body'] }}</span>
<time class="text-muted text-xs" datetime="{{ $notification?->created_at->toIso8601String() }}" data-time>{{ $notification?->created_at->diffForHumans() }}</time>
</span>
</button>
</form>
</li>
Put the bell in your layout's header, with the toast once in the page:
{{-- resources/views/layouts/app.blade.php: in the header, for people who are signed in. --}}
<header class="flex items-center justify-between gap-4 px-6 py-3">
<a href="{{ url('/') }}" class="font-semibold">Acme</a>
@auth
<x-notification-bell />
@endauth
</header>
{{-- Once, anywhere in the body: new notifications also show here, and screen readers hear them. --}}
<x-widget.toast />
Marking them read
Two routes: one notification, then where it points, or all of them at once. Both are forms, so a link on another site can't mark someone's notifications read.
// routes/web.php
use App\Http\Controllers\NotificationController;
Route::middleware('auth')->group(function () {
Route::post('/notifications/read', [NotificationController::class, 'readAll'])->name('notifications.read-all');
Route::post('/notifications/{notification}/read', [NotificationController::class, 'read'])->name('notifications.read');
});
A notification is looked up through the person's own, so no one can read or change another person's by guessing an id.
namespace App\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
class NotificationController
{
public function read(Request $request, string $notification): RedirectResponse
{
// Looked up through the person's own notifications: anyone else's id is a 404.
$notification = $request->user()->notifications()->findOrFail($notification);
$notification->markAsRead();
// The url your notification class stored; a path on your own site, so it can't send anyone elsewhere.
return redirect($notification->data['url'] ?? url()->previous());
}
public function readAll(Request $request): RedirectResponse
{
// One query, however many there are.
$request->user()->unreadNotifications()->update(['read_at' => now()]);
return back();
}
}
The script
Echo and its WebSocket client weigh about 26 KB once compressed, and every page that loads them opens a connection to Reverb. So only pages with the bell load them: guests, and pages without it, never connect. wire:navigate doesn't run app.js again, so it checks on each page it brings in too.
// resources/js/app.js, after Bladewell's own imports. install:broadcasting added `import './echo';` here: move it
// into notifications.js, so only pages with the bell open a connection to Reverb, and guests don't.
// Checked on the first page and on each one reached with wire:navigate, which doesn't run this file again. A module
// is only ever loaded once, however often it's imported.
const loadNotifications = () => {
if (document.querySelector('[data-notifications]')) {
import('./notifications');
}
};
loadNotifications();
document.addEventListener('livewire:navigated', loadNotifications);
When a notification arrives, the script fills in a copy of the template, puts it at the top of the list, raises the count and shows a toast, which screen readers also hear. On a page without the bell, it stops listening until the bell comes back. It sets every piece of text with textContent, so a customer named <b>Acme</b> shows as typed and runs nothing.
// resources/js/notifications.js: loaded only on pages with the bell (see app.js).
import './echo';
const LIMIT = 8;
let channel = null;
// The bell is looked up each time, not kept, so it still works after wire:navigate swaps the page.
const part = (name) => document.querySelector(`[data-notifications] [data-notifications-${name}]`);
function setUnread(count) {
const badge = part('count');
badge.dataset.unread = String(count);
badge.textContent = count > 9 ? '9+' : String(count);
badge.classList.toggle('hidden', count === 0);
part('read-all').classList.toggle('hidden', count === 0);
document.getElementById('notifications').setAttribute('aria-label', count ? `Notifications, ${count} unread` : 'Notifications');
}
// The payload is the notification's toArray() plus its id: the same id as the row saved in the database.
function show(notification) {
// Left on a page without the bell: listen() lets the channel go, but one may already be on its way.
if (!part('list')) {
return;
}
const item = part('template').content.firstElementChild.cloneNode(true);
const form = item.querySelector('form');
form.action = form.action.replace('__ID__', encodeURIComponent(notification.id));
// textContent, never innerHTML: what a notification says may include what someone typed.
item.querySelector('[data-title]').textContent = notification.title;
item.querySelector('[data-body]').textContent = notification.body;
const time = item.querySelector('[data-time]');
time.dateTime = new Date().toISOString();
time.textContent = 'Just now';
const list = part('list');
list.prepend(item);
list.querySelectorAll(':scope > li').forEach((row, index) => index >= LIMIT && row.remove());
part('empty').classList.add('hidden');
// The real count, not the badge's text, which stops at 9+.
setUnread(Number(part('count').dataset.unread) + 1);
// Also read out to screen readers, by the toast.
window.toast?.info(notification.body, { title: notification.title });
}
// The bell's channel, or none on a page without it: wire:navigate can lead to either.
function listen() {
const wanted = document.querySelector('[data-notifications]')?.dataset.channel ?? null;
if (wanted === channel) {
return;
}
if (channel) {
window.Echo.leave(channel);
}
channel = wanted;
if (channel) {
window.Echo.private(channel).notification(show);
}
}
listen();
document.addEventListener('livewire:navigated', listen);
Run it
Three processes while you build: the app, Reverb, and a queue worker for the broadcasts. Then send yourself a notification from php artisan tinker and watch the bell.
php artisan reverb:start
php artisan queue:work
npm run dev
Before you ship it
- Keep Reverb and the worker running. Both are long-running processes: run them under a process manager such as Supervisor, and restart them on each deploy. Put Reverb behind your web server so the browser connects over
wss://on port 443. Laravel Forge and Laravel Cloud can set this up for you. - The browser's settings are built in. Echo reads the
VITE_REVERB_*values when you runnpm run build, so set the production host, port and scheme before building. - Who may connect. Reverb accepts connections from any site by default. Set
allowed_originsinconfig/reverb.phpto your own domain. - More than one server. With several Reverb servers behind a load balancer, turn on
REVERB_SCALING_ENABLEDso they share messages through Redis. - What the live view misses. A notification that arrives while a tab is offline, or one read in another tab, shows correctly on the next page load: the database is the truth, the live updates are a convenience.