Building Real-time Features with Laravel Reverb

· 5 min read
laravel php websockets reverb

Prerequisites

  • Laravel 11+
  • Node.js for the frontend bits
  • A basic understanding of Laravel events
  • At least one mass-produced energy drink within arm’s reach
  • The emotional resilience of someone who has debugged CORS errors at 2am

What We’re Building

Right then. We’re adding real-time features to a Laravel app using Reverb, Laravel’s official WebSocket server. Live notifications, chat, presence channels, the whole lot. No third-party services, no monthly invoices from Pusher that make your eye twitch, no sending your data to someone else’s servers and hoping for the best.

If you’ve ever bolted Pusher onto a Laravel app and thought “this is fine but why am I paying for something my server could do itself,” this is your moment.

Thanos saying fine I’ll do it myself

The Approach

  1. Install and configure Reverb
  2. Set up broadcasting
  3. Create event classes
  4. Build frontend listeners
  5. Add presence channels

Nothing too wild. Lets get into it.

Step 1: Install Reverb

composer require laravel/reverb
php artisan reverb:install

This creates config/reverb.php, updates your .env with Reverb variables, and adds broadcasting configuration. One command and you’re most of the way there. Laravel really does spoil us sometimes.

Update your .env:

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-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}"

Step 2: Start the Server

php artisan reverb:start

If you want to see what’s actually happening under the hood (and you absolutely should the first time), pass the debug flag:

php artisan reverb:start --debug

You’ll get a lovely stream of connection events scrolling past. Very satisfying. Like watching logs on a fireplace, but nerdier.

Step 3: Create a Broadcast Event

This is where the magic starts. We create an event that implements ShouldBroadcast and Laravel handles the rest.

// app/Events/MessageSent.php
namespace App\Events;

use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

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

    public function __construct(public Message $message) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('chat.'.$this->message->conversation_id),
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'id' => $this->message->id,
            'content' => $this->message->content,
            'user' => [
                'id' => $this->message->user->id,
                'name' => $this->message->user->name,
            ],
            'created_at' => $this->message->created_at->toIso8601String(),
        ];
    }
}

The broadcastWith method lets you control exactly what data gets sent over the wire. No accidentally leaking user emails or password hashes to the frontend. You’re welcome, future you.

Now dispatch it from your controller:

// In your controller
public function store(Request $request, Conversation $conversation)
{
    $message = $conversation->messages()->create([
        'user_id' => auth()->id(),
        'content' => $request->content,
    ]);

    broadcast(new MessageSent($message))->toOthers();

    return response()->json($message);
}

That ->toOthers() is doing important work. It means the person who sent the message doesnt get their own message echoed back at them. Small detail, massive difference in user experience.

Step 4: Configure Channel Authorisation

Private channels need authorisation. Otherwise, what’s the point of them being private?

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

Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
    return Conversation::find($conversationId)?->users->contains($user);
});

Clean and simple. If the user is part of the conversation, they get access. If not, they can jog on.

Homer disappears into bushes

Step 5: Frontend Setup

Install Echo and the Pusher JS library (Reverb uses the Pusher protocol, which is actually quite clever):

npm install laravel-echo pusher-js

Configure Echo:

// resources/js/echo.js
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 ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Yes, you’re importing pusher-js even though you’re not using Pusher. Its just the protocol. Try not to think about it too hard.

Now listen for events:

// resources/js/chat.js
Echo.private(`chat.${conversationId}`)
    .listen('MessageSent', (e) => {
        console.log('New message:', e);
        messages.value.push(e);
    });

And just like that, you’ve got real-time messaging. Messages appear the instant they’re sent. No polling, no refreshing, no “have you tried turning it off and on again.”

Step 6: Presence Channels

This is the fun bit. Presence channels let you track who’s online and what they’re doing. Perfect for “user is typing” indicators that make your chat feel properly alive.

// app/Events/UserTyping.php
class UserTyping implements ShouldBroadcast
{
    public function __construct(public User $user, public Conversation $conversation) {}

    public function broadcastOn(): array
    {
        return [
            new PresenceChannel('chat.'.$this->conversation->id),
        ];
    }
}

Update the channel authorisation to return user data:

// routes/channels.php
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
    if (Conversation::find($conversationId)?->users->contains($user)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

And on the frontend, Echo.join gives you the full presence experience:

Echo.join(`chat.${conversationId}`)
    .here((users) => {
        onlineUsers.value = users;
    })
    .joining((user) => {
        onlineUsers.value.push(user);
    })
    .leaving((user) => {
        onlineUsers.value = onlineUsers.value.filter(u => u.id !== user.id);
    })
    .listen('UserTyping', (e) => {
        showTypingIndicator(e.user);
    });

.here() fires when you first join and gives you everyone already in the channel. .joining() and .leaving() do exactly what you’d expect. It’s beautifully intuitive.

Step 7: Notifications

Real-time notifications for authenticated users. Because nobody wants to refresh their page to find out someone followed them.

// app/Notifications/NewFollower.php
class NewFollower extends Notification implements ShouldBroadcast
{
    public function via($notifiable): array
    {
        return ['database', 'broadcast'];
    }

    public function toBroadcast($notifiable): BroadcastMessage
    {
        return new BroadcastMessage([
            'message' => "{$this->follower->name} started following you",
            'follower' => $this->follower->only('id', 'name', 'avatar'),
        ]);
    }
}

Listen on the user’s private notification channel:

Echo.private(`App.Models.User.${userId}`)
    .notification((notification) => {
        showToast(notification.message);
    });

That’s it. Real-time notifications with about six lines of frontend code. Lovely stuff.

Monica saying I know

Step 8: Production Deployment

For production, you’ll want Supervisor keeping Reverb alive. Because WebSocket servers that crash at 3am and dont restart themselves are not the vibe.

[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/log/reverb.log

And stick Nginx in front of it to handle the WebSocket upgrade:

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;
}

The Result

What you end up with:

  • Real-time messaging that actually feels instant
  • Presence detection so users know who’s online
  • Live notifications without polling
  • Zero third-party dependencies for WebSockets
  • Full control over your infrastructure

Frankenstein saying it’s alive

What I’d Do Differently

Add connection error handling and reconnection logic on the frontend from the start. WebSocket connections drop. They just do. Users shouldnt have to manually refresh the page to recover. A bit of exponential backoff and a “reconnecting…” toast goes a long way toward making the experience feel solid rather than fragile.

Reverb makes real-time Laravel feel native. No more wrestling with Pusher pricing or complex Redis configurations. Just WebSockets that work, running on your own kit.

Related Posts

Comments