
A Laravel Reverb realtime client for Flutter. It speaks the Pusher wire protocol in pure Dart — no native plugins — with a Laravel Echo-style API: chainable, cancelable listeners, automatic reconnection with re-authorization, presence channels, and client events (whispers).
It exists because of three specific problems, each of which cost us a production bug in a shipped app:
- A channel outlives one screen. Two screens listening to the same channel
is normal, and a bare
unsubscribe()from either one tears it down for both. Here,listen()returns a handle and the channel only unsubscribes when its last listener is cancelled — so ref-counting is an invariant of the package, not aMap<String, int>every app maintains by hand. - Backgrounded apps hold dead sockets. iOS silently kills a socket the app
isn't watching, and the client never notices.
handleAppLifecycle(on by default) disconnects on background and reconnects on foreground. - Event names are Laravel's, not the wire's.
listen('OrderCreated')resolves toApp\Events\OrderCreated, andlisten('.order.created')to abroadcastAs()name — the same rules Laravel Echo uses, so your backend docs translate directly.
Is this the right package for you?
pusher_reverb_flutter is a
mature, actively maintained alternative that solves much of the same problem,
and it is the better choice if you need any of these — none of which this
package supports:
- Encrypted channels (
private-encrypted-) - Pusher's hosted service, clusters or API-key configuration — this package targets self-hosted Reverb only
- Custom WebSocket paths
Pick this one if ref-counted channel teardown, app lifecycle handling, or Echo-compatible event names are what you're missing. Both are MIT and speak the same protocol, so switching either direction is a mechanical change.
Install
flutter pub add laravel_reverb
Laravel setup
This package targets a self-hosted Reverb
server, not Pusher's hosted service. In your Laravel app's .env, set:
BROADCAST_CONNECTION=reverb
REVERB_APP_KEY=your-reverb-app-key
REVERB_HOST=api.example.com
REVERB_PORT=443
These map directly onto the Reverb constructor: REVERB_APP_KEY to
appKey, REVERB_HOST to host, REVERB_PORT to port.
How it fits together

