Realtime & WebSockets
Everything the platform pushes instead of waiting to be polled: chat messages, read receipts, typing indicators, and notifications. One websocket connection carries all of it.
- Server: Laravel Reverb (Pusher protocol β any Pusher client works, no vendor account)
- Auth: the same Sanctum bearer token as the REST API
- Connect:
wss://{host}/app/{REVERB_APP_KEY} - Authorize:
POST /api/broadcasting/authβ note: not under/api/v1/
Realtime is an accelerator, never the source of truth. Every frame documented here has a REST equivalent. A client that misses frames (backgrounded, tunnel flapped, socket dropped) must still be correct after a plain refetch β so treat these as "something changed, here is the change already parsed", not as the only way the change is ever delivered.
Quick start
npm install laravel-echo pusher-js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
const echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST, // the API domain
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'],
// Private and presence channels are authorized by the API, with the same
// bearer token the REST calls use.
authEndpoint: '/api/broadcasting/auth',
auth: {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
},
});
Then subscribe β notifications on the user channel, one thread channel per open conversation:
echo.private(`users.${me.id}.notifications`)
.notification((frame) => inbox.unshift(frame));
echo.join(`chat.threads.${threadId}`)
.here((users) => setOnline(users))
.joining((user) => addOnline(user))
.leaving((user) => removeOnline(user))
.listen('.chat.message.sent', (message) => appendMessage(message))
.listen('.chat.message.read', (receipt) => markSeenUpTo(receipt.read_at))
.listen('.chat.message.reaction', (frame) => setReactions(frame.message_id, frame.reactions))
.listen('.chat.typing', (frame) => showTyping(frame));
The leading dot in
.listen('.chat.message.sent')is required. It tells Echo the name is a literal wire name, not a PHP class to be namespaced. Without it Echo listens forApp\Events\Chat\chat.message.sentand you will silently receive nothing.
Channels
| Channel | Type | Who may subscribe | Carries |
|---|---|---|---|
users.{userId}.notifications |
private | that user only | every in-app notification, live |
chat.threads.{threadId} |
presence | thread participants only | messages, read receipts, typing |
users.{userId}.presence |
private | anyone allowed to see that user online | platform-wide online/away/offline β see presence-api.md |
Authorization is enforced server-side in routes/channels.php on every subscribe β the user
channel by id comparison, the thread channel by a chat_participants membership check, the
presence channel by the target's privacy switch and the block list. A non-participant gets 403
from the auth endpoint and never receives a frame. There is no client-side gate to get wrong.
chat.threads.{threadId} is a presence channel
Join it with Echo's join(), not private(). A private- subscription authorizes fine and
then receives nothing at all, because the events broadcast on the presence- channel β this is
the single most likely reason a correct-looking integration sees no messages.
Presence pays for itself: here / joining / leaving give you the roster of who currently has
the conversation open, so "online now" and "left the chat" need no extra endpoint. Each member
arrives as:
{ "id": "uuid", "name": "Jane Doe", "avatar_url": "https://β¦" }
Events
chat.message.sent
A message landed in the thread β text, file, system, or an appointment bubble.
The payload is the exact ChatMessage object the REST API returns
(messaging-api.md), so you can push it straight into an
already-fetched list with no second shape to maintain:
{
"id": "uuid",
"thread_id": "uuid",
"type": 1,
"type_label": "Text",
"body": "See you then!",
"metadata": null,
"sender": { "id": "uuid", "name": "Jane Doe", "avatar_url": null },
"attachments": [],
"created_at": "2026-07-19T05:30:00+00:00"
}
Two differences from the REST shape, both deliberate:
- No
is_mine. It is viewer-relative and a broadcast has many viewers, so shipping it would mean shipping it wrong. Derive it:message.sender?.id === me.id. shared_slots/slot/session_typeare resolved live forcalendar_share(type 2) andappointment_request(type 3) frames, same as the REST list. A slot shared yesterday may since have been booked, so render itsstatus/is_selectablefrom the frame rather than caching it.
You will not receive your own messages. Every chat event broadcasts with toOthers(), which
suppresses the echo to the socket that caused it β your client already has the message from the
201 response, and delivering both is what produces duplicate bubbles. This only works if you
send the X-Socket-ID header on your REST calls (see Socket ID below).
System messages (type: 5, e.g. "meeting link is ready") broadcast too and have no sender.
chat.message.read
The other participant moved their read cursor β flip your sent bubbles to "seen".
{
"thread_id": "uuid",
"user_id": "uuid",
"read_at": "2026-07-19T05:31:00+00:00"
}
The cursor is a timestamp, not a message id: everything created at or before read_at is
read. That is the same rule unread_count is computed from, so a client that marks
created_at <= read_at as seen can never disagree with the inbox badge.
Fired by POST /chat/threads/{thread}/read.
chat.message.reaction
Somebody added, changed or removed an emoji on a message β redraw that bubble's pills.
{
"thread_id": "uuid",
"message_id": "uuid",
"user_id": "uuid",
"type": "love",
"reactions": [{ "type": "love", "label": "Love", "emoji": "β€οΈ", "count": 1 }],
"reaction_count": 1
}
It carries the resulting tally, not a delta. Add, swap and remove all arrive as "these are the
pills now", so replace the row wholesale β a client applying +1/-1 drifts permanently the first
time it misses a frame.
type is null when that person cleared their reaction; user_id says whose it was either way.
There is no my_reaction on the frame β it is the same payload for everyone β so derive it: if
user_id is you, yours is type; otherwise leave yours alone.
You do receive your own frames here, unlike chat.message.sent. Your other devices need the
update and no response payload is racing it, so applying it unconditionally is safe.
Fired by POST and DELETE /chat/messages/{message}/reactions. See
messaging-reactions-response.md.
chat.typing
{
"thread_id": "uuid",
"user": { "id": "uuid", "name": "Jane Doe" },
"is_typing": true
}
Persists nothing β the frame is the feature. It is also the one event that bypasses the queue and broadcasts synchronously, because a typing bubble that arrives after a queue hop is worse than no typing bubble.
Always time your own indicator out (~3β4s since the last frame). A client that crashes
mid-keystroke never sends is_typing: false, and without a local timeout the other side is stuck
watching a phantom type forever.
Notification frames
On users.{userId}.notifications, delivered through Echo's .notification() rather than
.listen(). Shape mirrors the notification REST list-item so it can be unshift()ed into a
fetched list:
{
"id": "uuid",
"type": "chat.new_message",
"type_label": "New Message",
"data": { "β¦": "type-specific payload" },
"read_at": null,
"created_at": "2026-07-19T05:30:00+00:00"
}
Deliberately carries no unread_count β that would cost a COUNT(*) per recipient on every
fan-out. Increment your own badge and reconcile from the REST endpoint on next load. Full type
catalog: notification-system.md.
A new chat message produces two frames for a recipient who has the thread open: the
chat.message.sent frame on the thread channel and a chat.new_message notification on their
user channel. That is intended β one renders the bubble, the other drives the badge and the
inbox. Suppress the notification toast while the thread is focused.
Sending typing state
The only REST endpoint that exists purely to feed the socket layer.
POST /api/v1/chat/threads/{thread}/typing
| Field | Rules | Default |
|---|---|---|
is_typing |
sometimes|boolean |
true |
{ "is_typing": true }
Response 200 β { "status": "success", "message": "Typing state broadcast.", "data": {} }
| Code | Meaning |
|---|---|
| 200 | Broadcast |
| 401 | Missing/invalid token |
| 404 | Thread not found, or you are not a participant |
| 422 | is_typing is not a boolean |
| 429 | Rate limited β 120 requests/minute |
That ceiling is why you must not call this on every keystroke. Debounce: send once when
composing starts, then at most every ~2s while it continues, and once with is_typing: false
when the field empties or the thread closes.
Chat, end to end
The full client for one open conversation β presence, messages, receipts, and typing on a single
subscription. Everything below assumes the echo instance from Quick start.
The HTTP client
One interceptor, and the duplicate-message problem never happens:
import axios from 'axios';
const api = axios.create({ baseURL: '/api/v1' });
api.interceptors.request.use((config) => {
config.headers.Authorization = `Bearer ${token}`;
// Excludes your own connection from broadcasts you cause. Read it per
// request, not once at startup: it is null before the handshake and
// changes on every reconnect.
const socketId = echo.socketId();
if (socketId) {
config.headers['X-Socket-ID'] = socketId;
}
return config;
});
Opening a thread
function openThread(threadId, view) {
const channel = echo.join(`chat.threads.${threadId}`); // join(), NOT private()
const typingTimers = new Map();
const hideTyping = (userId) => {
clearTimeout(typingTimers.get(userId));
typingTimers.delete(userId);
view.hideTyping(userId);
};
channel
// ββ who else has this conversation open right now ββββββββββββββββββ
.here((users) => view.setOnline(users))
.joining((user) => view.addOnline(user))
.leaving((user) => {
view.removeOnline(user);
hideTyping(user.id); // they cannot still be typing
})
// ββ a message landed (never one of yours β see toOthers) βββββββββββ
.listen('.chat.message.sent', (message) => {
view.appendMessage({
...message,
is_mine: message.sender?.id === me.id, // omitted from frames
});
hideTyping(message.sender?.id); // sending ends typing
// The thread is on screen, so it is read the moment it arrives.
// This also emits the receipt the other side renders as "seen".
if (view.isFocused) {
api.post(`/chat/threads/${threadId}/read`);
}
})
// ββ the other side read up to a point in time ββββββββββββββββββββββ
.listen('.chat.message.read', ({ user_id, read_at }) => {
if (user_id !== me.id) {
view.markSeenUpTo(read_at); // every message with created_at <= read_at
}
})
// ββ typing, with a local timeout βββββββββββββββββββββββββββββββββββ
.listen('.chat.typing', ({ user, is_typing }) => {
if (!is_typing) {
return hideTyping(user.id);
}
clearTimeout(typingTimers.get(user.id));
view.showTyping(user);
// A client that crashes mid-keystroke never sends is_typing:false.
// Without this the bubble stays forever.
typingTimers.set(user.id, setTimeout(() => hideTyping(user.id), 4000));
});
// Call when the conversation closes β leave() handles the presence prefix;
// leaveChannel() would need the literal `presence-chat.threads.{id}`.
return () => {
typingTimers.forEach(clearTimeout);
echo.leave(`chat.threads.${threadId}`);
};
}
Sending a message
Your own message arrives in the 201, not over the socket β render it from the response:
async function send(threadId, body) {
const { data } = await api.post(`/chat/threads/${threadId}/messages`, { body });
// Envelope is { status, message, data: { message } }.
view.appendMessage({ ...data.data.message, is_mine: true });
}
If you render optimistically before the response, reconcile on the returned id β do not
also wait for a chat.message.sent frame for it. toOthers() guarantees it will never come, so
the placeholder would never resolve.
Sending typing state
The endpoint allows 120/min, so a raw keystroke handler will hit 429 on a fast typist:
let lastSentAt = 0;
let stopTimer;
function onKeystroke(threadId) {
const now = Date.now();
// At most one "started" frame every 2s while composing continues.
if (now - lastSentAt > 2000) {
lastSentAt = now;
api.post(`/chat/threads/${threadId}/typing`); // is_typing defaults to true
}
// β¦and one "stopped" frame once the keystrokes stop.
clearTimeout(stopTimer);
stopTimer = setTimeout(() => {
lastSentAt = 0;
api.post(`/chat/threads/${threadId}/typing`, { is_typing: false });
}, 3000);
}
Also send is_typing: false when the composer is cleared, when the message is sent, and when the
thread closes β the other side's 4s timeout is a safety net, not the mechanism.
Keeping the inbox live
Do not join a thread channel per row. An inbox of 50 conversations would mean 50 presence subscriptions, each announcing you as present in a conversation you do not have open.
The user notification channel already carries a chat.new_message frame for every thread you
participate in, which is exactly what the list needs:
echo.private(`users.${me.id}.notifications`)
.notification((frame) => {
if (frame.type === 'chat.new_message') {
// frame.data = { thread_id, message_id, sender_id, sender_name, preview }
view.bumpThread(frame.data.thread_id, frame.data.preview);
return;
}
view.pushNotification(frame);
});
Join the thread channel when a conversation is opened, leave it when it closes. One presence subscription at a time.
Socket ID
Send X-Socket-ID: {echo.socketId()} on every REST write. It is what lets the server exclude
your own connection from a broadcast it caused:
axios.defaults.headers.common['X-Socket-ID'] = echo.socketId();
Echo's own axios integration does this automatically; a hand-rolled fetch client does not. Get it wrong and every message you send comes back to you as a duplicate bubble β the classic chat realtime bug, and it will look like a server problem.
Read socketId() after the connection is established, and refresh it on reconnect: it is null
before the handshake and changes every time the socket comes back.
Reconnection
Pusher-protocol clients reconnect on their own, but frames sent while you were away are gone β
Reverb has no replay buffer. So on every connected event after the first:
- Refetch
GET /chat/threads/{thread}/messagesfor any open thread (or everything after your newest knowncreated_at). - Refetch
GET /chat/threadsto resync unread counts. - Re-read
echo.socketId()and update the header.
This is the same path the REST-only fallback takes, which is the point: if a client is correct after a cold start, it is correct after a reconnect.
Deployment (Coolify Β· Nixpacks)
Production is a single Nixpacks container built from nixpacks.toml at the repo root;
supervisord runs nginx, php-fpm, the queue workers, the scheduler, and Reverb side by side. See
notification-system.md Β§3.3 for the full supervisor table.
The websocket-specific pieces:
| Piece | Setting | Why it is that way |
|---|---|---|
| Supervisor program | worker-reverb β reverb:start --host=0.0.0.0 --port=8081, numprocs=1 |
Connection and channel state live in memory. A second process would only see half the subscribers β scaling out needs REVERB_SCALING_ENABLED=true + Redis, not more processes |
| Nginx | location ^~ /app β 127.0.0.1:8081 |
Prefix match covers both /app/{key} (client socket) and /apps/{id}/events (signed publish). ^~ stops the \.php$ regex handing an upgrade request to php-fpm |
| Nginx timeouts | proxy_read_timeout 3600s |
Chat sockets are idle by nature and Reverb pings every 60s. A 60s read timeout races the keepalive and churns reconnects on quiet threads |
| Supervisord | minfds=65535 |
One file descriptor per open socket, inherited by every child. The stock 1024 caps concurrent connections at ~1k |
| Queue | broadcasts queue in the worker's --queue= list |
chat.message.sent / chat.message.read are queued. Drop the queue name and messages persist but never arrive |
Coolify environment variables (server side β the container reaches Reverb over loopback; clients come in through the public domain):
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=<generate fresh β never reuse the dev values>
REVERB_APP_KEY=<fresh>
REVERB_APP_SECRET=<fresh>
# Where the app POSTs events to publish them. Loopback, so publishing never
# leaves the container or pays for TLS.
REVERB_HOST=127.0.0.1
REVERB_PORT=8081
REVERB_SCHEME=http
REVERB_APP_KEY is public by design β clients need it to connect. REVERB_APP_SECRET signs
publishes and must never reach a client.
Coolify's Traefik passes websocket upgrades through untouched, so nothing extra is needed at the proxy layer.
Verifying a deploy
supervisorctl status worker-reverb # RUNNING, uptime climbing, not restarting
php artisan queue:monitor broadcasts # ~0 and not growing
tail -f /var/log/worker-reverb.log # connections appear as clients join
Troubleshooting
| Symptom | Cause |
|---|---|
Subscribe returns 403 |
Not a participant of that thread β or a stale/missing bearer token on authEndpoint |
Subscribe returns 401 |
Token not reaching /api/broadcasting/auth; check the auth.headers block, and that the path has no /v1 |
| Connects, authorizes, no messages | Subscribed with private() instead of join(), or missing the leading dot in .listen('.chat.message.sent') |
| Every sent message appears twice | X-Socket-ID not sent on the POST, so toOthers() had no socket to exclude |
| Messages save but never broadcast | broadcasts missing from the worker --queue= list, or no queue:work running at all |
| Sockets drop every ~60s | A proxy read timeout in front of nginx that is shorter than Reverb's ping interval |
| Nothing broadcasts anywhere | BROADCAST_CONNECTION still log or null |
Testing
Broadcasts dispatch through the event dispatcher, so Event::fake() intercepts them like any
other event β no Reverb process needed, and phpunit.xml pins BROADCAST_CONNECTION=null:
Event::fake([MessageSent::class]);
$this->actingAs($sender, 'sanctum')
->postJson(route('chat.threads.messages.store', $thread), ['body' => 'hi'])
->assertCreated();
Event::assertDispatched(MessageSent::class, fn ($e) => $e->broadcastWith()['body'] === 'hi');
See tests/Feature/Chat/ChatBroadcastTest.php for the full set, including presence-channel
authorization against the real broadcaster.