On this page
- Purpose
- The Problem
- The Solution
- What You’ll Learn
- Prerequisites & Tooling
- Knowledge Base
- Environment
- Setup Commands
- Environment Configuration
- High-Level Architecture
- The Real-Time Flow
- WebSocket vs HTTP Comparison
- Mail Delivery
- The Implementation
- Understanding the Naive Polling Approach
- Creating the Notification Event Class
- Dispatching Notifications
- Configuring Broadcasting
- Authorizing Private Channels
- Setting Up Laravel Echo on Frontend
- Creating a Notifications Composable
- Building the Notification Bell Component
- Backend API Endpoints
- Under the Hood
- How WebSockets Work
- Laravel Reverb Architecture
- Broadcasting Queue Flow
- Echo’s Connection Management
- Edge Cases & Pitfalls
- Memory Leaks with Event Listeners
- Notification Spam
- Stale Connections
- Private vs Presence Channels
- Channel Authorization
- Testing Real-Time Features
- Conclusion
- What You’ve Learned
- The Key Insights
- Performance Impact
- Next Steps
- Real-World Applications
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 databasetoBroadcast(): Data sent via WebSocketShouldQueue: 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:
- Friendship created in database
notify()triggers notification- Notification stored in
notificationstable - Notification broadcast via Reverb
- Frontend receives via WebSocket
- 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
trueif user can listen to channel - Callback returns
falseto 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:
- Use WebSockets for instant, bidirectional communication
- Leverage Laravel Reverb for scalable WebSocket server
- Implement private channels with proper authorization
- Persist notifications in database for reliability
- Handle reconnections gracefully with Echo
- 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. 🎉