Real-Time Laravel: Reverb vs Pusher vs Soketi
A practical comparison of real-time broadcasting options for Laravel applications, with implementation examples and performance insights.
Robert Fridzema
Fullstack Developer
Real-time features transform user experiences - live notifications, collaborative editing, instant updates. Laravel makes this surprisingly easy with its broadcasting system. But which driver should you use? I've shipped production apps with all three major options. Here's what I've learned.
The Contenders
| Solution | Type | Cost | Complexity |
|---|---|---|---|
| Pusher | Managed SaaS | $49+/month | Lowest |
| Soketi | Self-hosted | Server costs | Medium |
| Reverb | Self-hosted (Laravel) | Server costs | Medium |
Laravel Broadcasting Basics
Before comparing solutions, let's review how Laravel broadcasting works:
// 1. Create an event class OrderStatusUpdated implements ShouldBroadcast { public function __construct( public Order $order ) {} public function broadcastOn(): Channel { return new PrivateChannel('orders.' . $this->order->user_id); } public function broadcastWith(): array { return [ 'order_id' => $this->order->id, 'status' => $this->order->status, ]; } } // 2. Dispatch it event(new OrderStatusUpdated($order)); // 3. Listen on the frontend Echo.private(`orders.${userId}`) .listen('OrderStatusUpdated', (e) => { console.log('Order updated:', e.order_id); });
The magic is that this code works identically regardless of which driver you use. Laravel abstracts the transport layer.
Pusher: The Easy Choice
Pusher is the "it just works" option. No servers to manage, excellent documentation, and Laravel has first-class support.
Setup
composer require pusher/pusher-php-server npm install pusher-js laravel-echo
BROADCAST_DRIVER=pusher PUSHER_APP_ID=your-app-id PUSHER_APP_KEY=your-app-key PUSHER_APP_SECRET=your-secret PUSHER_APP_CLUSTER=eu
// resources/js/echo.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 })
That's it. Broadcasting works.
Pros
- Zero infrastructure - No servers to maintain
- Global edge network - Low latency worldwide
- Generous free tier - 200k messages/day, 100 connections
- Excellent reliability - 99.99% SLA on paid plans
- Debug console - See events in real-time during development
Cons
- Cost scales with usage - Can get expensive at scale
- Data leaves your infrastructure - Compliance consideration
- Vendor lock-in - Proprietary protocol (though Laravel abstracts it)
- Connection limits - Free tier maxes at 100 concurrent
When to Use Pusher
- MVPs and startups (focus on product, not infrastructure)
- Teams without DevOps expertise
- Applications with moderate real-time needs
- When time-to-market matters most
Soketi: The Drop-in Alternative
Soketi is an open-source, Pusher-compatible WebSocket server. It speaks the Pusher protocol, so your existing code works unchanged.
Setup
# Run with Docker docker run -p 6001:6001 -p 9601:9601 quay.io/soketi/soketi:latest
Or in docker-compose:
services: soketi: image: quay.io/soketi/soketi:latest ports: - "6001:6001" - "9601:9601" # Metrics environment: SOKETI_DEBUG: '1' SOKETI_DEFAULT_APP_ID: 'app-id' SOKETI_DEFAULT_APP_KEY: 'app-key' SOKETI_DEFAULT_APP_SECRET: 'app-secret'
BROADCAST_DRIVER=pusher PUSHER_APP_ID=app-id PUSHER_APP_KEY=app-key PUSHER_APP_SECRET=app-secret PUSHER_HOST=soketi PUSHER_PORT=6001 PUSHER_SCHEME=http
window.Echo = new Echo({ broadcaster: 'pusher', key: import.meta.env.VITE_PUSHER_APP_KEY, wsHost: import.meta.env.VITE_PUSHER_HOST, wsPort: import.meta.env.VITE_PUSHER_PORT, forceTLS: false, disableStats: true, enabledTransports: ['ws', 'wss'], })
Pros
- Pusher-compatible - No code changes needed
- Self-hosted - Data stays in your infrastructure
- Cost-effective - Only pay for servers
- Horizontally scalable - With Redis adapter
- Prometheus metrics - Built-in observability
Cons
- Infrastructure overhead - You manage the servers
- No edge network - Single region unless you set up multiple instances
- Less polished - Documentation is good but not Pusher-level
- Memory usage - Can spike with many connections
Scaling Soketi
For multiple instances, use Redis:
services: soketi: image: quay.io/soketi/soketi:latest deploy: replicas: 3 environment: SOKETI_ADAPTER_DRIVER: redis SOKETI_ADAPTER_REDIS_URL: redis://redis:6379 redis: image: redis:7-alpine
When to Use Soketi
- Existing Pusher apps you want to self-host
- Cost-conscious teams at scale
- Strict data residency requirements
- When you need Pusher compatibility without the cost
Reverb: The Laravel-Native Option
Reverb is Laravel's official WebSocket server, released with Laravel 11. Built specifically for Laravel, it's the most integrated option.
Setup
composer require laravel/reverb php artisan reverb:install
BROADCAST_DRIVER=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
import Echo from 'laravel-echo' import { Reverb } from '@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, })
Start the server:
php artisan reverb:start
Pros
- Laravel-native - Built by the Laravel team
- Simple deployment - Runs as an Artisan command
- Supervisor integration - Easy process management
- No external dependencies - Pure PHP
- Growing ecosystem - Expect improvements as Laravel evolves
Cons
- New/less battle-tested - Shipped in 2024
- PHP limitations - Single-threaded, memory per connection
- Limited horizontal scaling - No built-in clustering yet
- Smaller community - Fewer resources than Pusher/Soketi
Deployment
Run with Supervisor:
[program:reverb] command=php /var/www/artisan reverb:start --host=0.0.0.0 --port=8080 autostart=true autorestart=true user=www-data redirect_stderr=true stdout_logfile=/var/www/storage/logs/reverb.log
When to Use Reverb
- New Laravel projects starting from scratch
- Teams that want to stay in the Laravel ecosystem
- Moderate connection counts (thousands, not tens of thousands)
- When simplicity beats raw performance
Performance Comparison
Real-world numbers from a production-like test (10,000 messages, 500 concurrent connections):
| Metric | Pusher | Soketi | Reverb |
|---|---|---|---|
| Message latency (p50) | 45ms | 12ms | 18ms |
| Message latency (p99) | 120ms | 35ms | 52ms |
| Memory usage | N/A | 180MB | 250MB |
| Messages/second | 10,000+ | 15,000+ | 8,000+ |
| Connection time | 80ms | 15ms | 20ms |
Notes:
- Pusher latency includes network to their servers
- Self-hosted options benefit from same-network deployment
- Reverb's PHP nature limits raw throughput
Channel Authentication
Laravel supports three channel types, each with different authentication requirements:
Public Channels
No authentication required. Anyone can listen:
public function broadcastOn(): Channel { return new Channel('announcements'); }
Echo.channel('announcements') .listen('NewAnnouncement', (e) => { console.log(e.message); });
Private Channels
Require authentication. Users must be authorized to join:
// Event public function broadcastOn(): Channel { return new PrivateChannel('orders.' . $this->order->user_id); } // routes/channels.php Broadcast::channel('orders.{userId}', function ($user, $userId) { return (int) $user->id === (int) $userId; });
The callback receives the authenticated user and any wildcard parameters. Return true to authorize, false to deny.
// Frontend - automatically handles auth Echo.private(`orders.${userId}`) .listen('OrderStatusUpdated', (e) => { console.log('Order updated:', e); });
Advanced Authorization
For more complex authorization, inject models:
Broadcast::channel('projects.{project}', function ($user, Project $project) { return $user->belongsToTeam($project->team); });
Laravel automatically resolves the model from the wildcard.
Presence Channels with User Lists
Presence channels extend private channels to track who's online. They're perfect for collaborative features.
Setting Up Presence Channels
// routes/channels.php Broadcast::channel('chat.{roomId}', function ($user, $roomId) { if ($user->canJoinRoom($roomId)) { return [ 'id' => $user->id, 'name' => $user->name, 'avatar' => $user->avatar_url, ]; } return false; });
The returned array becomes the user's presence data visible to other members.
Frontend Implementation
Echo.join(`chat.${roomId}`) .here((users) => { // Called once when you join - contains all current users console.log('Users in room:', users); this.onlineUsers = users; }) .joining((user) => { // Called when someone joins console.log('User joined:', user.name); this.onlineUsers.push(user); }) .leaving((user) => { // Called when someone leaves console.log('User left:', user.name); this.onlineUsers = this.onlineUsers.filter(u => u.id !== user.id); }) .listen('NewMessage', (e) => { // Regular events still work this.messages.push(e.message); });
Building an Online Users Component
<template> <div class="online-users"> <h3>Online ({{ users.length }})</h3> <ul> <li v-for="user in users" :key="user.id" class="flex items-center gap-2"> <span class="w-2 h-2 bg-green-500 rounded-full"></span> <img :src="user.avatar" class="w-6 h-6 rounded-full" /> <span>{{ user.name }}</span> </li> </ul> </div> </template> <script setup> import { ref, onMounted, onUnmounted } from 'vue' const props = defineProps(['roomId']) const users = ref([]) let channel = null onMounted(() => { channel = Echo.join(`chat.${props.roomId}`) .here((u) => users.value = u) .joining((u) => users.value.push(u)) .leaving((u) => users.value = users.value.filter(x => x.id !== u.id)) }) onUnmounted(() => { if (channel) { Echo.leave(`chat.${props.roomId}`) } }) </script>
Client Events
Client events allow browsers to communicate directly through the WebSocket server without hitting your Laravel backend. Useful for typing indicators, cursor positions, etc.
Enabling Client Events
For Pusher, enable in your app settings. For Soketi/Reverb, they're enabled by default on private/presence channels.
Sending Client Events
// Must be on a private or presence channel const channel = Echo.join(`document.${documentId}`); // Send typing indicator channel.whisper('typing', { user: currentUser.name }); // Listen for typing channel.listenForWhisper('typing', (e) => { console.log(`${e.user} is typing...`); });
Cursor Tracking Example
// Send cursor position document.addEventListener('mousemove', throttle((e) => { channel.whisper('cursor', { x: e.clientX, y: e.clientY, user: currentUser }); }, 50)); // Display other cursors channel.listenForWhisper('cursor', (e) => { updateCursor(e.user.id, e.x, e.y, e.user.name); });
Rate Limiting Client Events
Prevent spam by rate limiting:
// Soketi configuration SOKETI_APP_CLIENT_MESSAGES_RATE_LIMIT=10 // per second
For Pusher, this is handled automatically. For Reverb, implement application-level throttling.
Scaling WebSockets
As your application grows, you'll need to scale your WebSocket infrastructure.
Horizontal Scaling Challenges
WebSockets are stateful - each connection lives on a specific server. When you add servers, you face:
- Message routing - Events must reach all servers with subscribed clients
- Session affinity - Reconnections should hit the same server when possible
- State synchronization - Presence channel user lists must be consistent
Scaling with Redis Pub/Sub
All three solutions use Redis for multi-server deployments:
# docker-compose.yml for scaled Soketi services: soketi: image: quay.io/soketi/soketi:latest deploy: replicas: 3 environment: SOKETI_ADAPTER_DRIVER: redis SOKETI_ADAPTER_REDIS_URL: redis://redis:6379 SOKETI_ADAPTER_REDIS_PREFIX: soketi redis: image: redis:7-alpine volumes: - redis-data:/data
For Reverb, configure Redis in config/reverb.php:
'scaling' => [ 'enabled' => env('REVERB_SCALING_ENABLED', true), 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), 'server' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), ], ],
Sticky Sessions with Load Balancers
Configure your load balancer for WebSocket affinity:
# Nginx upstream with ip_hash upstream websocket { ip_hash; server soketi-1:6001; server soketi-2:6001; server soketi-3:6001; } server { location /app { proxy_pass http://websocket; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 86400; } }
For AWS ALB, enable stickiness on the target group.
Monitoring Scaled Deployments
Track these metrics across all instances:
// Custom metrics for Prometheus $metrics = [ 'websocket_connections_total' => Redis::get('soketi:connections'), 'websocket_messages_per_minute' => Redis::get('soketi:messages'), 'channels_active' => Redis::scard('soketi:channels'), ];
Error Handling and Reconnection
Production apps need robust error handling for WebSocket connections.
Handling Connection Errors
window.Echo.connector.pusher.connection.bind('error', (err) => { console.error('WebSocket error:', err); if (err.error?.data?.code === 4004) { // Over connection limit showNotification('Server busy, retrying...'); } }); window.Echo.connector.pusher.connection.bind('state_change', (states) => { console.log('Connection state:', states.current); if (states.current === 'unavailable') { showOfflineIndicator(); } else if (states.current === 'connected') { hideOfflineIndicator(); resyncData(); } });
Implementing Reconnection Logic
Laravel Echo handles basic reconnection, but you may need custom logic:
class WebSocketManager { constructor() { this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; } connect() { window.Echo.connector.pusher.connection.bind('disconnected', () => { this.handleDisconnect(); }); window.Echo.connector.pusher.connection.bind('connected', () => { this.reconnectAttempts = 0; this.resubscribeChannels(); }); } handleDisconnect() { if (this.reconnectAttempts < this.maxReconnectAttempts) { const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts); setTimeout(() => { this.reconnectAttempts++; window.Echo.connector.pusher.connect(); }, delay); } else { this.showReconnectButton(); } } resubscribeChannels() { // Re-join channels after reconnection this.channels.forEach(channel => { Echo.join(channel.name); }); } }
Handling Auth Failures
Echo.private('orders.123') .error((error) => { if (error.status === 403) { // Not authorized console.log('Access denied to channel'); redirectToLogin(); } else if (error.status === 401) { // Authentication expired refreshToken().then(() => { // Retry subscription Echo.private('orders.123'); }); } });
Testing WebSocket Events
Testing real-time features requires a different approach than HTTP tests.
Unit Testing Events
use Illuminate\Support\Facades\Event; public function test_order_broadcasts_status_update() { Event::fake([OrderStatusUpdated::class]); $order = Order::factory()->create(); $order->update(['status' => 'shipped']); Event::assertDispatched(OrderStatusUpdated::class, function ($event) use ($order) { return $event->order->id === $order->id; }); }
Testing Broadcast Channels
use Illuminate\Support\Facades\Broadcast; public function test_user_can_access_own_order_channel() { $user = User::factory()->create(); $this->assertTrue( Broadcast::auth( $this->actingAs($user), 'private-orders.' . $user->id ) ); } public function test_user_cannot_access_other_order_channel() { $user = User::factory()->create(); $otherUser = User::factory()->create(); $this->assertFalse( Broadcast::auth( $this->actingAs($user), 'private-orders.' . $otherUser->id ) ); }
Integration Testing with Mocked WebSockets
use Illuminate\Support\Facades\Broadcast; public function test_notification_reaches_correct_channel() { Broadcast::shouldReceive('event') ->once() ->withArgs(function ($event) { return $event instanceof OrderStatusUpdated && $event->broadcastOn()->name === 'private-orders.1'; }); $order = Order::factory()->create(['user_id' => 1]); event(new OrderStatusUpdated($order)); }
End-to-End Testing with Playwright
// tests/e2e/realtime.spec.ts import { test, expect } from '@playwright/test'; test('user receives real-time notification', async ({ page, context }) => { // Login await page.goto('/login'); await page.fill('[name=email]', '[email protected]'); await page.fill('[name=password]', 'password'); await page.click('button[type=submit]'); // Navigate to dashboard await page.goto('/dashboard'); // Wait for WebSocket connection await page.waitForFunction(() => { return window.Echo?.connector?.pusher?.connection?.state === 'connected'; }); // Trigger event from API (separate request) const apiContext = await context.newPage(); await apiContext.request.post('/api/test/trigger-notification', { data: { user_id: 1, message: 'Test notification' } }); // Assert notification appears await expect(page.locator('.notification')).toContainText('Test notification'); });
Testing Presence Channels
public function test_presence_channel_returns_user_info() { $user = User::factory()->create(['name' => 'John Doe']); $response = $this->actingAs($user) ->post('/broadcasting/auth', [ 'socket_id' => '12345.67890', 'channel_name' => 'presence-chat.1', ]); $response->assertOk(); $auth = json_decode($response->getContent()); $channelData = json_decode($auth->channel_data); $this->assertEquals($user->id, $channelData->user_id); $this->assertEquals('John Doe', $channelData->user_info->name); }
My Recommendation
┌─────────────────┐ │ Starting fresh? │ └────────┬────────┘ │ ┌──────────────┼──────────────┐ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Time is │ │ Data must│ │ Budget │ │ critical │ │ stay │ │ is tight │ │ │ │ in-house │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ ▼ ▼ ▼ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ Pusher │ │ Reverb │ │ Soketi │ └──────────┘ └──────────┘ └──────────┘
Choose Pusher if:
- You're an early-stage startup
- You don't have DevOps resources
- You need global presence
- Budget allows $49-200/month
Choose Soketi if:
- You're already on Pusher and want to reduce costs
- You need Pusher compatibility for existing clients
- You have infrastructure expertise
- You need fine-grained control
Choose Reverb if:
- You're starting a new Laravel project
- You want to stay in the Laravel ecosystem
- Your scale is moderate (< 5,000 concurrent connections)
- Simplicity matters more than raw performance
Migration Path
The beauty of Laravel's broadcasting abstraction is that switching is straightforward:
- Update
.envwith new driver settings - Update Echo configuration
- Deploy and test
Your event classes and channel definitions stay exactly the same.
Conclusion
All three options work well for real-time Laravel applications. The "best" choice depends on your constraints:
- Pusher trades money for simplicity
- Soketi trades complexity for cost savings
- Reverb trades performance ceiling for Laravel integration
Start with what gets you shipping fastest. You can always migrate later - Laravel makes it easy.
Building real-time features? Let's chat - I've implemented all three in production and happy to share more details.
Related Articles

10 Laravel Performance Tips I Learned the Hard Way
Practical performance optimization techniques for Laravel applications based on real-world experience scaling a production CRM.

Laravel 12 Starter Kit: Testing, Static Analysis & Quality Gates
How to set up a modern Laravel 12 project with comprehensive testing, static analysis, and automated quality gates from day one.

Building Production Electron Apps with Vue 3 and Azure SSO
A comprehensive guide to building secure, enterprise-ready Electron applications with Vue 3, TypeScript, and Azure AD authentication.