PHPackages                             webpatser/resonate-roster - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Caching](/categories/caching)
4. /
5. webpatser/resonate-roster

ActiveLibrary[Caching](/categories/caching)

webpatser/resonate-roster
=========================

Redis room roster for Resonate: restart-safe, multi-node-correct presence channel membership

v0.2.0(2w ago)022MITPHPPHP ^8.5

Since May 22Pushed 2w agoCompare

[ Source](https://github.com/webpatser/resonate-roster)[ Packagist](https://packagist.org/packages/webpatser/resonate-roster)[ RSS](/packages/webpatser-resonate-roster/feed)WikiDiscussions main Synced 1w ago

READMEChangelogDependencies (7)Versions (3)Used By (2)

Resonate Roster
===============

[](#resonate-roster)

A Redis room roster for [Resonate](https://github.com/webpatser/resonate). It mirrors every presence channel into Redis so "who is online" becomes:

- **restart-safe**: it survives a Resonate reload, instead of being rebuilt only as clients reconnect;
- **multi-node-correct**: one shared truth across nodes, instead of per-node memory fragments;
- **backend-queryable**: readable directly from your Laravel app or a billing meter, with no metrics round-trip.

The problem it solves
---------------------

[](#the-problem-it-solves)

Resonate keeps presence channel membership in process memory, per node (`ArrayChannelManager` / `ArrayChannelConnectionManager`). That has two consequences:

1. **It is lost on a restart or reload.** After a `resonate:reload` the membership of `presence-chat.42` is empty until clients happen to reconnect and re-subscribe.
2. **It is not shared across nodes.** With `REVERB_SCALING_ENABLED=true`, Redis is only a pub/sub message bus between nodes. Each node still knows only its own connections. "Who is online across the cluster" is computed on demand by a metrics round-trip, not stored anywhere.

So there is no key you can read to answer "who is online in chat X". This package adds one.

How it works
------------

[](#how-it-works)

### The data model

[](#the-data-model)

A presence channel `C` on a Resonate node `N` is stored as a Redis **hash**:

```
{prefix}:{C}:{N}        field = socket id    value = presence user id

```

For example, two browser tabs from one user plus a second user, all on one node, look like:

```
roster:presence-chat.42:web-1-9001
  3919c8.41 => "7"      # user 7, tab one
  3919c8.88 => "7"      # user 7, tab two
  4a02f1.12 => "31"     # user 31

```

The key point: **each node owns only its own key.** There is no shared set that several nodes write into. That is a deliberate choice, because it is what makes TTL-based self-healing correct (see below).

A reader resolves a channel by `SCAN`-ing `{prefix}:{C}:*`, reading each node's hash, and merging:

- **sockets online** = every field across the hashes;
- **users online** = the distinct set of values across the hashes (so a user with three tabs counts once).

### The write side: `RedisRosterPlugin`

[](#the-write-side-redisrosterplugin)

The plugin runs **inside** the Resonate process as a registered server plugin. Because Resonate runs on a fiber runtime, its Redis writes suspend the calling fiber instead of blocking the event loop.

It reacts to three connection lifecycle events:

EventWhat it does`onSubscribe`A connection joined a `presence-*` channel: `HSET` its socket id and user id into this node's key, refresh the TTL, and record the channel on the connection's own state bag.`onUnsubscribe`A connection left a channel with an explicit `pusher:unsubscribe`: `HDEL` its socket id from this node's key.`onClose`A connection's socket closed: `HDEL` its socket id from every channel recorded on its state bag.`onClose` reads the channel list back from the **connection's state bag**, not from the channel manager. This is necessary: Resonate strips a connection from every channel *before* the close hook fires, so by the time `onClose` runs the manager no longer knows which channels the connection held. The plugin records them on subscribe precisely so it can clean them up on close.

### The heartbeat: self-healing

[](#the-heartbeat-self-healing)

The lifecycle hooks are the fast path, but they are not the source of truth. A node can crash without ever firing `onClose`, leaving stale entries behind. Two mechanisms fix that:

1. **Every key carries a TTL** (`ttl`, default 90s). Because each node owns its own key, a dead node's key is refreshed by nobody and simply expires. A live node never keeps a dead node's entries alive, which is exactly why the per-node key layout matters.
2. **A heartbeat tick** (`heartbeat_interval`, default 30s) is authoritative. On each tick the plugin walks every presence channel it has seen, reads the **live connections** from Resonate, and rewrites this node's key to match: it adds anything a missed `onSubscribe` left out, removes anything a missed `onClose` left behind, refreshes the TTL, and forgets channels that have emptied.

So the roster is eventually consistent within one heartbeat, and worst-case stale data clears within one TTL window.

### The read side: `RoomRoster`

[](#the-read-side-roomroster)

`RoomRoster` runs in your **Laravel app** (an ordinary synchronous request, not the fiber runtime), so it reads Redis over [predis](https://github.com/predis/predis). It shares the `RosterKeys` schema with the plugin, so the two can never disagree about where data lives.

```
Resonate process                         Laravel app
┌─────────────────────────┐              ┌────────────────────────┐
│ RedisRosterPlugin        │   writes     │ RoomRoster             │
│  onSubscribe/Unsub/Close │ ───────────► │  users(), userCount(), │
│  heartbeat reconcile     │   Redis      │  isOnline(), ...       │
│  (fledge-fiber async)    │ ◄─────────── │  (predis, synchronous) │
└─────────────────────────┘    reads      └────────────────────────┘

```

Installation
------------

[](#installation)

```
composer require webpatser/resonate-roster
```

Publish the config if you want to change the defaults:

```
php artisan vendor:publish --tag=resonate-roster-config
```

Registering the plugin
----------------------

[](#registering-the-plugin)

Add the plugin to the `plugins` array of your server in `config/reverb.php`:

```
'servers' => [
    'reverb' => [
        // ...
        'plugins' => [
            \Webpatser\ResonateRoster\RedisRosterPlugin::class,
        ],
    ],
],
```

Restart Resonate (`php artisan resonate:start`, or `resonate:reload` for a zero-downtime swap) to load it.

Reading the roster
------------------

[](#reading-the-roster)

Resolve `RoomRoster` from the container anywhere in your app:

```
use Webpatser\ResonateRoster\RoomRoster;

$roster = app(RoomRoster::class);

$roster->users('presence-chat.42');         // ['7', '31'] - distinct user ids
$roster->userCount('presence-chat.42');     // 2
$roster->sockets('presence-chat.42');       // every socket id
$roster->socketCount('presence-chat.42');   // 3
$roster->isOnline('presence-chat.42', '7'); // true
$roster->occupiedChannels();                // every channel with members
```

A billing meter that needs to know whether a chat is still occupied can ask `userCount('presence-chat.42')` directly, with no call into the socket server.

Configuration
-------------

[](#configuration)

KeyDefaultPurpose`connection``REDIS_*` envThe Redis server. The plugin and the reader **must** point at the same server and database.`key_prefix``roster`Namespace for every roster key. Avoid colons in the prefix.`ttl``90`Seconds each node's key lives; refreshed on every heartbeat.`heartbeat_interval``30`Seconds between reconcile ticks. Keep it well below `ttl`.`track``presence``presence` mirrors only presence channels; `all` mirrors every channel type, so the roster also answers "how many connections does this channel have".Override any of these per environment with `RESONATE_ROSTER_*` variables (see the published config file).

Notes and caveats
-----------------

[](#notes-and-caveats)

- **One Redis, both sides.** The plugin (fledge-fiber async client) and `RoomRoster` (predis) read the same `connection` block, so they must point at the same server and database. This is the single source of truth; do not split it.
- **Presence channels only.** Only `presence-*` channels are mirrored. Public and private channels are ignored.
- **Distinct-user semantics.** `users()` deduplicates by the presence `user_id`, so a user on several tabs or several nodes counts once. `sockets()` does not deduplicate.
- **Eventually consistent.** A missed lifecycle hook is corrected within one `heartbeat_interval`; a hard node crash clears within one `ttl`.
- **The roster is product-agnostic.** It mirrors any presence channel. "Chat rooms" are just `presence-chat.{id}` channels; nothing here is chat-specific.

Requirements
------------

[](#requirements)

- PHP 8.5+
- Resonate 0.4+
- A Redis server reachable from both the Resonate process and your Laravel app

Testing
-------

[](#testing)

```
composer test
```

Tests that touch Redis expect a server on `127.0.0.1:6379` and use database 15; they skip cleanly when no Redis is reachable.

License
-------

[](#license)

MIT. See [LICENSE](LICENSE).

###  Health Score

39

—

LowBetter than 84% of packages

Maintenance96

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community10

Small or concentrated contributor base

Maturity42

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

18d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e442a1d15a5b64438f3b471acfded80951afb1bed23641cfd80c5254099eab9d?d=identicon)[webpatser](/maintainers/webpatser)

---

Top Contributors

[![webpatser](https://avatars.githubusercontent.com/u/25720?v=4)](https://github.com/webpatser "webpatser (2 commits)")

---

Tags

WebSocketsredisBroadcastingpresencerosterresonate

###  Code Quality

TestsPest

### Embed Badge

![Health badge](/badges/webpatser-resonate-roster/health.svg)

```
[![Health](https://phpackages.com/badges/webpatser-resonate-roster/health.svg)](https://phpackages.com/packages/webpatser-resonate-roster)
```

###  Alternatives

[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[illuminate/cache

The Illuminate Cache package.

12936.5M1.7k](/packages/illuminate-cache)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[moonshine/moonshine

Laravel administration panel

1.3k239.9k72](/packages/moonshine-moonshine)[tallstackui/tallstackui

TallStackUI is a powerful suite of Blade components that elevate your workflow of Livewire applications.

719160.4k12](/packages/tallstackui-tallstackui)[iazaran/smart-cache

Smart Cache is a caching optimization package designed to enhance the way your Laravel application handles data caching. It intelligently manages large data sets by compressing, chunking, or applying other optimization strategies to keep your application performant and efficient.

2119.7k](/packages/iazaran-smart-cache)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
