What is Laravel Reverb?

Before Reverb, building real-time features in Laravel meant relying on third-party services like Pusher or self-hosted laravel-websockets. Both have limitations — Pusher has connection limits and costs money, laravel-websockets is community-maintained and slow to get official support.

Laravel Reverb, released in March 2024 with Laravel 11, is the first official first-party WebSocket server from the Laravel team. It's fast (written in pure PHP using React PHP under the hood), free, and integrates natively with Laravel Broadcasting — no configuration gymnastics required.

SolutionCostSelf-HostedOfficialPerformance
Laravel Reverb✓ Free✓ Yes✓ Official✓ Excellent
Pusher~ Paid (>200 conn)✗ No~ 3rd party✓ Good
laravel-websockets✓ Free✓ Yes✗ Community~ Slower
Soketi✓ Free✓ Yes✗ Community~ Good
📦 Stack Used

Laravel 11, PHP 8.3, Laravel Reverb 1.x, Vue 3 Composition API, Laravel Echo, Pusher JS (client), MySQL. Tested on OCI Ubuntu 22 production server.

How It Works — Event Flow

Understanding the data flow before writing code makes everything click. Here's what happens when a user sends a chat message:

Vue 3
User sends msg
Laravel API
POST /messages
Event fired
MessageSent::class
Vue 3
Echo receives msg
Reverb WS
Broadcasts to channel
Queue Worker
Processes broadcast

Install & Configure Reverb

terminal — install Reverb
# Install Reverb via artisan (handles everything)
php artisan install:broadcasting

# This command:
# ✓ Installs laravel/reverb via composer
# ✓ Publishes config/reverb.php
# ✓ Adds REVERB_* env vars to .env
# ✓ Installs laravel-echo + pusher-js via npm

# Start Reverb server (dev)
php artisan reverb:start

# With verbose output for debugging
php artisan reverb:start --debug

After install, your .env will have these vars pre-filled:

.env — Reverb config
# Broadcasting driver
BROADCAST_CONNECTION=reverb

# Reverb server config
REVERB_APP_ID=my-app
REVERB_APP_KEY=your-key-here
REVERB_APP_SECRET=your-secret-here
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http   # https on production

# Vite / frontend vars (exposed to JS)
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Create the Broadcast Event

Every real-time message is broadcast via a Laravel Event. The event implements ShouldBroadcast and defines which channel to broadcast on.

terminal
php artisan make:event MessageSent
app/Events/MessageSent.php
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;

