webpatser/resonate

Fiber-based WebSocket server: a drop-in replacement for Laravel Reverb, built on fledge-fiber

Maintainers

Package info

github.com/webpatser/resonate

pkg:composer/webpatser/resonate

Transparency log

Statistics

Installs: 347

Dependents: 6

Suggesters: 0

Stars: 0

Open Issues: 0

v0.6.0 2026-08-02 14:49 UTC

This package is auto-updated.

Last update: 2026-08-02 15:03:40 UTC


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, native array_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:restart cache 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-js configs 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 standard pusher: / client-* routing. Return Handled or Rejected to consume a custom event type, or Relay (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 the reverb.connections and reverb.messages Livewire 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.