Stu Mason
Stu Mason
Guide

Real-Time Chat with Laravel Reverb: What I Learned Building Two Platforms

Stuart Mason8 min read

I've built real-time chat on two separate marketplace platforms using Laravel Reverb. The first was for a service marketplace (think provider-customer messaging). The second was for a restaurant booki

Real-Time Chat with Laravel Reverb: What I Learned Building Two Platforms

I've built real-time chat on two separate marketplace platforms using Laravel Reverb. The first was for a service marketplace (think provider-customer messaging). The second was for a restaurant booking platform (guest-venue communication). Same technology, different contexts, same set of lessons learned.

Reverb is good. It's not perfect. Here's what I actually learned.

Why Reverb

Before Reverb, your options for WebSockets in Laravel were Pusher (paid, third-party) or Laravel Websockets (community package, maintenance concerns). Reverb is first-party, self-hosted, and free. It runs as a separate process alongside your Laravel app.

For marketplaces where chat is a core feature (not a nice-to-have), self-hosted WebSockets make sense. You don't want your chat feature to go down because a third-party service has an outage. And you don't want to pay per-message fees when you're processing thousands of messages daily.

Setting Up for Production

Install Reverb:

php artisan install:broadcasting

This sets up Reverb, configures broadcasting, and installs Laravel Echo on the frontend. The config lives in config/reverb.php.

For production, the key configuration:

REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST="0.0.0.0"
REVERB_PORT=8080
REVERB_SCHEME=https

I run Reverb behind a reverse proxy (Caddy in my case, via Coolify). The proxy handles SSL termination, so Reverb itself listens on HTTP internally but clients connect via WSS.

Supervisor config to keep Reverb running:

[program:reverb]
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/html/storage/logs/reverb.log
stopwaitsecs=3600

The Chat Model

Keep the data model simple. A conversation has participants and messages:

// app/Models/Conversation.php
final class Conversation extends Model
{
    public function participants(): BelongsToMany
    {
        return $this->belongsToMany(User::class, 'conversation_participants')
            ->withPivot('last_read_at')
            ->withTimestamps();
    }

    public function messages(): HasMany
    {
        return $this->hasMany(Message::class)->latest();
    }

    public function latestMessage(): HasOne
    {
        return $this->hasOne(Message::class)->latest();
    }

    public function isParticipant(User $user): bool
    {
        return $this->participants()->where('user_id', $user->id)->exists();
    }
}
// app/Models/Message.php
final class Message extends Model
{
    protected function casts(): array
    {
        return [
            'read_at' => 'datetime',
        ];
    }

    public function conversation(): BelongsTo
    {
        return $this->belongsTo(Conversation::class);
    }

    public function sender(): BelongsTo
    {
        return $this->belongsTo(User::class, 'sender_id');
    }
}

Channel Authorization

This is where security matters. Every conversation gets a private channel. Only participants can subscribe:

// routes/channels.php
Broadcast::channel(
    'conversation.{conversationId}',
    function (User $user, int $conversationId): bool {
        $conversation = Conversation::find($conversationId);

        if (! $conversation) {
            return false;
        }

        return $conversation->isParticipant($user);
    },
);

This runs on every connection attempt. If a user isn't a participant, they can't listen to the channel. Simple, but absolutely critical. Without proper channel authorization, users could listen to other people's conversations.

Broadcasting Messages

When a message is sent, broadcast it to the conversation channel:

// app/Events/MessageSent.php
final class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

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

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

    public function broadcastWith(): array
    {
        return [
            'id' => $this->message->id,
            'conversation_id' => $this->message->conversation_id,
            'sender' => [
                'id' => $this->message->sender->id,
                'name' => $this->message->sender->name,
                'avatar_url' => $this->message->sender->avatar_url,
            ],
            'body' => $this->message->body,
            'sent_at' => $this->message->created_at->toISOString(),
        ];
    }

    public function broadcastAs(): string
    {
        return 'message.sent';
    }
}

The Action that sends a message:

// app/Actions/Chat/SendMessage.php
final class SendMessage
{
    public function execute(
        Conversation $conversation,
        User $sender,
        string $body,
    ): Message {
        $message = $conversation->messages()->create([
            'sender_id' => $sender->id,
            'body' => $body,
        ]);

        $message->load('sender');

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

        // Update unread counts for other participants
        $conversation->participants()
            ->where('user_id', '!=', $sender->id)
            ->each(function (User $participant) use ($conversation) {
                $participant->notify(
                    new NewMessageNotification($conversation),
                );
            });

        return $message;
    }
}

Note ->toOthers(). This prevents the sender from receiving their own message via the WebSocket. They already have it — they sent it. Without this, you get duplicate messages in the UI.

Presence Channels for Online Status

Presence channels tell you who's currently in a conversation. This is how you show "John is online" or the green dot next to someone's avatar:

// routes/channels.php
Broadcast::channel(
    'conversation.{conversationId}.presence',
    function (User $user, int $conversationId): ?array {
        $conversation = Conversation::find($conversationId);

        if (! $conversation || ! $conversation->isParticipant($user)) {
            return null;
        }

        return [
            'id' => $user->id,
            'name' => $user->name,
            'avatar_url' => $user->avatar_url,
        ];
    },
);

Returning an array means the user is authorized and the array data is their presence information. Returning null denies access.

Frontend: React + Laravel Echo

On the frontend, Laravel Echo handles the WebSocket connection. Here's the React hook I use for chat:

