featured image

Implementing Real-Time Notifications with Laravel Reverb

Learn how to build a real-time notification system using Laravel Reverb, Laravel Broadcasting, and Vue 3 Composition API for instant user feedback.

Published

Mon Oct 20 2025

Technologies Used

Laravel Vue.js Inertia.js
Intermediate 37 minutes

The Polling Tax

Every social platform that uses HTTP polling is quietly burning server resources. You set up an interval that fires every five seconds, 1,000 users are online, and suddenly you have 12,000 requests per minute hitting your database — mostly empty responses. Users see notifications up to five seconds late. Mobile users burn battery making requests that return nothing. And none of that changes the fact that users expect instant feedback in 2025.

WebSockets flip this model. Instead of clients asking “anything new?” on a schedule, the server pushes data to connected clients the moment something happens. One persistent connection per user, notifications under 100ms, zero wasted queries.

This tutorial covers the real-time notification system in MyKpopLists: friend requests, comments, and likes delivered instantly using Laravel Reverb for the WebSocket server, Laravel Broadcasting for event dispatch, Laravel Echo for the frontend client, and a Vue 3 composable to tie it all together.

What You Need Before Starting

Knowledge Base:

  • Intermediate Laravel (events, broadcasting, notifications)
  • Vue 3 Composition API
  • Understanding of WebSockets vs HTTP
  • Basic pub/sub patterns

Environment:

  • Laravel 12.x
  • Laravel Reverb 1.0+
  • Laravel Echo 2.2+
  • Pusher JS 8.4+ (protocol compatibility)
  • Vue 3.5+

Setup:

composer require laravel/reverb
php artisan reverb:install
php artisan reverb:start  # Terminal 1
php artisan serve         # Terminal 2
npm run dev               # Terminal 3

.env configuration:

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-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}"

What Actually Happens When a Friend Request Arrives

The flow is worth understanding before touching any code. User 1 sends a friend request. Laravel stores it in the database and dispatches a FriendRequestNotification. The notification is queued, a worker picks it up, stores it in the notifications table, and broadcasts it to Reverb. Reverb pushes it over the WebSocket to User 2’s browser. User 2’s Vue component updates instantly — no page refresh, no polling.

The reason queueing matters: it’s non-blocking. User 1’s POST request returns immediately. The notification delivery happens asynchronously in the background, with retry logic if anything fails. If Reverb is briefly unavailable, the notification still gets persisted to the database and delivered when the connection recovers.

Building the Notification Class

Laravel notifications declare their delivery channels in a via() method. Using both database and broadcast means the notification gets persisted for reliability and delivered instantly over WebSockets:

<?php
// app/Notifications/FriendRequestNotification.php

namespace App\Notifications;

use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;

class FriendRequestNotification extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public User $requester
    ) {}

    public function via(object $notifiable): array
    {
        return ['database', 'broadcast'];
    }

    public function toArray(object $notifiable): array
    {
        return [
            'type' => 'friend_request',
            'requester_id' => $this->requester->id,
            'requester_name' => $this->requester->name,
            'requester_username' => $this->requester->username,
            'requester_avatar' => $this->requester->profile_picture,
            'message' => "{$this->requester->name} sent you a friend request",
        ];
    }

    public function toBroadcast(object $notifiable): BroadcastMessage
    {
        return new BroadcastMessage([
            'id' => $this->id,
            'type' => 'friend_request',
            'data' => $this->toArray($notifiable),
            'created_at' => now()->toISOString(),
        ]);
    }

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('App.Models.User.' . $this->requester->id),
        ];
    }
}

Dispatching it from a controller is one line:

$addressee->notify(new FriendRequestNotification(auth()->user()));

Locking Down Private Channels

The channel name App.Models.User.{id} is private — only the authenticated user with that ID should be able to subscribe. Laravel’s channel authorization lives in routes/channels.php:

Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});

The callback receives the authenticated user and the ID from the channel name. Return true to authorize, false to deny. This check runs every time a client tries to subscribe. An unauthenticated user or a user trying to subscribe to someone else’s channel gets rejected before any notification data flows.

One thing worth emphasizing: return true; without checking the ID means anyone can subscribe to anyone’s notification channel. Always verify identity.

The Frontend: Echo Setup and the Notifications Composable

Connect Echo to Reverb in resources/js/echo.ts:

import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

window.Pusher = Pusher

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 ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    auth: {
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
        },
    },
})

The composable handles all the notification logic — loading from the server on mount, subscribing to real-time events, marking as read, and cleaning up when the component unmounts:

// resources/js/composables/useNotifications.ts

import { ref, onMounted, onUnmounted } from 'vue'
import Echo from '@/echo'

export interface Notification {
    id: string
    type: string
    data: {
        message: string
        requester_id?: number
        requester_name?: string
        requester_avatar?: string
        [key: string]: any
    }
    read_at: string | null
    created_at: string
}

