webpatser / resonate
Fiber-based WebSocket server: a drop-in replacement for Laravel Reverb, built on fledge-fiber
Requires
- php: ^8.5
- illuminate/cache: ^13.0
- illuminate/console: ^13.0
- illuminate/contracts: ^13.0
- illuminate/support: ^13.0
- revolt/event-loop: ^1.0
- webpatser/fledge-fiber: ^13.4
Requires (Dev)
- laravel/pint: ^1.29
- laravel/pulse: ^1.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^4.0
- phpstan/phpstan: ^2.2
- pusher/pusher-php-server: ^7.2
README
Fiber-based drop-in replacement for Laravel Reverb, built on webpatser/fledge-fiber and PHP 8.5+.
Why
Reverb is already async, but it pulls in its own ReactPHP / Ratchet / clue-redis stack. Resonate consolidates the runtime onto Fledge: Revolt + webpatser/fledge-fiber, the same async stack that powers webpatser/torque, webpatser/laravel-fiber, and webpatser/laravel-resp3-cache. The wins are practical:
- PHP 8.5 only. No polyfills, no
version_compare, native URI parser, nativearray_all/array_any. - One async runtime per app. Fledge's HTTP server gives HTTP/2 and shares the loop with the rest of your async work, with no second event loop competing for the request.
- Fiber ergonomics. Channel auth, application providers, and pub/sub callbacks read like synchronous code but yield on I/O. Custom auth backends can hit a database or HTTP API without blocking the tick.
The wire protocol, REST API, and config schema are byte-compatible with Laravel Reverb.
Install (fresh app)
composer require webpatser/resonate php artisan resonate:install php artisan resonate:start
Install (swap from laravel/reverb)
composer remove laravel/reverb composer require webpatser/resonate
That's it. Nothing else changes:
- Same
config/reverb.php. Resonate reads the existing file. - New artisan commands:
resonate:start,resonate:restart,resonate:reload,resonate:install. Update supervisor / systemd / Docker entrypoints accordingly. - Same
laravel:reverb:restartcache key. Running servers restart on the same signal. - Same Pusher wire protocol (byte-exact JSON framing) and the same Pusher-compatible REST API.
- Supervisor / systemd / Docker configs stay as-is.
- Front-end Echo and
pusher-jsconfigs stay as-is.
Zero-downtime reload
resonate:restart is the legacy hard restart: it sets the laravel:reverb:restart cache key, the running server picks it up within 5 seconds, calls stop(), and your supervisor respawns it. WebSocket connections drop; the listener is gone for the 0-5 second window between exit and respawn. Fine for development, rough for production deploys.
resonate:reload is the production path. The listener is bound with SO_REUSEPORT so the new process can hold the port while the old one drains.
# Default: spawn a replacement, wait for /up, then drain the old PID. php artisan resonate:reload # Drain only (for systemd ExecReload=, Kubernetes preStop, Supervisor). php artisan resonate:reload --drain
Tune the drain window with REVERB_DRAIN_TIMEOUT (default 30 seconds). Existing WebSocket clients stay connected to the old process until they disconnect naturally or the timeout fires. --term-timeout (default 5 seconds) bounds the wait for the old process to exit after SIGTERM; the reload fails rather than reporting success if it is still alive.
GET /up returns the PID of the process that answered alongside health, so a probe can tell the replacement apart from the outgoing server while both hold the port.
Runtime files
resonate:start writes two files into storage/:
| File | Contents |
|---|---|
resonate.pid |
The server PID, written once the listening sockets are bound. |
resonate.json |
The PID and the effective host, port and path the server was started with. |
resonate:reload reads the metadata file so a replacement inherits the CLI overrides the running server was started with, rather than falling back to config. Metadata belonging to another PID is ignored.
A second resonate:start fails while the PID file names a live process, since SO_REUSEPORT would otherwise let it bind the same port and split the node into two processes with separate channel state. Pass --force to start anyway; resonate:reload passes it during a swap.
Resource limits
Defaults are set for a shared, multi-tenant process. Set any of these to 0 to disable the check.
Key (under servers.reverb) |
Environment variable | Default | What it bounds |
|---|---|---|---|
max_channel_name_length |
REVERB_MAX_CHANNEL_NAME_LENGTH |
255 |
Length of a channel name a client may subscribe to. Over-long names are rejected with pusher code 4200. Pusher itself caps names at 164 characters. |
max_subscriptions_per_connection |
REVERB_MAX_SUBSCRIPTIONS_PER_CONNECTION |
250 |
Distinct channels one connection may hold. An over-cap subscribe is rejected with pusher code 4302; the connection and its existing subscriptions stay intact. |
max_outbound_queue_size |
REVERB_MAX_OUTBOUND_QUEUE_SIZE |
1000 |
Messages that may wait on one connection. Exceeding it closes the connection with WebSocket code 1013. |
scaling.max_queued_messages |
REVERB_SCALING_MAX_QUEUED_MESSAGES |
10000 |
Inbound pub/sub envelopes waiting to be handled. Envelopes arriving while the queue is full are dropped and logged. |
Outbound queues
Every connection owns an outbound queue drained by a single writer fiber. send() queues and returns, so a client that stops reading its socket suspends only its own writer instead of stalling the channel fan-out and whatever issued the broadcast. One writer per connection keeps frames in the order they were queued.
The bound exists because unbounded buffering trades a stall for memory exhaustion. Budget for it as the bound multiplied by your average payload multiplied by the number of connections that can fall behind at once. A connection closed with 1013 reconnects and resubscribes on its own, which is standard Pusher client behaviour.
Message size
apps.apps[].max_message_size (default 10_000 bytes) is enforced per application. The application is known during the handshake, so each connection's RFC 6455 parser carries its own application's limit and an oversized frame is refused on its header with close code 1009, before a payload byte is buffered. The protocol layer re-checks the assembled message and rejects it with pusher code 4019.
The server-wide limit is now the smallest limit configured across your applications, and it governs only an upgrade whose app key resolves to no application. Such a connection is closed with pusher code 4001 as soon as the handler takes over.
Horizontal scaling
Set REVERB_SCALING_ENABLED=true along with your REDIS_* variables. Multiple Resonate instances coordinate via Redis pub/sub on fledge-fiber's async Redis client; message, terminate, and metrics events propagate across nodes.
Resonate uses a pure JSON envelope for cross-node messages, with no serialize() on the wire. This means a cluster cannot run mixed Resonate and laravel/reverb nodes; migration is all-at-once.
Server-side plugins
Resonate is a product-agnostic Pusher relay, but the fiber runtime makes it a natural host for stateful, server-side application logic - periodic timers, custom message types, connection bookkeeping - without a second process. The plugin API exposes that without coupling Resonate to any one product.
A plugin implements ServerPlugin plus any of three capability interfaces:
MessageInterceptor-onMessage(Connection, array $event): MessageDisposition. Runs before the standardpusher:/client-*routing. ReturnHandledorRejectedto consume a custom event type, orRelay(the default for traffic you don't own) to leave ordinary Pusher messages untouched.ConnectionLifecycle-onOpen/onClose/onSubscribe/onUnsubscribe. Observe connection transitions to maintain your own registries.TickScheduler-ticks()returns[{interval, callback}]. Each callback is scheduled on the event loop inside a fiber, so async DB/Redis calls suspend the fiber rather than blocking the loop.
Plugins receive a PluginContext at boot() with sendTo(), broadcast() (scaling-aware), terminate(), unsubscribe(), and connectionsOn(). broadcast() and connectionsOn() take an Application, an app id string, or null for the sole configured app, and the context resolves one itself via application() / applications() - so a TickScheduler callback, which has no connection to derive an app from, can still broadcast. Per-connection state lives on the Connection via setState() / state(). Register plugin classes in config/reverb.php:
'servers' => [ 'reverb' => [ // ... 'plugins' => [ App\Resonate\ChatPlugin::class, ], ], ],
Plugin classes are resolved through the container (so their dependencies inject), booted once at server start, and every hook call is exception-isolated - a misbehaving plugin can never break the core connection lifecycle.
First-party plugins
A small family of plugins ships under webpatser/*. Pick the ones you need; each is opt-in, each has its own README with the full setup.
| Package | What it does |
|---|---|
webpatser/resonate-roster |
Cluster-wide presence and channel-occupancy state in Redis. Restart-safe, self-healing, queryable from the backend without a metrics round-trip. |
webpatser/resonate-webhooks |
Pusher-style HTTP webhooks (channel_occupied/channel_vacated, member_added/member_removed, client_event). Signed, exactly-once per cluster via the roster. |
webpatser/resonate-user-cap |
Per-user connection cap with cluster-correct enforcement. Terminates over-cap connections with a Pusher error frame. |
webpatser/resonate-token-auth |
Token-based subscribe auth (JWT by default, pluggable). Lets mobile and S2S clients skip /broadcasting/auth. |
webpatser/resonate-delivery |
At-least-once message delivery within a retention window: every broadcast logged to a Redis Stream, replayed to reconnecting subscribers. |
webpatser/resonate-pulse |
Laravel Pulse cards for the suite: roster occupancy, webhook deliveries, user-cap terminations, token-auth rejections. |
A companion Laravel-side package, not a Resonate plugin, that consumes the webhooks:
| Package | What it does |
|---|---|
webpatser/resonate-channel-meter |
Records billable and observable channel occupancy periods from resonate-webhooks events as Eloquent models in your Laravel app. |
See docs/plugins.md for the same list with framing notes, plus a full setup walkthrough with a worked plugin you can build yourself.
Requirements
- PHP
^8.5 - Laravel
^13.0
Optional integrations:
laravel/pulse: Resonate registers thereverb.connectionsandreverb.messagesLivewire dashboard components automatically.laravel/telescope: entry storage for inspecting connections, channels, and messages.
Acknowledgements
Resonate is a clean-room port of laravel/reverb (MIT, © Taylor Otwell, Joe Dixon). Several files (notably the Pusher protocol layer and the Pulse dashboard cards) are direct ports of Reverb's MIT-licensed code. See LICENSE.md for the full attribution.
License
MIT. See LICENSE.md.