Implementing Real-Time Notifications with Laravel: A Complete Guide
Learn how to implement real-time notifications in Laravel using broadcasting, WebSockets, and Laravel Echo for instant user updates.
        
        
        You know that feeling when you refresh a page obsessively, waiting for an update? Your users hate it too. Real-time notifications solve this problem by pushing updates instantly to your users without requiring page refreshes. Whether you're building a chat app, a project management tool, or a SaaS dashboard, real-time notifications create a responsive, modern user experience that keeps users engaged.
I've implemented real-time notifications in several projects, including a project management tool where team members needed instant updates about task assignments and a customer support platform where agents had to respond immediately to new tickets. In this guide, I'll walk you through everything I've learned about implementing Laravel's broadcasting system, from basic setup to production-ready configurations.
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 immediately. Without real-time notifications, you're forcing users to manually check for updates, which creates a frustrating experience and reduces engagement.
Real-time notifications also reduce server load. Instead of users polling your server every few seconds (which hammers your database), WebSockets maintain a single persistent connection that pushes updates only when something actually happens.
Understanding Laravel Broadcasting
Laravel's broadcasting system lets you broadcast 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 multiple broadcasting drivers including Pusher, Ably, Redis, and Laravel Reverb (the new first-party WebSocket server). You pick one based on your needs and infrastructure.
Events: These are standard Laravel events that implement the ShouldBroadcast interface. When you fire these events, Laravel automatically broadcasts them to connected clients.
Echo Client: This is Laravel's JavaScript library that listens for broadcast events on the frontend and triggers callbacks when they arrive.
Setting Up Broadcasting in Laravel
Let's start with the backend configuration. First, you'll need to decide on a broadcasting driver. I'll cover both Pusher (easiest for getting started) and Laravel Reverb (best for production when you control your infrastructure).
Installing Required Packages
Run these commands to install the necessary dependencies:
composer require pusher/pusher-php-server
npm install --save-dev laravel-echo pusher-js
If you're using Laravel Reverb instead, install it with:
composer require laravel/reverb
php artisan reverb:install
Configuring Your Broadcasting Driver
Open your .env file and set up your broadcasting configuration. For Pusher:
BROADCAST_DRIVER=pusher
PUSHER_APP_ID=your_app_id
PUSHER_APP_KEY=your_app_key
PUSHER_APP_SECRET=your_app_secret
PUSHER_APP_CLUSTER=mt1
For Laravel Reverb:
BROADCAST_DRIVER=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
Next, uncomment the broadcasting service provider in your config/app.php:
'providers' => [
    // Other providers...
    App\Providers\BroadcastServiceProvider::class,
],
Creating a Notification Event
Now let's create an event that will broadcast notifications. I'll create a NewNotification event that fires whenever a user receives a notification.
Run this Artisan command:
php artisan make:event NewNotification
Open the generated event file and implement the ShouldBroadcast interface:
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
use App\Models\Notification;
class NewNotification implements ShouldBroadcast
{
    use InteractsWithSockets, SerializesModels;
    public $notification;
    public function __construct(Notification $notification)
    {
        $this->notification = $notification;
    }
    /**
     * Get the channels the event should broadcast on.
     */
    public function broadcastOn()
    {
        return new Channel('user.' . $this->notification->user_id);
    }
    /**
     * The event's broadcast name.
     */
    public function broadcastAs()
    {
        return 'notification.created';
    }
    /**
     * Get the data to broadcast.
     */
    public function broadcastWith()
    {
        return [
            'id' => $this->notification->id,
            'title' => $this->notification->title,
            'message' => $this->notification->message,
            'created_at' => $this->notification->created_at->toISOString(),
        ];
    }
}
This event broadcasts to a private channel specific to each user. The broadcastOn method determines which channel receives the event, while broadcastWith specifies what data gets sent to the frontend.
Setting Up Channel Authentication
Since we're using private channels (prefixed with "user."), we need to authorize who can listen to them. Open routes/channels.php and add this authorization logic:
<?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. Super important for security.
Creating the Notification Model
Let's create a simple notification model to store our notifications:
php artisan make:model Notification -m
In the migration file:
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
    public function up()
    {
        Schema::create('notifications', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->onDelete('cascade');
            $table->string('title');
            $table->text('message');
            $table->boolean('read')->default(false);
            $table->string('type')->nullable(); // For different notification types
            $table->json('data')->nullable(); // For additional metadata
            $table->timestamps();
        });
    }
    public function down()
    {
        Schema::dropIfExists('notifications');
    }
};
Run the migration:
php artisan migrate
Broadcasting the Notification
Now let's create a service or controller method that creates notifications and broadcasts them. I typically put this in a NotificationService:
<?php
namespace App\Services;
use App\Models\Notification;
use App\Events\NewNotification;
class NotificationService
{
    /**
     * Create and broadcast a notification.
     */
    public function send($userId, $title, $message, $type = null, $data = [])
    {
        $notification = Notification::create([
            'user_id' => $userId,
            'title' => $title,
            'message' => $message,
            'type' => $type,
            'data' => $data,
        ]);
        // Broadcast the notification
        broadcast(new NewNotification($notification));
        return $notification;
    }
    /**
     * Mark notification as read.
     */
    public function markAsRead($notificationId)
    {
        return Notification::where('id', $notificationId)
            ->update(['read' => true]);
    }
    /**
     * Get unread count for a user.
     */
    public function getUnreadCount($userId)
    {
        return Notification::where('user_id', $userId)
            ->where('read', false)
            ->count();
    }
}
You can inject this service anywhere in your application. For example, when someone assigns a task:
use App\Services\NotificationService;
public function assignTask(Request $request, NotificationService $notifier)
{
    $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);
}
Frontend Setup with Laravel Echo
Time to set up the frontend to receive these notifications. First, configure Laravel Echo in your resources/js/bootstrap.js:
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
    forceTLS: true,
    encrypted: true,
    authEndpoint: '/broadcasting/auth',
    auth: {
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
    },
});
If you're using Laravel Reverb:
window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT,
    wsTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
    forceTLS: false,
    enabledTransports: ['ws', 'wss'],
    authEndpoint: '/broadcasting/auth',
    auth: {
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
        },
    },
});
Building the Notification Component
Let's create a Vue.js component that displays notifications in real-time. Create NotificationBell.vue:
<template>
    <div class="notification-bell">
        <button @click="toggleDropdown" 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="badge">{{ unreadCount }}</span>
        </button>
        <div v-if="showDropdown" class="notification-dropdown">
            <div class="header">
                <h3>Notifications</h3>
                <button @click="markAllAsRead" v-if="unreadCount > 0">
                    Mark all read
                </button>
            </div>
            <div class="notifications-list">
                <div 
                    v-for="notification in notifications" 
                    :key="notification.id"
                    :class="['notification-item', { unread: !notification.read }]"
                    @click="handleNotificationClick(notification)"
                >
                    <h4>{{ notification.title }}</h4>
                    <p>{{ notification.message }}</p>
                    <span class="time">{{ formatTime(notification.created_at) }}</span>
                </div>
                <div v-if="notifications.length === 0" class="empty">
                    No notifications yet
                </div>
            </div>
        </div>
    </div>