export function useNotifications(userId: number) {
    const notifications = ref<Notification[]>([])
    const unreadCount = ref(0)
    const isConnected = ref(false)

    const loadNotifications = async () => {
        try {
            const response = await fetch('/api/notifications')
            const data = await response.json()
            notifications.value = data.notifications
            unreadCount.value = data.unread_count
        } catch (error) {
            console.error('Failed to load notifications:', error)
        }
    }

    const markAsRead = async (notificationId: string) => {
        try {
            await fetch(`/api/notifications/${notificationId}/read`, {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
                },
            })

            const notification = notifications.value.find(n => n.id === notificationId)
            if (notification) {
                notification.read_at = new Date().toISOString()
                unreadCount.value = Math.max(0, unreadCount.value - 1)
            }
        } catch (error) {
            console.error('Failed to mark notification as read:', error)
        }
    }

    const markAllAsRead = async () => {
        try {
            await fetch('/api/notifications/mark-all-read', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                    'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
                },
            })
            notifications.value.forEach(n => {
                n.read_at = new Date().toISOString()
            })
            unreadCount.value = 0
        } catch (error) {
            console.error('Failed to mark all as read:', error)
        }
    }

    onMounted(() => {
        loadNotifications()

        const channel = Echo.private(`App.Models.User.${userId}`)

        channel.notification((notification: Notification) => {
            notifications.value.unshift(notification)
            unreadCount.value++

            if ('Notification' in window && Notification.permission === 'granted') {
                new Notification(notification.data.message, {
                    icon: notification.data.requester_avatar || '/logo.png',
                    tag: notification.id,
                })
            }

            const audio = new Audio('/sounds/notification.mp3')
            audio.play().catch(() => {})
        })

        Echo.connector.pusher.connection.bind('connected', () => {
            isConnected.value = true
        })

        Echo.connector.pusher.connection.bind('disconnected', () => {
            isConnected.value = false
        })
    })

    onUnmounted(() => {
        Echo.leave(`App.Models.User.${userId}`)
    })

    return {
        notifications,
        unreadCount,
        isConnected,
        markAsRead,
        markAllAsRead,
        loadNotifications,
    }
}

The onUnmounted cleanup is not optional. If you subscribe to Echo.private(...) without calling Echo.leave(...) on unmount, the subscription stays active after the component is destroyed. The event listener closure holds a reference to the component’s reactive state, preventing garbage collection. In a single-page app where users navigate frequently, this adds up.

The NotificationBell Component

<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { useNotifications } from '@/composables/useNotifications'
import { Bell } from 'lucide-vue-next'

const page = usePage()
const userId = computed(() => page.props.auth.user.id)

const {
    notifications,
    unreadCount,
    isConnected,
    markAsRead,
    markAllAsRead,
} = useNotifications(userId.value)

const isOpen = ref(false)

const handleNotificationClick = (notification: any) => {
    markAsRead(notification.id)
    if (notification.type === 'friend_request') {
        window.location.href = '/friends/requests'
    } else if (notification.type === 'comment') {
        window.location.href = `/posts/${notification.data.post_id}`
    }
    isOpen.value = false
}

const formatTime = (timestamp: string) => {
    const diff = Date.now() - new Date(timestamp).getTime()
    const minutes = Math.floor(diff / 60000)
    const hours = Math.floor(diff / 3600000)
    const days = Math.floor(diff / 86400000)
    if (minutes < 1) return 'Just now'
    if (minutes < 60) return `${minutes}m ago`
    if (hours < 24) return `${hours}h ago`
    return `${days}d ago`
}
</script>

The isConnected flag is worth surfacing in the UI — a pulsing bell or a yellow dot tells users their real-time connection is down, rather than leaving them wondering why notifications stopped appearing.

Backend Endpoints for Notification Management

<?php
// app/Http/Controllers/NotificationController.php

class NotificationController extends Controller
{
    public function index(Request $request)
    {
        $notifications = Auth::user()
            ->notifications()
            ->latest()
            ->paginate(20);

        return response()->json([
            'notifications' => $notifications->items(),
            'unread_count' => Auth::user()->unreadNotifications()->count(),
            'pagination' => [
                'current_page' => $notifications->currentPage(),
                'last_page' => $notifications->lastPage(),
                'total' => $notifications->total(),
            ],
        ]);
    }

    public function markAsRead(Request $request, string $id)
    {
        $notification = Auth::user()
            ->notifications()
            ->where('id', $id)
            ->firstOrFail();

        $notification->markAsRead();
        return response()->json(['success' => true]);
    }

    public function markAllAsRead(Request $request)
    {
        Auth::user()->unreadNotifications->markAsRead();
        return response()->json(['success' => true]);
    }
}

Handling Edge Cases That Will Bite You

Batch notifications instead of looping. Calling $user->notify(...) in a loop sends one notification per iteration. A thousand users means a thousand separate dispatches to the queue. Use Notification::send($users, new SomeNotification()) for bulk sends, or add delays between dispatches to avoid overwhelming Reverb.

Echo handles reconnection automatically. When a user closes their laptop and reopens it, Echo detects the disconnection and reconnects using exponential backoff. After reconnect, it resubscribes to all channels. You don’t need to build reconnection logic — but you do need to handle the disconnected state in your UI so users know real-time updates are temporarily paused.

Each WebSocket connection uses roughly 10KB of RAM on the server. For 10,000 concurrent users, that’s about 100MB — far less than the same number of HTTP polling connections would consume. Reverb is built on ReactPHP’s event loop, which handles thousands of concurrent connections on a single thread using non-blocking I/O.

Testing broadcast behavior without a real WebSocket server uses Laravel’s fakes:

use Illuminate\Support\Facades\Notification;

public function test_friend_request_notification_is_sent()
{
    Notification::fake();

    $requester = User::factory()->create();
    $addressee = User::factory()->create();

    $addressee->notify(new FriendRequestNotification($requester));

    Notification::assertSentTo(
        $addressee,
        FriendRequestNotification::class,
        function ($notification) use ($requester) {
            return $notification->requester->id === $requester->id;
        }
    );
}

The architectural takeaway: always persist notifications to the database. A WebSocket message that arrives while a user is offline is lost. The database record survives — and when the user logs back in, loadNotifications() fetches it. Speed and reliability don’t have to conflict; you just need both layers.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!