Events from the page, in Laravel
Pages send events to the rooms they are in, straight through WordPlus Cloud, with no request to your app: chat messages, typing, read ticks, a cursor. The options of a room say which events its pages may send.

What a room allows
// routes/channels.php: the chat of Ann and Ben. They send messages, typing, reactions and read ticks. The messages and
// reactions reach this app by webhook, which keeps the conversation.
Broadcast::channel( 'chat.{a}.{b}', fn ( User $user, string $a, string $b ) => in_array( (string) $user->id, [ $a, $b ], true ),
[ 'history' => 50, 'emit' => [ 'message', 'typing', 'reaction', 'read' ], 'webhook' => [ 'message', 'reaction' ] ] );
emitlists the names, or'*'for any. None by default: a room your app alone sends to.webhooknames those your app gets too, to keep them (below).historykeeps the last events of the room for 30 minutes: what a page that reconnects missed comes again from it.- The answer for one user can narrow it, from a room of the facade:
[ 'emit' => [ 'typing' ] ], or[ 'readOnly' => true ]. - Reserved:
joined,join,leave,countanderrorare the names kept for the room itself.
Sending
A page sends to a room it has joined, and hears what the others send:
const chat = rt.join( 'chat.34.42' );
chat.emit( 'message', { text: 'Hi' } ); // sent, nothing waited for
const { id, at } = await chat.emitWithAck( 'message', { text: 'Hi' } ); // WordPlus Cloud took it: checked, kept, sent
chat.volatile.emit( 'typing', { on: true } ); // dropped rather than queued while off line, never kept
await chat.timeout( 5000 ).emitWithAck( 'message', { text: 'Hi' } ); // an ack waited for 5 seconds at most
chat.on( 'message', ( data, meta ) => draw( data.text, meta.from ) ); // meta: { from, id, at }
- Everyone in the room gets it, the other tabs and devices of the sender too. The tab that sent it does not, so it draws its own at once.
emitWithAckresolves with the kept event id and time, or rejects with a code:not_allowed(the room does not list it),forbidden,rate_limited,too_large.meta.fromis the sender user id, as your app vouched for them: a page cannot send as someone else.- Binary: an
ArrayBuffer,Uint8ArrayorBlobmay be anywhere in the data. It is sealed as binary. - Limits: 1 MB an event, 50 a second a connection, in bursts of up to 100.
Typing, done the usual way
let sent = 0;
input.addEventListener( 'input', () => {
if ( Date.now() - sent < 2000 ) return; // at most every 2 seconds
sent = Date.now();
chat.volatile.emit( 'typing', { on: true } );
} );
chat.on( 'typing', ( data, meta ) => showTyping( meta.from, data.on ) ); // and hide it after 4 seconds of nothing
Keeping the conversation
The conversation is for your app to keep, in its own database, as Better Messages keeps its own in the WordPress database. A room keeps its events for 30 minutes, for pages that reconnect. Your app gets the events the room names in webhook (webhooks) and saves them by event_id, the id the pages saw as meta.id. A route gives them back, a page at a time:
// the boot() of a service provider
$this->callAfterResolving( Realtime::class, function ( Realtime $realtime ) {
$realtime->on( 'message', fn ( array $e ) => Message::firstOrCreate( [ 'event_id' => $e['event_id'] ], [
'room' => $e['room'], 'from_id' => $e['from'], 'text' => $e['data']['text'], 'created_at' => Carbon::createFromTimestampMs( $e['at'] ),
] ) );
} );
// routes/web.php: the newest 50, or with ?before=<id> the 50 before that one, oldest first. Only its two people may
// read it.
Route::get( '/chat/{a}/{b}/messages', function ( Request $request, string $a, string $b ) {
abort_unless( in_array( (string) $request->user()->id, [ $a, $b ], true ), 403 );
$before = Message::firstWhere( 'event_id', $request->query( 'before' ) );
$page = Message::where( 'room', "chat.{$a}.{$b}" )->when( $before, fn ( $q ) => $q->where( 'id', '<', $before->id ) )
->orderByDesc( 'id' )->limit( 51 )->get();
return [ 'messages' => $page->take( 50 )->reverse()->values()->map->toChat(), 'more' => $page->count() > 50 ]; // [ id, from, text, at, reactions ]
} )->middleware( 'auth' );
- Check there anything that matters: the events do not go through your app before they are sent on.
atis in milliseconds, andreactionsmaps each reaction to who chose it. A reaction comes as{ id, key }: the one reaction of the user to messageid, an emptykeytaking it back.
Ready-made: the chat
The Better Messages conversation, on any room: your app conversation paged back as the reader scrolls up, the room filling in what came since, the day separators, the messages of a sender stacked, typing, reactions, the read ticks and the composer. It is one element:
<wordplus-chat room="chat.{{ $chat->a }}.{{ $chat->b }}" messages="/chat/{{ $chat->a }}/{{ $chat->b }}/messages" placeholder="{{ __( 'Write a message' ) }}"></wordplus-chat>
await rt.callScreen(); // loads the elements and the call screen, rt.ui() the elements without calls
messagesis your route, asked with the page cookies.chat.load = async ( before ) => ( { messages, more } )replaces it, for a token or another shape:beforeisnullfor the newest page, or the oldest message shown ({ id, at }). With neither, it shows what the room still has.- Its room lets it send what it uses:
'emit' => [ 'message', 'typing', 'reaction', 'read' ], and namesmessageandreactionin'webhook'for your app to keep. - What it sends is what you would:
messageas{ text }with an ack,typingas{ on }andreadas{ id }, both volatile, andreactionas{ id, key }. Your own pages and apps on the same room read it. - Names and pictures come from your directory.
- It fills the height its place gives it. Its look is that of Better Messages, in a shadow root. Its colours are CSS custom properties your page sets.
<wordplus-typing room="…"> is the typing line alone, for a page that draws its own messages.
More, with Node and Python side by side: events in depth.