</template>
<script>
export default {
    name: 'NotificationBell',
    props: {
        userId: {
            type: Number,
            required: true,
        },
    },
    data() {
        return {
            notifications: [],
            unreadCount: 0,
            showDropdown: false,
        };
    },
    mounted() {
        this.fetchNotifications();
        this.listenForNotifications();
    },
    methods: {
        async fetchNotifications() {
            try {
                const response = await axios.get('/api/notifications');
                this.notifications = response.data.notifications;
                this.unreadCount = response.data.unread_count;
            } catch (error) {
                console.error('Failed to fetch notifications:', error);
            }
        },
        listenForNotifications() {
            window.Echo.private(`user.${this.userId}`)
                .listen('.notification.created', (event) => {
                    // Add new notification to the top
                    this.notifications.unshift(event);
                    this.unreadCount++;
                    // Show browser notification if permitted
                    this.showBrowserNotification(event);
                    // Play sound (optional)
                    this.playNotificationSound();
                });
        },
        showBrowserNotification(notification) {
            if ('Notification' in window && Notification.permission === 'granted') {
                new Notification(notification.title, {
                    body: notification.message,
                    icon: '/notification-icon.png',
                });
            }
        },
        playNotificationSound() {
            const audio = new Audio('/notification-sound.mp3');
            audio.play().catch(e => console.log('Could not play sound:', e));
        },
        toggleDropdown() {
            this.showDropdown = !this.showDropdown;
        },
        async handleNotificationClick(notification) {
            if (!notification.read) {
                await this.markAsRead(notification.id);
            }
            // Handle navigation based on notification type
            if (notification.data?.task_id) {
                this.$router.push(`/tasks/${notification.data.task_id}`);
            }
        },
        async markAsRead(notificationId) {
            try {
                await axios.post(`/api/notifications/${notificationId}/read`);
                
                const notification = this.notifications.find(n => n.id === notificationId);
                if (notification) {
                    notification.read = true;
                    this.unreadCount = Math.max(0, this.unreadCount - 1);
                }
            } catch (error) {
                console.error('Failed to mark notification as read:', error);
            }
        },
        async markAllAsRead() {
            try {
                await axios.post('/api/notifications/mark-all-read');
                this.notifications.forEach(n => n.read = true);
                this.unreadCount = 0;
            } catch (error) {
                console.error('Failed to mark all as read:', error);
            }
        },
        formatTime(timestamp) {
            const date = new Date(timestamp);
            const now = new Date();
            const diffMs = now - date;
            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 date.toLocaleDateString();
        },
    },
    beforeUnmount() {
        // Clean up Echo listener
        window.Echo.leave(`user.${this.userId}`);
    },
};
</script>
This component handles everything: fetching existing notifications, listening for new ones in real-time, displaying browser notifications, and marking items as read.
Creating the API Endpoints
You'll need API endpoints to support the notification component. Create a NotificationController:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Services\NotificationService;
use Illuminate\Http\Request;
class NotificationController extends Controller
{
    protected $notificationService;
    public function __construct(NotificationService $notificationService)
    {
        $this->notificationService = $notificationService;
    }
    /**
     * Get user's notifications.
     */
    public function index(Request $request)
    {
        $notifications = $request->user()
            ->notifications()
            ->orderBy('created_at', 'desc')
            ->limit(50)
            ->get();
        $unreadCount = $this->notificationService->getUnreadCount(
            $request->user()->id
        );
        return response()->json([
            'notifications' => $notifications,
            'unread_count' => $unreadCount,
        ]);
    }
    /**
     * Mark a notification as read.
     */
    public function markAsRead(Request $request, $id)
    {
        $notification = $request->user()
            ->notifications()
            ->findOrFail($id);
        $this->notificationService->markAsRead($notification->id);
        return response()->json(['success' => true]);
    }
    /**
     * Mark all notifications as read.
     */
    public function markAllAsRead(Request $request)
    {
        $request->user()
            ->notifications()
            ->where('read', false)
            ->update(['read' => true]);
        return response()->json(['success' => true]);
    }
}
Add these routes to 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']);
});
Running Laravel Reverb in Production
If you chose Laravel Reverb, you'll need to run it as a service. Create a supervisor configuration at /etc/supervisor/conf.d/reverb.conf:
[program:reverb]
command=php /path/to/your/app/artisan reverb:start
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
Then restart supervisor:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start reverb
For deployment, make sure your firewall allows traffic on port 8080 (or whatever port you configured for Reverb).
Optimizing Performance with Queues
Broadcasting events can slow down your application if done synchronously. I always queue my broadcasts for better performance. Update your event to implement ShouldBroadcastNow during development, but use ShouldBroadcast with queues in production.
Configure your queue in .env:
QUEUE_CONNECTION=redis
Make sure you're running the queue worker:
php artisan queue:work --tries=3
When you broadcast events, Laravel will automatically queue them and process them in the background, keeping your HTTP responses fast.
Adding Toast Notifications
Real-time notifications work great with toast messages. Here's a simple toast component you can add:
<template>
    <div v-if="visible" :class="['toast', type]">
        <div class="toast-content">
            <span class="toast-icon">{{ icon }}</span>
            <div>
                <h4>{{ title }}</h4>
                <p>{{ message }}</p>
            </div>
        </div>
        <button @click="close" class="toast-close">×</button>
    </div>
