Skip to main content
Back to Blog
deep-diveJanuary 8, 202518 min min read

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

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

SolutionTypeCostComplexity
PusherManaged SaaS$49+/monthLowest
SoketiSelf-hostedServer costsMedium
ReverbSelf-hosted (Laravel)Server costsMedium

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):

MetricPusherSoketiReverb
Message latency (p50)45ms12ms18ms
Message latency (p99)120ms35ms52ms
Memory usageN/A180MB250MB
Messages/second10,000+15,000+8,000+
Connection time80ms15ms20ms

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:

  1. Message routing - Events must reach all servers with subscribed clients
  2. Session affinity - Reconnections should hit the same server when possible
  3. 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:

  1. Update .env with new driver settings
  2. Update Echo configuration
  3. 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.

#Laravel #WebSockets #Pusher #Reverb #Real-time
Share: