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

Purpose

The Problem

You’re building a social platform where users need instant feedback:

  • Friend requests arrive in real-time
  • New comments appear without refreshing
  • Likes update immediately
  • Activity notifications pop up instantly

The naive approach: Poll the server every few seconds.

// ❌ NAIVE APPROACH - Polling
setInterval(async () => {
  const response = await fetch('/api/notifications')
  const notifications = await response.json()
  updateUI(notifications)
}, 5000)  // Check every 5 seconds

Problems:

  • Wasted resources: 1000 users × 12 requests/minute = 12,000 requests/minute (mostly empty responses)
  • Delayed updates: Up to 5-second delay before users see notifications
  • Server overload: Constant database queries even when nothing changed
  • Battery drain: Mobile devices constantly making HTTP requests
  • Not truly real-time: Users expect instant updates, not 5-second delays

The Solution

We’re analyzing MyKpopLists’ real-time notification system using:

  • Laravel Reverb for WebSocket server
  • Laravel Broadcasting for event dispatching
  • Laravel Echo for frontend WebSocket client
  • Database notifications for persistence
  • Vue 3 Composition API for reactive UI

What You’ll Learn

This tutorial covers:

  • WebSocket connections and persistent channels
  • Laravel Broadcasting system and event classes
  • Private channel authorization
  • Frontend WebSocket client with Laravel Echo
  • Real-time UI updates with Vue reactivity
  • Notification persistence and read/unread states

Prerequisites & Tooling

Knowledge Base

  • Intermediate Laravel (events, broadcasting, notifications)
  • Vue 3 Composition API
  • Understanding of WebSockets vs HTTP
  • Basic understanding of 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 Commands

# Install Reverb
composer require laravel/reverb

# Publish configuration
php artisan reverb:install

# Start Reverb server
php artisan reverb:start

# In another terminal, start Laravel
php artisan serve

# In another terminal, start Vite
npm run dev

Environment Configuration

# .env
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}"

High-Level Architecture

The Real-Time Flow

sequenceDiagram
    participant U1 as User 1 Browser
    participant R as Reverb Server
    participant L as Laravel App
    participant DB as Database
    participant U2 as User 2 Browser

    U1->>L: POST /friend-request
    L->>DB: Store friend request
    L->>L: Dispatch FriendRequestNotification
    L->>R: Broadcast to private channel
    R->>U2: Push notification via WebSocket
    U2->>U2: Update UI instantly
    
    Note over U2: No page refresh needed!

WebSocket vs HTTP Comparison

graph LR
    subgraph HTTP Polling
        A1[Client] -->|Request| B1[Server]
        B1 -->|Response| A1
        A1 -->|Request| B1
        B1 -->|Response| A1
        A1 -->|Request| B1
        B1 -->|Response| A1
    end
    
    subgraph WebSocket
        A2[Client] <-->|Persistent Connection| B2[Server]
        B2 -.->|Push when ready| A2
    end

Mail Delivery

Think of notifications like mail delivery:

HTTP Polling = Walking to your mailbox every 5 minutes to check for mail

  • Wastes time and energy
  • Mail sits in box until you check
  • You might check 100 times with no mail

WebSockets = Mail carrier rings your doorbell when mail arrives

  • Instant notification
  • No wasted trips
  • Only notified when there’s actually mail

The Implementation

Understanding the Naive Polling Approach

Most developers start with HTTP polling:

// ❌ NAIVE APPROACH - Polling every 5 seconds
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const notifications = ref([])
let pollInterval: number

onMounted(() => {
  pollInterval = setInterval(async () => {
    const response = await fetch('/api/notifications')
    notifications.value = await response.json()
  }, 5000)
})

onUnmounted(() => {
  clearInterval(pollInterval)
})
</script>

Problems:

  • 12 requests per minute per user
  • 5-second delay for updates
  • Wasted bandwidth on empty responses
  • Server CPU spent on repeated queries

Creating the Notification Event Class

Laravel notifications can be broadcast in real-time:

<?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
    ) {}

    /**
     * Get the notification's delivery channels.
     */
    public function via(object $notifiable): array
    {
        return ['database', 'broadcast'];
        // 'database': Persists notification
        // 'broadcast': Sends via WebSocket
    }

    /**
     * Get the array representation for database storage.
     */
    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",
        ];
    }

    /**
     * Get the broadcastable representation of the notification.
     */
    public function toBroadcast(object $notifiable): BroadcastMessage
    {
        return new BroadcastMessage([
            'id' => $this->id,
            'type' => 'friend_request',
            'data' => $this->toArray($notifiable),
            'created_at' => now()->toISOString(),
        ]);
    }

    /**
     * Get the channels the event should broadcast on.
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('App.Models.User.' . $this->requester->id),
        ];
    }
}

Key concepts:

  • via(): Specifies delivery channels (database + broadcast)
  • toArray(): Data stored in database
  • toBroadcast(): Data sent via WebSocket
  • ShouldQueue: Process asynchronously

Dispatching Notifications

Send notifications when events occur:

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

namespace App\Http\Controllers;

use App\Models\Friendship;
use App\Models\User;
use App\Notifications\FriendRequestNotification;
use Illuminate\Http\Request;

class FriendController extends Controller
{
    public function sendFriendRequest(Request $request)
    {
        $addressee = User::findOrFail($request->addressee_id);
        
        // Create friendship record
        $friendship = Friendship::create([
            'requester_id' => auth()->id(),
            'addressee_id' => $addressee->id,
            'status' => 'pending',
        ]);

        // Send notification (database + broadcast)
        $addressee->notify(new FriendRequestNotification(auth()->user()));

        return back()->with('success', 'Friend request sent!');
    }

    public function acceptFriendRequest(Friendship $friendship)
    {
        $friendship->update(['status' => 'accepted']);

        // Notify the requester
        $friendship->requester->notify(
            new FriendAcceptedNotification(auth()->user())
        );

        return back()->with('success', 'Friend request accepted!');
    }
}

What happens:

  1. Friendship created in database
  2. notify() triggers notification
  3. Notification stored in notifications table
  4. Notification broadcast via Reverb
  5. Frontend receives via WebSocket
  6. UI updates instantly

Configuring Broadcasting

Set up the broadcasting configuration:

<?php
// config/broadcasting.php

return [
    'default' => env('BROADCAST_CONNECTION', 'reverb'),

    'connections' => [
        'reverb' => [
            'driver' => 'reverb',
            'key' => env('REVERB_APP_KEY'),
            'secret' => env('REVERB_APP_SECRET'),
            'app_id' => env('REVERB_APP_ID'),
            'options' => [
                'host' => env('REVERB_HOST', '127.0.0.1'),
                'port' => env('REVERB_PORT', 8080),
                'scheme' => env('REVERB_SCHEME', 'http'),
                'useTLS' => env('REVERB_SCHEME', 'http') === 'https',
            ],
        ],
    ],
];

Authorizing Private Channels

Users should only receive their own notifications:

<?php
// routes/channels.php

use Illuminate\Support\Facades\Broadcast;

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

// Authorize presence channels (who's online)
Broadcast::channel('online', function ($user) {
    return [
        'id' => $user->id,
        'name' => $user->name,
        'username' => $user->username,
    ];
});

Security:

  • Callback returns true if user can listen to channel
  • Callback returns false to deny access
  • For presence channels, return user data to share

Setting Up Laravel Echo on Frontend

Configure Echo to connect to Reverb:

// resources/js/echo.ts

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

// Make Pusher available globally for Echo
window.Pusher = Pusher

// Create Echo instance
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'],
    // Include auth headers for private channels
    auth: {
        headers: {
            'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
        },
    },
})

export default window.Echo
// resources/js/app.ts

import { createApp } from 'vue'
import { createInertiaApp } from '@inertiajs/vue3'
import './echo'  // Import Echo configuration

createInertiaApp({
    // ... your Inertia setup
})

Creating a Notifications Composable

Build a reusable composable for notifications:

// resources/js/composables/useNotifications.ts

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

export interface Notification {
    id: string
    type: string
    data: {
        message: string
        requester_id?: number
        requester_name?: string
        requester_username?: 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)

    // Load initial notifications from server
    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)
        }
    }

    // Mark notification as read
    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') || '',
                },
            })

            // Update local state
            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)
        }
    }

    // Mark all as read
    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') || '',
                },
            })

            // Update local state
            notifications.value.forEach(n => {
                n.read_at = new Date().toISOString()
            })
            unreadCount.value = 0
        } catch (error) {
            console.error('Failed to mark all as read:', error)
        }
    }

    // Listen for real-time notifications
    onMounted(() => {
        loadNotifications()

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

        // Listen for notifications
        channel.notification((notification: Notification) => {
            console.log('Received notification:', notification)

            // Add to notifications array
            notifications.value.unshift(notification)
            unreadCount.value++

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

            // Play sound (optional)
            const audio = new Audio('/sounds/notification.mp3')
            audio.play().catch(() => {
                // Ignore if autoplay is blocked
            })
        })

        // Track connection status
        Echo.connector.pusher.connection.bind('connected', () => {
            isConnected.value = true
            console.log('WebSocket connected')
        })

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

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

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

Building the Notification Bell Component

Create a UI component for notifications:

<!-- resources/js/components/NotificationBell.vue -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { usePage } from '@inertiajs/vue3'
import { useNotifications } from '@/composables/useNotifications'
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
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)
    
    // Navigate based on notification type
    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 date = new Date(timestamp)
    const now = new Date()
    const diff = now.getTime() - date.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>

<template>
  <DropdownMenu v-model:open="isOpen">
    <DropdownMenuTrigger as-child>
      <button
        class="relative rounded-full p-2 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
        :class="{ 'animate-pulse': !isConnected }"
      >
        <Bell class="h-6 w-6" />
        
        <!-- Unread badge -->
        <span
          v-if="unreadCount > 0"
          class="absolute right-0 top-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white"
        >
          {{ unreadCount > 9 ? '9+' : unreadCount }}
        </span>

        <!-- Connection indicator -->
        <span
          v-if="!isConnected"
          class="absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white bg-yellow-500"
          title="Reconnecting..."
        />
      </button>
    </DropdownMenuTrigger>

    <DropdownMenuContent align="end" class="w-80">
      <!-- Header -->
      <div class="flex items-center justify-between border-b px-4 py-3">
        <h3 class="font-semibold">Notifications</h3>
        <button
          v-if="unreadCount > 0"
          @click="markAllAsRead"
          class="text-sm text-blue-600 hover:text-blue-700"
        >
          Mark all read
        </button>
      </div>

      <!-- Notifications list -->
      <div class="max-h-96 overflow-y-auto">
        <div
          v-if="notifications.length === 0"
          class="px-4 py-8 text-center text-gray-500"
        >
          No notifications yet
        </div>

        <DropdownMenuItem
          v-for="notification in notifications"
          :key="notification.id"
          @click="handleNotificationClick(notification)"
          class="cursor-pointer px-4 py-3 hover:bg-gray-50"
          :class="{ 'bg-blue-50': !notification.read_at }"
        >
          <div class="flex gap-3">
            <!-- Avatar -->
            <img
              v-if="notification.data.requester_avatar"
              :src="notification.data.requester_avatar"
              :alt="notification.data.requester_name"
              class="h-10 w-10 rounded-full"
            />
            <div
              v-else
              class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-200"
            >
              <Bell class="h-5 w-5 text-gray-500" />
            </div>

            <!-- Content -->
            <div class="flex-1 min-w-0">
              <p class="text-sm" :class="{ 'font-semibold': !notification.read_at }">
                {{ notification.data.message }}
              </p>
              <p class="text-xs text-gray-500">
                {{ formatTime(notification.created_at) }}
              </p>
            </div>

            <!-- Unread indicator -->
            <div
              v-if="!notification.read_at"
              class="h-2 w-2 rounded-full bg-blue-600"
            />
          </div>
        </DropdownMenuItem>
      </div>

      <!-- Footer -->
      <div class="border-t px-4 py-3 text-center">
        <a
          href="/notifications"
          class="text-sm text-blue-600 hover:text-blue-700"
        >
          View all notifications
        </a>
      </div>
    </DropdownMenuContent>
  </DropdownMenu>
</template>

Backend API Endpoints

Create endpoints for notification management:

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

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class NotificationController extends Controller
{
    /**
     * Get user's notifications
     */
    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(),
                'per_page' => $notifications->perPage(),
                'total' => $notifications->total(),
            ],
        ]);
    }

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

        $notification->markAsRead();

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

    /**
     * Mark all notifications as read
     */
    public function markAllAsRead(Request $request)
    {
        Auth::user()->unreadNotifications->markAsRead();

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

    /**
     * Delete notification
     */
    public function destroy(string $id)
    {
        $notification = Auth::user()
            ->notifications()
            ->where('id', $id)
            ->firstOrFail();

        $notification->delete();

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

    /**
     * Get unread count
     */
    public function unreadCount()
    {
        return response()->json([
            'count' => Auth::user()->unreadNotifications()->count(),
        ]);
    }
}
<?php
// routes/api.php

use App\Http\Controllers\NotificationController;

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']);
    Route::delete('/notifications/{id}', [NotificationController::class, 'destroy']);
    Route::get('/notifications/unread-count', [NotificationController::class, 'unreadCount']);
});

Under the Hood

How WebSockets Work

WebSockets provide full-duplex communication:

HTTP Handshake:
Client: GET /socket HTTP/1.1
        Upgrade: websocket
        Connection: Upgrade

Server: HTTP/1.1 101 Switching Protocols
        Upgrade: websocket
        Connection: Upgrade

[Connection upgraded to WebSocket]

Bidirectional Communication:
Client ←→ Server (persistent connection)

Memory implications:

  • Each WebSocket connection: ~10KB RAM
  • 10,000 concurrent users: ~100MB RAM
  • Much more efficient than 10,000 HTTP connections

Laravel Reverb Architecture

Reverb is built on top of ReactPHP:

// Simplified Reverb internals
class ReverbServer
{
    protected $connections = [];

    public function onConnect($connection)
    {
        $this->connections[$connection->id] = $connection;
    }

    public function broadcast($channel, $event, $data)
    {
        foreach ($this->connections as $connection) {
            if ($connection->isSubscribedTo($channel)) {
                $connection->send(json_encode([
                    'event' => $event,
                    'data' => $data,
                ]));
            }
        }
    }
}

Performance characteristics:

  • Event loop: Non-blocking I/O
  • Single-threaded: Uses async operations
  • Scales to 10,000+ connections per server
  • CPU usage: ~5% for 1000 active connections

Broadcasting Queue Flow

When you call notify():

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

// Laravel does:
// 1. Serialize notification
// 2. Push to queue (database/Redis)
// 3. Queue worker picks it up
// 4. Notification sent to database
// 5. Notification broadcast to Reverb
// 6. Reverb pushes to connected clients

Why queue?

  • Non-blocking: User request returns immediately
  • Retry logic: Failed broadcasts are retried
  • Rate limiting: Prevents overwhelming Reverb
  • Scalability: Multiple workers can process notifications

Echo’s Connection Management

Echo handles reconnection automatically:

// Echo internally does:
connection.bind('disconnected', () => {
    setTimeout(() => {
        connection.connect()  // Exponential backoff
    }, retryDelay)
})

// Resubscribes to channels after reconnect
connection.bind('connected', () => {
    channels.forEach(channel => {
        channel.subscribe()
    })
})

Edge Cases & Pitfalls

Memory Leaks with Event Listeners

<!-- ❌ BAD: Not cleaning up Echo listeners -->
<script setup lang="ts">
onMounted(() => {
    Echo.private(`App.Models.User.${userId}`)
        .notification((notification) => {
            // Handle notification
        })
    // Missing cleanup!
})
</script>

Solution:

<script setup lang="ts">
let channel: any

onMounted(() => {
    channel = Echo.private(`App.Models.User.${userId}`)
        .notification((notification) => {
            // Handle notification
        })
})

onUnmounted(() => {
    if (channel) {
        Echo.leave(`App.Models.User.${userId}`)
    }
})
</script>

Notification Spam

// ❌ BAD: Sending notification in a loop
foreach ($users as $user) {
    $user->notify(new SomeNotification());  // 1000 notifications!
}

Solution: Batch notifications or use queues:

// ✅ GOOD: Queue notifications
foreach ($users as $user) {
    $user->notify((new SomeNotification())->delay(now()->addSeconds($index)));
}

// Or use Notification facade for bulk
Notification::send($users, new SomeNotification());

Stale Connections

// Problem: User closes laptop, connection dies
// Solution: Ping/pong to detect dead connections

// Reverb automatically sends pings
// Echo automatically responds with pongs
// Dead connections are cleaned up after timeout

Private vs Presence Channels

Private Channels:

  • One-to-one communication
  • User receives only their notifications
  • Authorization required

Presence Channels:

  • Many-to-many communication
  • See who else is online
  • Share user data with channel members
// Presence channel example
Broadcast::channel('chat.{roomId}', function ($user, $roomId) {
    return [
        'id' => $user->id,
        'name' => $user->name,
        'avatar' => $user->profile_picture,
    ];
});
// Frontend
Echo.join('chat.1')
    .here((users) => {
        console.log('Currently online:', users)
    })
    .joining((user) => {
        console.log(user.name + ' joined')
    })
    .leaving((user) => {
        console.log(user.name + ' left')
    })

Channel Authorization

// ❌ DANGEROUS: No authorization
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
    return true;  // Anyone can listen to any user's channel!
});

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

Testing Real-Time Features

use Tests\TestCase;
use App\Models\User;
use App\Notifications\FriendRequestNotification;
use Illuminate\Support\Facades\Notification;

class NotificationTest extends TestCase
{
    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;
            }
        );
    }

    public function test_notification_is_broadcast()
    {
        Event::fake([NotificationSent::class]);

        $user = User::factory()->create();
        $user->notify(new FriendRequestNotification($user));

        Event::assertDispatched(NotificationSent::class);
    }
}

Conclusion

What You’ve Learned

You now understand how to build production-grade real-time notifications that:

  1. Use WebSockets for instant, bidirectional communication
  2. Leverage Laravel Reverb for scalable WebSocket server
  3. Implement private channels with proper authorization
  4. Persist notifications in database for reliability
  5. Handle reconnections gracefully with Echo
  6. Build reactive UIs with Vue composables

The Key Insights

Real-time features are about user experience. The technical implementation (WebSockets, broadcasting) is just a means to an end - making users feel connected and informed instantly.

Reliability matters more than speed. A notification that arrives in 100ms but gets lost is worse than one that arrives in 500ms reliably. Always persist notifications in the database.

Connection management is critical. Handle disconnections, reconnections, and network failures gracefully. Users shouldn’t notice when their connection drops and reconnects.

Performance Impact

Before WebSockets (polling every 5s):

  • 1000 users × 12 req/min = 12,000 req/min
  • Database queries: 12,000/min
  • Average latency: 2.5 seconds

After WebSockets:

  • 1000 persistent connections
  • Database queries: Only when notifications sent
  • Average latency: < 100ms
  • 25x faster, 99% fewer requests

Next Steps

  • Add presence channels: Show who’s online
  • Implement typing indicators: Real-time “user is typing…”
  • Add read receipts: Show when messages are read
  • Build live chat: Real-time messaging system
  • Add push notifications: Browser and mobile push

Real-World Applications

This pattern is used by:

  • Facebook: Real-time notifications and chat
  • Twitter: Live tweet updates and notifications
  • Slack: Real-time messaging and presence
  • Discord: Voice, video, and text chat

You’ve just learned how billion-user platforms handle real-time communication. 🎉

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!