</template>
<script>
export default {
    name: 'ToastNotification',
    props: {
        title: String,
        message: String,
        type: {
            type: String,
            default: 'info',
        },
        duration: {
            type: Number,
            default: 5000,
        },
    },
    data() {
        return {
            visible: false,
        };
    },
    computed: {
        icon() {
            const icons = {
                success: '✓',
                error: '✗',
                warning: '⚠',
                info: 'ℹ',
            };
            return icons[this.type] || icons.info;
        },
    },
    mounted() {
        this.visible = true;
        if (this.duration > 0) {
            setTimeout(() => this.close(), this.duration);
        }
    },
    methods: {
        close() {
            this.visible = false;
            setTimeout(() => this.$emit('close'), 300);
        },
    },
};
</script>
You can trigger toast notifications whenever a real-time event arrives, giving users immediate visual feedback.
Common Mistakes to Avoid
Not Using Queues: Synchronous broadcasting will slow down your application. Always queue broadcasts in production to keep response times fast.
Missing Channel Authentication: I once deployed an app where users could listen to any channel because I forgot to set up proper authorization. Always secure your private channels in routes/channels.php.
Forgetting CORS Configuration: If your frontend and backend are on different domains, you'll need to configure CORS for the broadcasting auth endpoint. Add your frontend domain to config/cors.php.
Not Handling Disconnections: WebSocket connections can drop. Make sure your frontend reconnects automatically when the connection is lost. Laravel Echo handles this by default, but test it thoroughly.
Overbroadcasting: Don't broadcast every tiny change. Only broadcast events that users actually need to know about immediately. Broadcasting unnecessary events wastes bandwidth and can overwhelm clients.
Ignoring Rate Limits: In high-traffic applications, implement rate limiting on your broadcasting endpoints to prevent abuse. Use Laravel's built-in rate limiting middleware.
Choosing Between Pusher and Laravel Reverb
So which broadcasting driver should you use? Here's what I recommend based on my experience:
Use Pusher when:
- You're prototyping or building an MVP
 - You don't want to manage WebSocket infrastructure
 - Your traffic is moderate (Pusher's free tier handles 200k messages/day)
 - You need reliability without DevOps overhead
 
Use Laravel Reverb when:
- You're building for production with high traffic
 - You want full control over your infrastructure
 - You want to minimize costs (no per-message pricing)
 - You have the DevOps capacity to monitor and maintain it
 
I typically start with Pusher for new projects. Once traffic grows and costs become a concern, I migrate to Reverb. The code changes required for migration are minimal since Laravel abstracts the broadcasting driver.
Testing Real-Time Notifications
Testing WebSocket functionality requires a different approach than standard HTTP tests. Here's how I test notifications:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use App\Models\User;
use App\Services\NotificationService;
use Illuminate\Support\Facades\Event;
use App\Events\NewNotification;
class NotificationTest extends TestCase
{
    public function test_notification_is_broadcast_when_created()
    {
        Event::fake();
        $user = User::factory()->create();
        $service = app(NotificationService::class);
        $service->send($user->id, 'Test', 'Test message');
        Event::assertDispatched(NewNotification::class);
    }
    public function test_user_can_mark_notification_as_read()
    {
        $user = User::factory()->create();
        $service = app(NotificationService::class);
        $notification = $service->send($user->id, 'Test', 'Test message');
        $this->assertFalse($notification->read);
        $service->markAsRead($notification->id);
        $this->assertTrue($notification->fresh()->read);
    }
}
For end-to-end testing with actual WebSocket connections, I use Laravel Dusk with some custom helpers to wait for broadcast events.
Extending the System
Once you have basic notifications working, you can extend the system with additional features:
Notification Preferences: Let users choose which notifications they want to receive. Add a notification_preferences JSON column to your users table.
Multiple Notification Types: Create different event classes for different notification types (TaskAssigned, CommentAdded, PaymentReceived) so you can handle them differently on the frontend.
Push Notifications: Integrate with Firebase Cloud Messaging or Apple Push Notification service to send notifications to mobile devices.
Email Fallbacks: If a user isn't online when a notification is broadcast, send them an email as a fallback. You can check if they're connected to your WebSocket server before deciding.
Notification History: Keep a longer history of notifications and implement pagination so users can scroll through older notifications.
The beauty of Laravel's broadcasting system is that it's flexible enough to handle all these extensions without major architectural changes.
Wrapping Up
Real-time notifications transform how users interact with your application. Instead of constantly refreshing pages or wondering if something changed, they get instant feedback about important events. The setup takes a bit of work upfront, but once you have the infrastructure in place, adding new notification types becomes trivial.
Start with the basic setup I've outlined here, test it thoroughly with a simple notification type, then gradually expand to cover all the events in your application that deserve real-time updates. Your users will notice the difference immediately.
Need help implementing real-time notifications in your Laravel application? I've built notification systems for several production applications and can help you get set up quickly. Contact me to discuss your project.
Need Help With Your Laravel Project?
I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.
⚡ Currently available for 2-3 new projects
            About Hafiz Riaz
Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.
View Portfolio →Get web development tips via email
Join 50+ developers • No spam • Unsubscribe anytime
Related Articles
                                                        Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...
                                                        Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...
                                                        Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery