Implementing Real-Time Notifications with Laravel: A Complete Guide
Build production-ready real-time notifications in Laravel with Reverb, Echo 2.1 hooks, and Vue.js Composition API.
You know that feeling when you refresh a page obsessively, waiting for an update? Your users hate it too. Real-time notifications fix this by pushing updates instantly without page refreshes. Whether you're building a chat app, a project management tool, or a SaaS dashboard, real-time notifications create the kind of responsive experience users expect in 2026.
I've built notification systems for project management tools where team members needed instant task assignment alerts, and for customer support platforms where response time was everything. The good news? Laravel's broadcasting ecosystem has gotten significantly better since Reverb became the official first-party WebSocket server. In this guide, I'll walk you through everything from basic setup to production-ready configurations, using the latest Laravel 12 patterns and Echo 2.1 hooks.
Why Real-Time Notifications Matter
Here's the thing: users expect instant feedback. When someone mentions them in a comment, assigns them a task, or sends them a message, they want to know right now. Not after a page refresh. Not after polling your server every five seconds.
Real-time notifications also reduce server load. Instead of users hammering your database with constant polling requests, WebSockets maintain a single persistent connection that pushes updates only when something actually happens. One connection per user versus hundreds of HTTP requests per minute. That's a massive difference at scale.
And here's what a lot of developers miss: real-time notifications improve retention. Users who get instant feedback stay engaged longer. They don't wander off to check their email while waiting for updates.
Understanding Laravel Broadcasting
Laravel's broadcasting system lets you push server-side events to your client-side JavaScript application. When something important happens (like a new notification), Laravel fires an event, broadcasts it through a WebSocket server, and your frontend receives it instantly.
The system works through three main components:
Broadcasting Drivers: Laravel supports Reverb (the first-party WebSocket server), Pusher Channels, and Ably. Reverb is the recommended default for most applications now. It's open source, self-hosted, and has no per-message pricing.
Events: Standard Laravel events that implement the ShouldBroadcast interface. Fire these events and Laravel automatically broadcasts them to connected clients.
Echo Client: Laravel's JavaScript library for listening to broadcast events on the frontend. Echo 2.1 introduced useEcho hooks for Vue and React that make this ridiculously simple compared to the old manual setup.
Setting Up Broadcasting in Laravel 12
Broadcasting isn't enabled by default in new Laravel applications. You set it up with a single Artisan command. I'll cover both Reverb (my recommendation for most projects) and Pusher (still useful for quick prototypes).
Option 1: Reverb (Recommended)
Run this command and say yes when it asks to install Reverb:
php artisan install:broadcasting --reverb
That's it. This single command handles everything: it installs the Composer and NPM packages, creates config/broadcasting.php and routes/channels.php, and sets up your .env variables. Compare that to the manual multi-step process we used to deal with. So much better.
Your .env will now include:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=your_app_id
REVERB_APP_KEY=your_app_key
REVERB_APP_SECRET=your_app_secret
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Start the Reverb server alongside your dev server:
php artisan reverb:start --debug
The --debug flag shows you every message flowing through the WebSocket server. Super helpful during development, but turn it off in production.
Option 2: Pusher
If you want a managed service with zero infrastructure to maintain:
php artisan install:broadcasting --pusher
This will prompt you for your Pusher credentials and configure everything automatically. Your .env will include:
BROADCAST_CONNECTION=pusher
PUSHER_APP_ID=your_app_id
PUSHER_APP_KEY=your_app_key
PUSHER_APP_SECRET=your_app_secret
PUSHER_APP_CLUSTER=mt1
Note that it's BROADCAST_CONNECTION, not the old BROADCAST_DRIVER. This changed in Laravel 11 and catches a lot of developers who are following older tutorials.
Creating a Notification Event
Let's create an event that broadcasts notifications. This is where your backend tells the frontend "hey, something happened."
php artisan make:event NewNotification
Now implement the ShouldBroadcast interface:
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
use App\Models\Notification;
class NewNotification implements ShouldBroadcast
{
use InteractsWithSockets, SerializesModels;
public function __construct(
public Notification $notification
) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('user.' . $this->notification->user_id),
];
}
public function broadcastAs(): string
{
return 'notification.created';
}
public function broadcastWith(): array
{
return [
'id' => $this->notification->id,
'title' => $this->notification->title,
'message' => $this->notification->message,
'type' => $this->notification->type,
'created_at' => $this->notification->created_at->toISOString(),
];
}
}
A couple of things to notice. I'm using a PrivateChannel because notification data should only go to the intended user. The constructor uses PHP 8.2 constructor promotion for cleaner code. And broadcastWith() controls exactly what data gets sent to the frontend, so you don't accidentally expose sensitive model attributes.
Channel Authentication
Since we're using private channels, we need to authorize who can listen. Open routes/channels.php:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('user.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
This ensures users can only listen to their own notification channel. Skip this step and you've got a security hole where anyone can eavesdrop on anyone else's notifications. I've seen this in production code more times than I'd like to admit.
The Notification Model and Service
Create the notification model with a migration:
php artisan make:model Notification -m
The migration:
return new class extends Migration
{
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('message');
$table->boolean('read')->default(false);
$table->string('type')->nullable();
$table->json('data')->nullable();
$table->timestamps();
$table->index(['user_id', 'read', 'created_at']);
});
}
};
That composite index on user_id, read, and created_at matters. Without it, your "fetch unread notifications" query will get slow fast as the table grows. I usually validate my indexing decisions with the JSON formatter when inspecting query explain plans.
Now the service layer that ties everything together:
<?php
namespace App\Services;
use App\Events\NewNotification;
use App\Models\Notification;
class NotificationService
{
public function send(
int $userId,
string $title,
string $message,
?string $type = null,
array $data = []
): Notification {
$notification = Notification::create([
'user_id' => $userId,
'title' => $title,
'message' => $message,
'type' => $type,
'data' => $data,
]);
broadcast(new NewNotification($notification));
return $notification;
}
public function markAsRead(int $notificationId): bool
{
return Notification::where('id', $notificationId)
->update(['read' => true]) > 0;
}
public function getUnreadCount(int $userId): int
{
return Notification::where('user_id', $userId)
->where('read', false)
->count();
}
}
Inject this service anywhere you need to fire notifications:
public function assignTask(Request $request, NotificationService $notifier): JsonResponse
{
$task = Task::create($request->validated());
$notifier->send(
$task->assigned_to,
'New Task Assigned',
"You've been assigned: {$task->title}",
'task_assigned',
['task_id' => $task->id]
);
return response()->json($task, 201);
}
Frontend Setup with Echo 2.1
This is where things have gotten really nice. Echo 2.1 introduced useEcho hooks for Vue and React that handle channel subscription, event listening, and cleanup automatically. No more manual window.Echo setup scattered across your components.
Configuring Echo
If you used install:broadcasting, your resources/js/echo.js is already configured. But if you need to set it up manually or you're using the Vue starter kit, use configureEcho:
import { configureEcho } from '@laravel/echo-vue';
configureEcho({
broadcaster: 'reverb',
});
That's the entire configuration for Reverb. The package reads your VITE_REVERB_* environment variables automatically and fills in sensible defaults. For Pusher, swap 'reverb' for 'pusher' and it picks up the VITE_PUSHER_* variables instead.
Building the Notification Component
Here's a notification bell component using the Composition API and useEcho:
<script setup>
import { ref, onMounted } from 'vue';
import { useEcho } from '@laravel/echo-vue';
import axios from 'axios';
const props = defineProps({
userId: { type: Number, required: true },
});
const notifications = ref([]);
const unreadCount = ref(0);
const showDropdown = ref(false);
onMounted(async () => {
await fetchNotifications();
});
async function fetchNotifications() {
try {
const { data } = await axios.get('/api/notifications');
notifications.value = data.notifications;
unreadCount.value = data.unread_count;
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
}
useEcho(
`user.${props.userId}`,
'.notification.created',
(event) => {
notifications.value.unshift(event);
unreadCount.value++;
showBrowserNotification(event);
}
);
function showBrowserNotification(notification) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/notification-icon.png',
});
}
}
async function markAsRead(id) {
try {
await axios.post(`/api/notifications/${id}/read`);
const notification = notifications.value.find(n => n.id === id);
if (notification) {
notification.read = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
async function markAllAsRead() {
try {
await axios.post('/api/notifications/mark-all-read');
notifications.value.forEach(n => n.read = true);
unreadCount.value = 0;
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
function formatTime(timestamp) {
const diffMs = Date.now() - new Date(timestamp);
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`;
return new Date(timestamp).toLocaleDateString();
}
</script>
<template>
<div class="relative">
<button @click="showDropdown = !showDropdown" class="relative">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
</svg>
<span v-if="unreadCount > 0"
class="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{{ unreadCount }}
</span>
</button>
<div v-if="showDropdown"
class="absolute right-0 mt-2 w-80 bg-white rounded-lg shadow-lg border z-50 max-h-96 overflow-y-auto">
<div class="flex items-center justify-between p-3 border-b">
<h3 class="font-semibold">Notifications</h3>
<button v-if="unreadCount > 0" @click="markAllAsRead"
class="text-sm text-blue-600 hover:underline">
Mark all read
</button>
</div>
<div v-for="notification in notifications" :key="notification.id"
:class="['p-3 border-b cursor-pointer hover:bg-gray-50', { 'bg-blue-50': !notification.read }]"
@click="markAsRead(notification.id)">
<h4 class="font-medium text-sm">{{ notification.title }}</h4>
<p class="text-gray-600 text-sm">{{ notification.message }}</p>
<span class="text-gray-400 text-xs">{{ formatTime(notification.created_at) }}</span>
</div>
<div v-if="notifications.length === 0" class="p-4 text-center text-gray-500">
No notifications yet
</div>
</div>
</div>
</template>
Compare this to the old approach where you'd manually set up window.Echo.private() in mounted() and clean up in beforeUnmount(). The useEcho hook handles subscription and teardown automatically when the component mounts and unmounts. Less code, fewer bugs.
Notice the dot prefix in .notification.created. That's required when you use broadcastAs() to define a custom event name. Without the dot, Echo prepends the full namespace, and your listener silently fails to match. This trips up almost everyone the first time.
API Endpoints
Create the controller to support the notification component:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
public function __construct(
private NotificationService $notificationService
) {}
public function index(Request $request): JsonResponse
{
$notifications = $request->user()
->notifications()
->orderByDesc('created_at')
->limit(50)
->get();
return response()->json([
'notifications' => $notifications,
'unread_count' => $this->notificationService
->getUnreadCount($request->user()->id),
]);
}
public function markAsRead(Request $request, int $id): JsonResponse
{
$request->user()
->notifications()
->findOrFail($id);
$this->notificationService->markAsRead($id);
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()
->notifications()
->where('read', false)
->update(['read' => true]);
return response()->json(['success' => true]);
}
}
Register the routes in routes/api.php:
Route::middleware('auth:sanctum')->group(function () {
Route::get('/notifications', [NotificationController::class, 'index']);
Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead']);
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead']);
});
If you're building a full RESTful API, you'll want to add pagination to the index endpoint and maybe filter by notification type. But this gets you started.
Running Reverb in Production
Development is easy. Production requires a bit more thought. You need Reverb running as a daemon that survives server restarts and crashes.
Supervisor Configuration
Create /etc/supervisor/conf.d/reverb.conf:
[program:reverb]
command=php /var/www/your-app/artisan reverb:start --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
stopwaitsecs=3600
Then reload Supervisor:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start reverb
If you need to deploy code changes that affect broadcasting, use php artisan reverb:restart instead of a hard kill. This gracefully terminates existing connections before stopping the server.
Nginx Reverse Proxy
You'll want to proxy WebSocket connections through Nginx so everything runs on ports 80/443:
server {
listen 443 ssl;
server_name ws.yourapp.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
Update your .env for production to reflect the public-facing host:
REVERB_HOST="ws.yourapp.com"
REVERB_PORT=443
REVERB_SCHEME=https
REVERB_SERVER_HOST="0.0.0.0"
REVERB_SERVER_PORT=8080
The distinction between REVERB_HOST/REVERB_PORT (where clients connect) and REVERB_SERVER_HOST/REVERB_SERVER_PORT (where Reverb actually listens) confuses a lot of people. The server listens on 8080, but clients connect through Nginx on 443.
Managed Reverb on Laravel Cloud
Don't want to deal with any of this infrastructure? Laravel Cloud now offers fully managed Reverb clusters. Deploy your app and WebSocket infrastructure scales automatically. If your project is on Forge or Cloud already, this is worth considering.
Monitoring with Pulse
Laravel Pulse has built-in Reverb integration for monitoring connection counts and message throughput. Add the recorders to config/pulse.php:
use Laravel\Reverb\Pulse\Recorders\ReverbConnections;
use Laravel\Reverb\Pulse\Recorders\ReverbMessages;
'recorders' => [
ReverbConnections::class => [
'sample_rate' => 1,
],
ReverbMessages::class => [
'sample_rate' => 1,
],
],
Then add the cards to your Pulse dashboard:
<x-pulse>
<livewire:reverb.connections cols="full" />
<livewire:reverb.messages cols="full" />
</x-pulse>
Run php artisan pulse:check on your Reverb server to start recording. This gives you visibility into how many concurrent connections you're handling and how many messages are flowing through the system. If you're building a SaaS dashboard with Filament, Pulse is the natural companion for monitoring your real-time infrastructure.
Queuing Broadcasts for Performance
Broadcasting events synchronously blocks your HTTP response until the message is sent to the WebSocket server. In production, always queue your broadcasts. Since the NewNotification event implements ShouldBroadcast (not ShouldBroadcastNow), Laravel automatically queues it.
Make sure your queue worker is running:
php artisan queue:work --tries=3
During development, you can temporarily switch to ShouldBroadcastNow if you want instant feedback without running a queue worker. But always switch back to ShouldBroadcast before deploying.
Testing Real-Time Notifications
Testing WebSocket functionality takes a different approach than standard HTTP tests. Here's what I do:
<?php
namespace Tests\Feature;
use App\Events\NewNotification;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class NotificationTest extends TestCase
{
public function test_notification_broadcasts_when_created(): void
{
Event::fake();
$user = User::factory()->create();
$service = app(NotificationService::class);
$service->send($user->id, 'Test Title', 'Test message');
Event::assertDispatched(NewNotification::class, function ($event) use ($user) {
return $event->notification->user_id === $user->id;
});
}
public function test_user_can_mark_notification_as_read(): void
{
$user = User::factory()->create();
$service = app(NotificationService::class);
$notification = $service->send($user->id, 'Test', 'Message');
$this->assertFalse($notification->read);
$service->markAsRead($notification->id);
$this->assertTrue($notification->fresh()->read);
}
public function test_notification_broadcasts_to_correct_channel(): void
{
Event::fake();
$user = User::factory()->create();
$service = app(NotificationService::class);
$service->send($user->id, 'Test', 'Message');
Event::assertDispatched(NewNotification::class, function ($event) use ($user) {
$channels = $event->broadcastOn();
return $channels[0]->name === "private-user.{$user->id}";
});
}
}
The third test verifies that notifications go to the right channel. This has saved me from a bug where I accidentally hard-coded a user ID in the channel name. Always test your channel logic.
Choosing Between Reverb and Pusher
Here's my honest take after running both in production.
Use Reverb when you're building for production with any meaningful traffic, you want full control, or you want to avoid per-message pricing. Reverb handles thousands of concurrent connections on a single server, scales horizontally with Redis, and costs you nothing beyond hosting. It's what I recommend for most projects now.
Use Pusher when you're building a quick prototype or MVP, you don't want to manage WebSocket infrastructure at all, or your traffic is light enough that Pusher's free tier covers it. It works out of the box with zero setup, and the dashboard is nice for debugging.
The migration path between them is smooth. Laravel abstracts the broadcasting driver, so switching from Pusher to Reverb later means changing your .env variables and Echo configuration. Your events, channels, and frontend listeners stay exactly the same.
Common Mistakes to Avoid
Not using queues for broadcasting. Synchronous broadcasting blocks your HTTP response. Use ShouldBroadcast with a queue worker. Always.
Missing channel authentication. Without proper authorization in routes/channels.php, users could listen to channels they shouldn't have access to. Test this explicitly.
Forgetting the dot prefix. When you use broadcastAs() to set a custom event name, your Echo listener needs a dot prefix: .notification.created, not notification.created. This is the single most common "why isn't it working" question I see.
Confusing Reverb host variables. REVERB_HOST is where clients connect (your public domain). REVERB_SERVER_HOST is where the process actually listens (usually 0.0.0.0). Mixing these up means either clients can't connect or Reverb only listens on localhost.
Overbroadcasting. Don't broadcast every database change. Only push events that users actually need to see immediately. Broadcasting unnecessary events wastes bandwidth and overwhelms connected clients. Check your broadcasting logic with the HTTP status codes reference when debugging failed broadcast requests.
FAQ
Do I need Redis to use Laravel Reverb?
Not for a single server. Reverb works fine standalone. You only need Redis if you're scaling horizontally across multiple Reverb servers, where Redis handles the pub/sub communication between them.
Can I use Laravel's built-in notification system with broadcasting?
Yes. Laravel's Notification class supports a broadcast channel alongside mail, database, and others. You can use $user->notify(new TaskAssigned($task)) and have it broadcast automatically. The approach in this guide uses custom events for more control, but both work.
How many concurrent connections can Reverb handle?
A single Reverb server can handle thousands of concurrent WebSocket connections. The exact number depends on your server resources and message volume. You may need to increase your operating system's open file limit (ulimit -n) for high-traffic applications. With Redis-based horizontal scaling, there's effectively no ceiling.
Should I use useEcho or the manual window.Echo approach?
Use useEcho from @laravel/echo-vue (or @laravel/echo-react). It handles channel subscription and cleanup automatically, supports TypeScript, and integrates cleanly with the Composition API. The manual window.Echo approach still works but requires you to manage lifecycle events yourself.
How do I handle notifications when the user is offline?
Persist notifications in the database (like we do in this guide) and fetch unread ones when the user reconnects. For critical notifications, add an email fallback. Check if the user has an active WebSocket connection before deciding which delivery channel to use.
Wrapping Up
Real-time notifications used to be a pain to set up in Laravel. Between third-party WebSocket services, manual Echo configuration, and Options API boilerplate, there was a lot of ceremony. That's changed. Reverb gives you a production-grade WebSocket server with one Artisan command. Echo 2.1's useEcho hook cuts the frontend code in half. And Pulse gives you monitoring out of the box.
Start with the basic setup I've outlined here, test it with a simple notification type, then expand to cover all the events in your application that need real-time delivery. Your users will notice the difference immediately.
Need help building real-time features into your Laravel application? I've shipped notification systems for several production apps and can help you get running quickly. Get in touch to discuss your project.
About Hafiz
Senior Full-Stack Developer with 9+ years building web apps and SaaS platforms. I specialize in Laravel and Vue.js, and I write about the real decisions behind shipping production software.
View My Work →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
Building a Full-Stack File Upload System with Laravel, Vue.js, and S3
Build a production-ready file upload system with Laravel, Vue.js drag-and-drop,...
Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Dockerize your Laravel and Vue.js application with PHP 8.3, MySQL 8.4, Nginx, an...
The Ralph Wiggum Technique: Let Claude Code Work Through Your Entire Task List While You Sleep
Queue up your Laravel task list, walk away, and come back to finished work. Here...