10 min read

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.

Implementing Real-Time Notifications with Laravel: A Complete Guide

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">&times;</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

Hafiz Riaz

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