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.
| Solution | Cost | Self-Hosted | Official | Performance |
|---|---|---|---|---|
| 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 |
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:
Install & Configure 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:
# 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.
php artisan make:event MessageSent
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:
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
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); }
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).
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')}` } } })
<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:
[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
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:
# 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;
}
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.
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.