Reverb chat with polling fallback
Broadcast new chat messages over Laravel Reverb and fall back to cursor-based polling when the WebSocket is unavailable.
LaravelNotificationsreverbbroadcastwebsocketpolling
Code
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
class MessageSent implements ShouldBroadcast
{
use Dispatchable;
public function __construct(public Message $message) {}
public function broadcastOn(): array
{
return [new PrivateChannel('conversation.'.$this->message->conversation_id)];
}
public function broadcastAs(): string
{
return 'message.sent';
}
// Send a minimal payload, never the whole model with hidden relations.
public function broadcastWith(): array
{
return $this->message->only(['id', 'body', 'sender', 'created_at']);
}
}
// routes/api.php: the polling endpoint returns anything newer than the client's last id.
// Route::get('/conversations/{c}/messages', fn (Conversation $c, Request $r) =>
// $c->messages()->where('id', '>', (int) $r->query('after', 0))->orderBy('id')->limit(50)->get(['id','body','sender','created_at']));
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
export function connectChat(conversationId, render) {
let lastId = 0;
let timer = null;
// De-duplicate: the same message can arrive by socket and by poll.
const accept = (m) => {
if (m.id <= lastId) return;
lastId = m.id;
render(m);
};
const poll = async () => {
const res = await fetch(`/api/conversations/${conversationId}/messages?after=${lastId}`);
if (res.ok) (await res.json()).forEach(accept);
};
const startPolling = () => {
if (!timer) timer = setInterval(poll, 4000);
};
const stopPolling = () => {
clearInterval(timer);
timer = null;
};
const 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,
forceTLS: location.protocol === 'https:',
enabledTransports: ['ws', 'wss'],
});
echo.private(`conversation.${conversationId}`).listen('.message.sent', accept);
// Socket state drives the fallback; catch up once on every reconnect.
echo.connector.pusher.connection.bind('state_change', ({ current }) => {
if (current === 'connected') { stopPolling(); poll(); }
else if (['unavailable', 'failed', 'disconnected'].includes(current)) startPolling();
});
poll();
return () => { stopPolling(); echo.leave(`conversation.${conversationId}`); };
}
Needs: laravel-echo@^2.0, pusher-js@^8.4
How it works
What it solves
WebSockets give instant chat, but corporate proxies, flaky mobile networks and a stopped Reverb process all happen in real life. A chat that silently stops updating feels broken. This pair of files keeps messages flowing by degrading to short polling and recovering automatically.
How it works
MessageSent implements ShouldBroadcast and sends to a private channel named after the conversation, using a dedicated event name and a small payload of only the fields the UI needs.
On the client, one accept() function receives messages from both sources. It remembers the highest id seen and ignores anything at or below it, so a message that arrives by socket and again by poll renders once. The poll endpoint returns rows with an id greater than the after cursor, ordered and limited, which makes it cheap and idempotent.
The Echo connection reports its state. When it becomes unavailable, failed or disconnected, a four second interval starts. When it is connected again, polling stops and one catch-up poll runs, which fills any gap from the outage.
Adapting it
- Back the polling interval off to 10 seconds after a minute of failures.
- Authorise the private channel in
routes/channels.php, checking the user belongs to the conversation. - Use
ShouldBroadcastNowif you do not run a queue worker.
Pitfalls
- Broadcasting is queued by default; without a worker nothing is sent.
- Do not broadcast model attributes you would not show to every participant.
How to use it
- Run
php artisan install:broadcastingandnpm i laravel-echo pusher-js. - Start Reverb with
php artisan reverb:startand a queue worker. - Put the event in
app/Events/and add the poll route from the comment. - Authorise the channel in
routes/channels.phpforconversation.{id}. - Fire it after saving:
MessageSent::dispatch($message);, and callconnectChat(id, renderFn)on the page.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.