// resources/js/hooks/use-chat.ts
import { useEffect, useRef, useState, useCallback } from 'react';
import type { MessageData } from '@/types/generated';

export function useChat(conversationId: number) {
    const [messages, setMessages] = useState<MessageData[]>([]);
    const [typingUsers, setTypingUsers] = useState<string[]>([]);
    const channelRef = useRef<any>(null);

    useEffect(() => {
        const channel = window.Echo.private(`conversation.${conversationId}`);
        channelRef.current = channel;

        channel.listen('.message.sent', (event: { id: number; sender: { id: number; name: string; avatar_url: string | null }; body: string; sent_at: string }) => {
            setMessages((prev) => [
                ...prev,
                {
                    id: event.id,
                    sender: event.sender,
                    body: event.body,
                    sentAt: event.sent_at,
                },
            ]);
        });

        channel.listenForWhisper('typing', (event: { name: string }) => {
            setTypingUsers((prev) => {
                if (prev.includes(event.name)) {
                    return prev;
                }
                return [...prev, event.name];
            });

            // Clear typing indicator after 3 seconds
            setTimeout(() => {
                setTypingUsers((prev) =>
                    prev.filter((name) => name !== event.name),
                );
            }, 3000);
        });

        return () => {
            channel.stopListening('.message.sent');
            window.Echo.leave(`conversation.${conversationId}`);
        };
    }, [conversationId]);

    const sendTypingIndicator = useCallback(() => {
        channelRef.current?.whisper('typing', {
            name: 'You', // Replace with actual user name
        });
    }, []);

    return { messages, typingUsers, sendTypingIndicator };
}

Typing Indicators

Typing indicators use Echo's "whisper" feature — client-side events that don't hit your server. They go directly through the WebSocket:

// In the chat input component
function ChatInput({ onSend, onTyping }: ChatInputProps) {
    const [value, setValue] = useState('');
    const typingTimeout = useRef<NodeJS.Timeout>();

    function handleChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
        setValue(e.target.value);

        // Throttle typing events
        if (!typingTimeout.current) {
            onTyping();
            typingTimeout.current = setTimeout(() => {
                typingTimeout.current = undefined;
            }, 2000);
        }
    }

    function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        if (value.trim()) {
            onSend(value.trim());
            setValue('');
        }
    }

    return (
        <form onSubmit={handleSubmit} className="flex gap-2 p-4 border-t">
            <textarea
                value={value}
                onChange={handleChange}
                placeholder="Type a message..."
                className="grow resize-none rounded-lg border p-2"
                rows={1}
                onKeyDown={(e) => {
                    if (e.key === 'Enter' && !e.shiftKey) {
                        e.preventDefault();
                        handleSubmit(e);
                    }
                }}
            />
            <button type="submit" className="rounded-lg bg-blue-600 px-4 py-2 text-white">
                Send
            </button>
        </form>
    );
}

Throttle the typing indicator. Nobody needs to know you're typing ten times per second. Once every two seconds is enough.

Read Receipts

Mark messages as read when the user views them:

// app/Actions/Chat/MarkConversationRead.php
final class MarkConversationRead
{
    public function execute(Conversation $conversation, User $user): void
    {
        $conversation->participants()->updateExistingPivot(
            $user->id,
            ['last_read_at' => now()],
        );

        broadcast(
            new ConversationRead($conversation, $user),
        )->toOthers();
    }
}

On the frontend, trigger this when the chat window is visible. Not on every message — that's too many requests. Use an Intersection Observer or a simple "window is focused" check.

What's Annoying About Reverb

Let me be honest about the rough edges:

Reconnection handling. When the WebSocket disconnects (network change, laptop sleep, server restart), Echo reconnects automatically. But messages sent during the disconnection are lost. You need to fetch missed messages via HTTP when the connection is re-established. I use a "last message ID" approach — on reconnect, fetch messages newer than the last one we have.

Scaling. Reverb is single-process. For most applications, that's fine — a single Reverb instance handles thousands of concurrent connections. But if you need horizontal scaling, you'll need Redis pub/sub to share state between Reverb instances. It works, but it's an extra layer.

Debugging. When a WebSocket message doesn't arrive, debugging is harder than HTTP. Is it a channel auth issue? A broadcasting issue? A frontend listener issue? I ended up adding extensive logging to the broadcast events and channel auth callbacks during development.

SSL in local development. If you're using Herd with HTTPS locally, the WebSocket connection also needs to be secure. This is usually fine, but occasionally causes headaches with certificate validation. In development, I sometimes just use HTTP.

What Works Well

Speed. Messages arrive almost instantly. We're talking sub-100ms from send to display on the other end. For chat, this is critical — anything slower than 200ms feels laggy.

Reliability. In production, Reverb has been rock solid. I've had it running for months without a restart. Supervisor handles the occasional hiccup.

Integration. Because it's first-party, everything just works together. Broadcasting, channel authorization, Echo — it's a cohesive system, not a collection of packages that sort of work together.

If you're building a Laravel app that needs real-time features — chat, notifications, live updates — Reverb is the answer. It's not as feature-rich as dedicated chat services like Sendbird or Stream, but it's yours, it's free, and it's good enough for the vast majority of use cases.


I write about Laravel, AI tooling, and the realities of building software. More at stuartmason.co.uk.

Get the Friday email

What I shipped this week, what I learned, one useful thing.

No spam. Unsubscribe anytime. Privacy policy.