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(2mo ago)022MITPHPPHP ^8.5CI passing

Since May 22Pushed 3w 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 3w 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

38

—

LowBetter than 83% of packages

Maintenance92

Actively maintained with recent releases

Popularity2

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

63d 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 (6 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

3345.3M347](/packages/psalm-plugin-laravel)[illuminate/cache

The Illuminate Cache package.

12937.0M1.9k](/packages/illuminate-cache)[laravel/ai

The official AI SDK for Laravel.

1.0k3.2M246](/packages/laravel-ai)[illuminate/filesystem

The Illuminate Filesystem package.

15265.2M3.3k](/packages/illuminate-filesystem)[moonshine/moonshine

Laravel administration panel

1.3k253.1k86](/packages/moonshine-moonshine)[tallstackui/tallstackui

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

728176.2k14](/packages/tallstackui-tallstackui)

PHPackages © 2026

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