Realtime (WebSocket / Reverb / Pusher)
Driver-based realtime for NativeBlade apps: subscribe to channels and receive
pushed messages live, run presence (who is online), send ephemeral client events,
and consume accumulating streams (AI tokens, live feeds). One connection
multiplexes many channels; you pick the driver in config and plug in.
Why it is shaped this way. The app’s PHP runs in php-wasm (request/response),
so a long-lived socket cannot live in PHP. It lives in the webview JS layer, and
every incoming message is delivered to your Livewire component as a#[On]
event, the same pipe native sensors and shell streaming use. A realtime app is
always a client: the socket needs a server (your Reverb/Pusher/WS
backend) on the other side.
Status
- Working:
reverb/pusher(public + private/presence channels via
realtimeAuth(), whisper, streams, lifecycle) and rawws(any external or
custom WebSocket, bidirectionalsend, AI/token streaming with coalescing,
auto-reconnect with backoff). Every message is delivered on both the generic
nb:realtimeand the pre-routednb:realtime:{channel}:{event}, pick a style.
1. Configure connections
Declare your connections once in your AppServiceProvider. Each named connection
picks a driver and its endpoint. The first one registered is the default.
use NativeBlade\Facades\NativeBladeConfig;
use NativeBlade\Config\RealtimeConfig;
NativeBladeConfig::realtimeConfig(function (RealtimeConfig $c) {
// Laravel Reverb (or Pusher) for people-facing pub/sub:
$c->connection('app', 'reverb', [
'key' => 'app-key',
'host' => 'rt.myapp.com',
'port' => 443,
'scheme' => 'https', // 'https' => forceTLS
'authEndpoint' => 'https://api.myapp.com/broadcasting/auth', // private/presence
]);
// A second connection only when the server/protocol differs (e.g. AI streaming):
$c->connection('ai', 'ws', ['url' => 'wss://ai.myapp.com/stream']);
});
| Driver | options |
|---|---|
reverb |
key, host, port, scheme, forceTLS, authEndpoint |
pusher |
key, cluster, host?, port?, authEndpoint |
ws |
url (wss://…), for any raw/custom WebSocket |
Nothing secret belongs here: the Pusher/Reverb key is a public client key, and
private/presence auth happens at runtime against authEndpoint with the user’s
bearer token.
2. Subscribe and receive
Subscribe in mount() and handle messages with #[On('nb:realtime')]. The
builder is flushed by ->toResponse() like every other native action.
use Livewire\Attributes\On;
use NativeBlade\Facades\NativeBlade;
use NativeBlade\Plugins\Realtime;
public array $messages = [];
public function mount(int $roomId): void
{
// Load history first (HTTP to your backend), then go live:
$this->messages = /* GET /rooms/{roomId}/messages */;
NativeBlade::realtime(fn (Realtime $r) =>
$r->subscribe('chat.'.$roomId) // public channel
)->toResponse();
}
#[On('nb:realtime')]
public function onMessage($connection, $channel, $event, $payload): void
{
if ($event === 'MessageSent') {
$this->messages[] = $payload;
}
}
nb:realtime carries $connection, $channel, $event, $payload. Route on
$event / $channel.
Prefer a dedicated listener per event? The same message is also delivered
pre-routed, so you can drop the if ($event === …):
#[On('nb:realtime:chat.42:MessageSent')]
public function shipped($payload): void { $this->messages[] = $payload; }
Channel types
$r->subscribe('news'); // public, no auth
$r->private('user.'.$id); // private, auth required (bearer to authEndpoint)
$r->presence('room.'.$roomId); // private + a live member list
For private/presence channels, hand the layer the user’s bearer token once
after login (Echo POSTs it to your authEndpoint); pass null on logout:
NativeBlade::realtimeAuth($userToken)->toResponse();
Presence (who is online)
Presence members arrive on nb:realtime-presence:
#[On('nb:realtime-presence')]
public function onPresence($connection, $channel, $event, $members = null, $user = null): void
{
// $event: 'here' (=> $members), 'joining' (=> $user), 'leaving' (=> $user)
}
3. Send
How you send depends on the driver.
Reverb / Pusher: the client only receives broadcasts. To send a persisted
message, POST to your backend, which thenbroadcast()s it to everyone:public function send(): void { NativeBlade::http()->post('https://api.myapp.com/rooms/'.$this->roomId.'/messages', [ 'body' => $this->draft, ]); $this->draft = ''; }Whisper, ephemeral client events (typing indicators, cursors, presence
pings) on a private/presence channel. Not persisted, rate-limited:NativeBlade::realtimeWhisper('chat.'.$this->roomId, 'typing', ['user' => auth()->id()])->toResponse();Native send, on the
wsdriver,realtimeSend()is a real frame sent
over the socket (bidirectional):NativeBlade::realtimeSend('feed', 'move', ['x' => 3], 'ws')->toResponse();
4. Raw WebSocket feeds (ws driver)
Point a connection at any WebSocket, a third-party feed you don’t control (a
crypto ticker, sports scores, an IoT gateway, a game server) or your own
non-Laravel backend. The framework opens/reconnects the socket, routes each frame
to Livewire, coalesces streams, and queues sends before the socket is open; the
frame shapes are your server’s (bring-your-own-protocol).
$c->connection('ticker', 'ws', ['url' => 'wss://stream.example.com/btcusd']);
public function mount(): void
{
NativeBlade::realtime(fn (Realtime $r) => $r->on('ticker')->subscribe('btcusd'))->toResponse();
}
#[On('nb:realtime')]
public function onFrame($connection, $channel, $event, $payload): void
{
// $payload = the parsed JSON frame (or the raw string if it wasn't JSON).
// If your frames are {channel, event, data}, $channel/$event are routed for you.
$this->price = $payload['p'] ?? $this->price;
}
public function send(): void
{
// Bidirectional: a real frame over the socket (string, or JSON-encoded object).
NativeBlade::realtimeSend('btcusd', 'ping', ['nonce' => 1], 'ticker')->toResponse();
}
subscribe() also sends a {"event":"subscribe","channel":"…"} frame in case
your server needs one; servers that stream everything by default just ignore it.
The connection auto-reconnects with backoff, emitting the lifecycle events (§6).
5. Streams (AI tokens, live feeds)
A stream() is a single growing response, not discrete list messages. The
driver coalesces its deltas (flushing a few times a second) so your component
appends batched text instead of re-rendering per token.
$r->on('ai')->stream('session.'.$sessionId); // open the stream
// then send the prompt:
NativeBlade::realtimeSend('session.'.$sessionId, 'prompt', ['text' => $prompt], 'ai')->toResponse();
public string $answer = '';
#[On('nb:realtime-stream')]
public function onDelta($connection, $streamId, $delta): void
{
$this->answer .= $delta; // batched append, smooth typewriter
}
#[On('nb:realtime-stream-end')]
public function onDone($connection, $streamId): void { /* finalize / persist */ }
#[On('nb:realtime-stream-error')]
public function onStreamError($connection, $streamId, $error): void
{
// Mid-stream drop = generation interrupted. Offer a retry, a partial
// generation cannot be resumed. This is NOT a gap to backfill.
}
6. Reconnection (the part that matters past the happy path)
Connections drop; the driver auto-reconnects and re-subscribes your channels, but
you missed whatever was sent during the gap. Re-fetch history on reconnect:
#[On('nb:realtime-reconnected')]
public function resync($connection): void
{
$this->loadSince($this->lastMessageId); // HTTP gap-fill since your last id
}
Also emitted: nb:realtime-connected (first connect) and
nb:realtime-disconnected ($connection, $reason).
Channels vs streams behave differently on purpose: a channel reconnect is a
gap to backfill (above); a stream drop is nb:realtime-stream-error → retry, not
backfill (a partial generation can’t be resumed). The stream driver does not
silently reconnect-and-rerun.
7. Multiple connections and channels
- Many channels, one connection (e.g. a Tinder-style list of chats): just
subscribe to each channel,chat.1,chat.2,chat.77, on the same
connection. Reverb multiplexes them; the JS layer ref-counts, so a channel two
components share opens once and closes when the last leaves. The conversation
list can subscribe to a firehose channel (user.{id}) for unread badges. - Many connections (e.g. people chat on Reverb + AI chat on a raw WS): declare
two named connections and target one with->on('name'). Rule of thumb:
channels separate things on the same server, named connections separate
servers/protocols.
Leave a channel on unmount():
public function unmount(): void
{
NativeBlade::realtimeLeave('chat.'.$this->roomId)->toResponse();
}
8. High-frequency feeds: deliver straight to JS
Every default realtime event becomes a Livewire #[On] dispatch, a full
php-wasm request. That’s right for human-paced traffic (chat, presence,
notifications). It is fatal for a firehose: a multiplayer game broadcasting
state at ~20 Hz, live cursors, or a telemetry stream turns into tens of PHP
requests per second, each booting Laravel and writing to the runtime’s virtual
filesystem, until it runs out of file descriptors and every request 500s
(File descriptor value too large).
For those, declare the connection deliver: 'js'. Its events bypass
Livewire/PHP entirely and surface as DOM CustomEvents that your own
public/js consumes directly, at native speed:
NativeBladeConfig::realtimeConfig(function (RealtimeConfig $c) {
$c->connection('game', 'ws', [
'url' => 'wss://game.myapp.com/play',
'deliver' => 'js', // <- events go to public/js, never to PHP
]);
});
Consume them in a small script under public/js/ (a classic script, no
import/export there; see Architecture). The events keep the same names,
delivered on window:
// public/js/game/net.js
window.addEventListener('nb:realtime', (e) => {
applyServerFrame(e.detail.payload); // e.detail = { connection, channel, event, payload, id }
});
window.addEventListener('nb:realtime-connected', () => setStatus('online'));
window.addEventListener('nb:realtime-reconnected', () => rejoin());
window.addEventListener('nb:realtime-disconnected', () => setStatus('offline'));
That listener is the whole bridge, no capture-phase tricks, no patching
Livewire.dispatch, no frame-mirror detection. Events are delivered only to the
active app frame, so a background/navigation buffer frame never double-consumes.
Sending still goes through PHP at human rate, call a Livewire method from JS
for actions the server must validate:
const wire = window.Livewire.find(root.closest('[wire\\:id]').getAttribute('wire:id'));
wire.$call('sendMove', movement); // one request per user action, not per frame
…or, for a raw ws connection you publish on directly, use
NativeBlade::realtimeSend(...) from that Livewire method. The rule: inbound
firehose → JS; outbound user actions → a normal Livewire call.
Events reference
Default connections deliver these as Livewire #[On] events; deliver: 'js'
connections deliver the identical set as window DOM CustomEvents whose
event.detail holds the same arguments.
| Event | Arguments |
|---|---|
nb:realtime |
$connection, $channel, $event, $payload |
nb:realtime-presence |
$connection, $channel, $event, $members?, $user? |
nb:realtime-stream |
$connection, $streamId, $delta |
nb:realtime-stream-end |
$connection, $streamId |
nb:realtime-stream-error |
$connection, $streamId, $error |
nb:realtime-connected |
$connection |
nb:realtime-reconnected |
$connection |
nb:realtime-disconnected |
$connection, $reason |
Setup
laravel-echo+pusher-jsare added to the app’spackage.jsonautomatically
(new apps via the scaffold; existing apps viaphp artisan nativeblade:update,
which syncs deps from the stub and runsnpm install). They are dynamically
imported, so they only load at runtime if the app actually uses realtime.- Run
php artisan nativeblade:configafter changingrealtimeConfig(), it
publishes the connections intopublic/nativeblade-config.json, which the JS
layer reads at boot. - You need a realtime server (Laravel Reverb, Pusher, or your own WS broker).
The NativeBlade app is only the client.