Quick start
final reverb = Reverb(
host: 'api.example.com',
port: 443,
appKey: 'your-reverb-app-key',
useTls: true,
authEndpoint: 'https://api.example.com/broadcasting/auth',
authHeaders: () async => {'Authorization': 'Bearer $token'},
);
await reverb.connect();
Errors, state and teardown
onError(constructor parameter) is how the package reports runtime failures it handled without throwing — a dropped socket, a rejected subscription, a failed authorizer, a non-fatal protocol error. There is no other channel for these; if you don't pass it, they are reported nowhere.reverb.statesis aStream<ReverbState>of every connection state change (connecting,connected,reconnecting,disconnected,failed);reverb.stateis the current value.handleAppLifecycle(defaulttrue) disconnects on background and reconnects on foreground, so iOS doesn't hold a socket the OS will silently kill. Set it tofalseif your app manages the socket itself.- Call
reverb.dispose()from your app's teardown (e.g. alongside other singletons at shutdown). It disconnects, stops observing app lifecycle events, closes thestatesstream and closes thehttp.Clientit created forauthEndpoint(if any) — skipping it leaks the socket, the lifecycle observer and that client's connection pool. A client you passed in yourself via a customauthorizeris never touched — that one is yours.
Recipes
Public channel
final subscription = reverb.channel('orders').listen(
'OrderCreated',
(Map<String, dynamic> data) => print(data),
);
Private channel
listen returns a chainable Subscription; cancel it in dispose to remove
every listener registered through the chain.
class _OrderScreenState extends State<OrderScreen> {
Subscription? _subscription;
@override
void initState() {
super.initState();
_subscription = reverb
.private('users.1')
.listen('OrderShipped', (data) => print(data))
.listen('OrderCancelled', (data) => print(data));
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}
A channel unsubscribes once its last listener is cancelled, but the handle
itself is never left dead: calling listen on it again resends
pusher:subscribe (re-authorizing private and presence channels against the
current socket id) and puts it straight back to work — as long as nothing
else has since claimed the same name. Stick to one pattern per channel name:
either keep reusing the handle you already have, or always ask reverb for
a fresh one (reverb.private('users.1'), etc.). Mixing the two for the same
name — holding an old, emptied handle while also asking for a new one — means
only the one holding the name is live. The other stays inert, and it does not
wake up on its own when the occupant releases the name: it only reclaims it on
its own next 0-to-1 listener transition.
Presence channel
final channel = reverb.presence('chat.1');
channel.members(
here: (members) => print('online: $members'),
joining: (member) => print('joined: ${member.id}'),
leaving: (member) => print('left: ${member.id}'),
);
Whisper (client events)
Whispers are only available on private and presence channels — they never reach the application server, so they suit ephemeral signals like typing indicators.
final channel = reverb.private('chat.1');
channel.listenForWhisper('typing', (data) => print('${data['user']} is typing'));
channel.whisper('typing', {'user': 'Alice'});
Event names
A bare event name is namespaced against App\Events (or whatever namespace
you passed to the constructor), so listen('OrderCreated') matches the wire
event App\Events\OrderCreated. A leading dot means a literal
broadcastAs() name: listen('.order.created') matches an event broadcast as
order.created.
Reconnection
When the socket drops, laravel_reverb retries with exponential backoff —
1s, 2s, 4s, 8s, 16s, capped at 30s, with jitter so that clients dropped by the
same outage don't all reconnect in lockstep. On reconnect, every private and
presence channel is re-authorized against the new socket id, because a Pusher
auth signature is bound to the socket id it was issued for.
Reverb does not replay events missed while disconnected — this is exactly
what onReconnected is for. It fires only after every previously-live
channel has resubscribed (so a refetch can't race a half-restored socket),
and it does not fire on the first successful connect — but an explicit
disconnect() followed by connect() does fire it, since that is a real
restore too:
reverb.onReconnected(() => refetch());
If an Authorizer throws for a given private or presence channel, that
failure is reported through onError and laravel_reverb retries it with
the same exponential backoff used for reconnects, up to three attempts in
total. Every failure — including the last — is reported through onError,
so a transient 500 or a token that is momentarily expired is never silent.
Once the last attempt fails, the channel is left registered but subscribed to nothing — re-listening on the same handle, or requesting it again, does not retry on its own, since from the registry's point of view the channel is still there and already has its listener. Cancel every listener on it first, so it actually unsubscribes and is dropped from the registry, and then listen again (or request it again) to force a fresh authorization attempt:
subscription.cancel();
final channel = reverb.private('users.1'); // retries authorization
Custom authorizer
For apps that need their own HTTP client, interceptors, token refresh or
certificate pinning, pass authorizer instead of authEndpoint. The package
then makes no HTTP requests of its own:
final reverb = Reverb(
host: 'api.example.com',
appKey: 'your-reverb-app-key',
authorizer: (String channelName, String socketId) async {
final response = await myHttpClient.post(
Uri.parse('https://api.example.com/broadcasting/auth'),
body: {'socket_id': socketId, 'channel_name': channelName},
);
return ReverbAuth(auth: jsonDecode(response.body)['auth'] as String);
},
);
Migrating from pusher_channels_flutter
| pusher_channels_flutter | laravel_reverb |
|---|---|
init(...) + connect() |
Reverb(...) constructor + connect() |
subscribe(channelName: 'private-users.1') |
private('users.1') — the prefix is added for you |
onAuthorizer |
authEndpoint/authHeaders, or authorizer for a full override |
Manual ref-counting / unsubscribe |
Subscription.cancel() — the channel tears itself down once its last listener is gone |
trigger(eventName, channelName, data) |
channel.whisper(eventName, data) |
Publisher
Published under the pub.dev verified publisher gaitco.com.
Libraries
- laravel_reverb
- A Laravel Reverb realtime client for Flutter.