class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly Message $message,
        public readonly User    $sender,
    ) {}

    /**
     * Broadcast on a private channel per conversation.
     * chat.{id} — only members of that conversation can subscribe.
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('chat.'.$this->message->conversation_id),
        ];
    }

    // Custom event name on the frontend
    public function broadcastAs(): string
    {
        return 'message.sent';
    }

    // Control exactly what data is sent over the WebSocket
    public function broadcastWith(): array
    {
        return [
            'id'         => $this->message->id,
            'body'       => $this->message->body,
            'created_at' => $this->message->created_at->toISOString(),
            'sender'     => [
                'id'     => $this->sender->id,
                'name'   => $this->sender->name,
                'avatar' => $this->sender->avatar_url,
            ],
        ];
    }
}

Authorize Private Channels

Private channels require the user to be authorized before they can subscribe. Define the authorization logic in channels.php:

routes/channels.php
use App\Models\Conversation;

// Private chat channel — user must be a participant
Broadcast::channel('chat.{conversationId}', function (User $user, int $conversationId) {
    return Conversation::find($conversationId)
        ?->participants()->where('user_id', $user->id)->exists();
});

// Presence channel — shows who's currently online in a room
Broadcast::channel('presence.chat.{conversationId}', function (User $user, int $id) {
    if (Conversation::find($id)?->participants()->where('user_id', $user->id)->exists()) {
        return ['id' => $user->id, 'name' => $user->name]; // returned data = member info
    }
});

Message Controller

app/Http/Controllers/MessageController.php
public function store(SendMessageRequest $request, Conversation $conversation): JsonResponse
{
    // Authorize — is this user a participant?
    $this->authorize('send', $conversation);

    // Persist message to DB
    $message = $conversation->messages()->create([
        'user_id' => Auth::id(),
        'body'    => $request->validated('body'),
    ]);

    // 🔥 Broadcast to all channel subscribers via Reverb
    MessageSent::dispatch($message->load('user'), Auth::user());

    return $this->success(new MessageResource($message), 'Message sent', 201);
}
⚡ Use Queued Broadcasting

For production, implement ShouldBroadcastNow only for urgent events. Use ShouldBroadcast (queued) for most events — it prevents your HTTP response from waiting on WebSocket delivery. Run php artisan queue:work alongside Reverb.

Vue 3 Frontend with Laravel Echo

On the frontend, Laravel Echo provides a clean API to subscribe to channels and listen for events. It wraps the Pusher.js client (which Reverb is compatible with).

resources/js/echo.js — bootstrap Echo
import Echo   from 'laravel-echo'
import Pusher from 'pusher-js'

window.Pusher = Pusher

window.Echo = new Echo({
    broadcaster:   'reverb',
    key:           import.meta.env.VITE_REVERB_APP_KEY,
    wsHost:        import.meta.env.VITE_REVERB_HOST,
    wsPort:        import.meta.env.VITE_REVERB_PORT ?? 8080,
    wssPort:       import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS:      (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    authEndpoint:  '/broadcasting/auth',    // Laravel handles this
    auth: {
        headers: {
            // Pass Sanctum token for channel auth
            Authorization: `Bearer ${localStorage.getItem('token')}`
        }
    }
})
components/ChatRoom.vue
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from 'vue'

const props = defineProps<{ conversationId: number }>()

const messages = ref([])
const newMsg   = ref('')
const isTyping = ref(false)
const msgEnd   = ref(null)       // scroll anchor ref
let typingTimer: number

onMounted(async () => {
    // Load message history
    const { data } = await api.get(`/conversations/${props.conversationId}/messages`)
    messages.value = data.data

    // 🔥 Subscribe to private channel via Echo
    window.Echo
        .private(`chat.${props.conversationId}`)

        // Listen for new messages
        .listen('.message.sent', (e) => {
            messages.value.push(e)
            nextTick(() => msgEnd.value?.scrollIntoView({ behavior: 'smooth' }))
        })

        // Listen for typing indicators
        .listenForWhisper('typing', (e) => {
            isTyping.value = true
            setTimeout(() => isTyping.value = false, 2000)
        })
})

onUnmounted(() => {
    // ✓ Always leave channel on unmount — prevents memory leaks
    window.Echo.leave(`chat.${props.conversationId}`)
})

async function sendMessage() {
    if (!newMsg.value.trim()) return
    await api.post(`/conversations/${props.conversationId}/messages`, {
        body: newMsg.value
    })
    newMsg.value = ''
}

function onTyping() {
    clearTimeout(typingTimer)
    // Send whisper (client-to-client, not saved to DB)
    window.Echo.private(`chat.${props.conversationId}`)
        .whisper('typing', { user: authUser.value?.name })
}
</script>

<template>
  <div class="chat-room">
    <div class="messages">
      <ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
      <TypingIndicator v-if="isTyping" />
      <div ref="msgEnd" />   <!-- scroll anchor -->
    </div>
    <div class="input-row">
      <input v-model="newMsg" @input="onTyping"
             @keyup.enter="sendMessage" placeholder="Type a message…" />
      <button @click="sendMessage">Send</button>
    </div>
  </div>
</template>

Production — Supervisor + SSL

In production, Reverb needs to run persistently as a background service. Use Supervisor to manage both the Reverb WebSocket server and the queue worker:

/etc/supervisor/conf.d/reverb.conf
[program:reverb]
process_name=%(program_name)s
command=php /var/www/laravel/artisan reverb:start --host="0.0.0.0" --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
stopwaitsecs=3600

[program:laravel-queue]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/laravel/artisan queue:work --sleep=3 --tries=3 --timeout=90
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/log/laravel-queue.log
bash — start supervisor
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start reverb
sudo supervisorctl start laravel-queue:*
sudo supervisorctl status   # verify both are RUNNING

Nginx WebSocket Proxy

Nginx must proxy WebSocket upgrade requests to Reverb on port 8080. Add this to your server block:

/etc/nginx/sites-available/laravel — add to server block
# WebSocket proxy for Laravel Reverb
location /app {
    proxy_pass             http://127.0.0.1:8080;
    proxy_http_version     1.1;
    proxy_set_header       Upgrade    $http_upgrade;
    proxy_set_header       Connection "Upgrade";
    proxy_set_header       Host       $host;
    proxy_set_header       X-Real-IP  $remote_addr;
    proxy_read_timeout     60s;
    proxy_connect_timeout  60s;
    proxy_send_timeout     60s;
}
✅ Update .env for Production

On the server: set REVERB_SCHEME=https, REVERB_HOST=yourdomain.com, REVERB_PORT=443. The Nginx proxy handles SSL termination — Reverb itself runs on HTTP internally on port 8080.

🏁 Key Takeaways

Reverb is now the default choice for real-time in any new Laravel 11 project. It's the only solution that's officially maintained, free, self-hosted, and trivially set up with php artisan install:broadcasting.

Private + Presence channels are the foundation of any real chat product — use private channels for message delivery, presence channels to show online status. The authorization logic in channels.php keeps your rooms secure.

Whispers (client-to-client messages) are a hidden gem — they let you send typing indicators, read receipts, and cursor positions without touching your database at all. Pure WebSocket magic.

👨‍💻
Sonu Sahani
Full Stack Developer · CodeCraft Systems

6+ years building production Laravel apps with real-time features for UK, USA & global clients. Currently at Lifelancer (UK) — a job portal with live notifications, chat, and WebSocket-driven activity feeds built on Reverb.