Search by

baggins800 / reverb-rs

elytica

Laravel integration for reverb-rs, a drop-in Rust replacement for the Laravel Reverb WebSocket server.

Package info

github.com/Baggins800/reverb-rs

Language:Rust

pkg:composer/baggins800/reverb-rs

Statistics

Installs: 15

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-09-29 20:38 UTC

This package is auto-updated.

Last update: 2026-09-29 20:49:33 UTC


README

A drop-in replacement for Laravel Reverb, written in Rust on Tokio.

It speaks the same Pusher protocol on the same routes, reads the same .env, and returns byte-identical frames — so pusher-js, Laravel Echo and pusher-php-server need no changes.

Close to a 100% replacement, but read What is not covered first. Everything a WebSocket client or the Pusher HTTP API can observe is covered. Reverb's five Laravel events are relayed back onto your event bus by the companion package, which restores Pulse, Telescope and your own listeners. A few things genuinely cannot follow.

Using it in a Laravel application

You keep laravel/reverb installed. It still provides config/reverb.php, the reverb broadcast connection, the event classes and the Pulse cards. The only thing that changes is which process serves WebSockets.

1. Install the package

composer require baggins800/reverb-rs
php artisan reverb-rs:binary --build

--build compiles the Rust sources shipped in the package, so it needs a Rust toolchain (rustup.rs) and takes a few minutes the first time. The binary lands in vendor/bin, where the other commands look for it.

If reverb-rs is already on PATH — a system package, a container image — it is used as-is and nothing is installed.

Without --build the command downloads a prebuilt release instead. Releases are built by .github/workflows/release.yml on a v* tag, so that path works once a tag has been pushed — until then, build from source or use the container.

Prefer to build it yourself:

git clone https://github.com/Baggins800/reverb-rs && cd reverb-rs
cargo build --release

2. Change nothing in your application

reverb-rs reads the same environment variables as Reverb, so your existing .env already configures it:

REVERB_APP_ID=123456
REVERB_APP_KEY=...
REVERB_APP_SECRET=...

REVERB_SERVER_HOST=0.0.0.0     # what the server binds to
REVERB_SERVER_PORT=8080
REVERB_HOST=reverb.example.com # what your app and browsers connect to
REVERB_PORT=443
REVERB_SCHEME=https

config/broadcasting.php, your Echo setup, broadcast(new OrderShipped($order)), ->toOthers(), Broadcast::channel() authorization and /broadcasting/auth all stay exactly as they are. tests/laravel.rs drives Laravel's own broadcaster against reverb-rs to prove it: broadcasts reach subscribers, toOthers() excludes the right socket, and the signatures /broadcasting/auth hands to the browser are accepted for both private and presence channels.

3. Run it instead of reverb:start

php artisan reverb-rs:start

That is the drop-in: it exports everything config/reverb.php resolves to, hands it to the server, and replaces itself with it — so signals and supervisors behave exactly as they did. It takes the same options as reverb:start.

Running the binary directly works too, reading the same .env:

cd /var/www/my-app && /usr/local/bin/reverb-rs

Supervisor — replace the command in your existing Reverb program:

[program:reverb]
command=php /var/www/my-app/artisan reverb-rs:start
directory=/var/www/my-app
autostart=true
autorestart=true
user=www-data
stopsignal=TERM                  ; closes connections cleanly before exiting
stopwaitsecs=15

Or systemd:

[Service]
Type=simple
WorkingDirectory=/var/www/my-app
ExecStart=/usr/local/bin/reverb-rs
Restart=always
User=www-data
KillSignal=SIGTERM
TimeoutStopSec=15

Command-line flags mirror reverb:start, and win over the environment:

reverb-rs --host 0.0.0.0 --port 8080 --path /ws --hostname reverb.example.com --debug

4. Behind a reverse proxy

Unchanged from Reverb — terminate TLS at nginx and forward the upgrade:

location / {
    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header Host $host;
    proxy_set_header Origin $http_origin;   # needed if you restrict allowed_origins
    proxy_read_timeout 3600s;
}

To terminate TLS in the server instead, set REVERB_SERVER_TLS_CERT and REVERB_SERVER_TLS_KEY. For local development with Herd or Valet, setting REVERB_HOST to your .test hostname is enough — the certificate is found the same way Reverb finds it.

5. Check it worked

curl http://127.0.0.1:8080/up                    # {"health":"OK"}
php artisan tinker
>>> broadcast(new App\Events\OrderShipped(Order::first()));

Your browser should receive the event. php artisan reverb:restart keeps working if your cache store is file or redis: the server watches the same key and shuts down cleanly when it changes. On any other store it logs a warning at startup and you restart with SIGTERM, which does the same thing.

6. Optional: keep Pulse, Telescope and your listeners

Reverb's Laravel events do not fire in a separate process. See Observability to relay them back; the Pulse Connections card needs nothing at all.

Rolling back

Nothing in your application changed, so rolling back is stopping reverb-rs and starting php artisan reverb:start again. The one exception is a Redis-scaled cluster, which must be all one implementation or the other — see What is not covered.

Why

Reverb runs on ReactPHP: one process, one thread, one event loop. Every broadcast frame is a PHP array allocation and a json_encode. On a stock PHP install the loop is StreamSelectLoop, which is select(2) and therefore capped at FD_SETSIZE (1024) descriptors.

reverb-rs keeps the same architecture on the outside and replaces the inside: a work-stealing Tokio runtime across every core, one encoded frame shared by reference across all subscribers of a channel, and no garbage collector.

The single largest win came from counting syscalls rather than guessing. Writing each frame individually cost one sendto per message; coalescing the frames already queued for a connection into one flush took 2,101 syscalls down to 428 for the same 2,000 messages, and CPU per message with it. Nothing waits to be batched — only frames already sitting in the queue are gathered — so an idle connection's latency is unchanged.

Measured against the real thing

Full results and method are in benchmark.md. The headline, from 500 subscribers on one channel receiving 1000 events of 100 bytes — 500,000 delivered messages, median of three runs on 14 cores:

Laravel Reverb reverb-rs
Messages delivered 138,567 msg/s 1,705,695 msg/s 12.3× faster
Wire throughput 195 Mbit/s 2402 Mbit/s 12.3×
CPU per message 5.94 µs 1.60 µs 3.7× less
Latency p50 54.9 ms 3.7 ms 14.8× lower
Memory idle 51.0 MB 7.0 MB 7.3× smaller
Memory per idle connection 21.8 KB 6.9 KB 3.1× smaller

Most of that 12.3× is having more than one core to use, which Reverb by design cannot. So the benchmark also runs both servers pinned to a single core with taskset, which is the comparison with that advantage removed:

One core each Laravel Reverb reverb-rs
Messages delivered 152,826 msg/s 1,084,578 msg/s 7.1× faster
CPU per message 5.24 µs 0.64 µs 8.2× less
Latency p99 61.0 ms 97.8 ms 1.6× worse

Seven times the throughput on one core is the runtime difference rather than the parallelism. Two things in there are worth not glossing over. reverb-rs is more efficient per message on one core than on fourteen (0.64 µs against 1.60 µs) — no cross-core cache traffic, and write batching coalesces harder when one thread is doing all the work. And its p99 latency on one core is worse than Reverb's: saturating a single worker thread with batched writes makes some connections wait their turn. Throughput and tail latency pull against each other there.

Wire throughput is loopback, so read it as a ceiling the server does not impose rather than a rate a real NIC would carry.

Reproduce it:

cd /path/to/reverb && composer install    # once
cargo build --release
cargo run --release --example benchmark -- --reverb-php /path/to/reverb

That starts and stops both servers itself, restarting them before every measurement so idle memory is genuinely idle, and writes benchmark.md. To load-test a single server instead, use cargo run --release --example bench -- --addr 127.0.0.1:8080.

PHP could not complete a 2000-connection run at all: stock PHP has no ext-event, so ReactPHP falls back to StreamSelectLoop and select(2) caps it at FD_SETSIZE (1024) descriptors. reverb-rs was tested to 5000 connections at 50 MB. That ceiling is a property of the PHP install rather than of Reverb's design — installing ext-event, ext-ev or ext-uv lifts it, though the server stays single-threaded either way.

Running in a container

docker pull ghcr.io/baggins800/reverb-rs

docker run -d -p 8080:8080 \
  -e REVERB_APP_ID=... -e REVERB_APP_KEY=... -e REVERB_APP_SECRET=... \
  ghcr.io/baggins800/reverb-rs

10.5 MB, for linux/amd64 and linux/arm64. The server is statically linked against musl and sits on distroless/static, so the image holds the binary, CA certificates and nothing else — no shell, no libc, no package manager. It runs as nonroot, and the binary is PID 1 and handles SIGTERM itself, so docker stop closes client connections cleanly before exiting.

Since there is no shell or HTTP client in the image, the binary is its own health probe:

reverb-rs --healthcheck      # exit 0 if the configured port answers /up

which is what the image's HEALTHCHECK and the compose service use. docker-compose.yml brings the server up with Redis and carries the switches for horizontal scaling and the event relay:

REVERB_APP_ID=... REVERB_APP_KEY=... REVERB_APP_SECRET=... docker compose up -d

Set REVERB_SCALING_ENABLED=true before scaling the service past one replica, or each replica will only serve its own connections.

Build it yourself with docker build -t reverb-rs . — the same Dockerfile CI uses.

Continuous integration

.github/workflows/ci.yml runs on every push and pull request: cargo fmt, cargo clippy with warnings denied, and the whole test suite against a real Redis and a real Laravel application, so the gated PHP suites actually run rather than skip. It also builds the image and checks it serves and shuts down cleanly.

.github/workflows/release.yml runs on a v* tag. It builds the image for each architecture on its own native runner — a Rust build under QEMU takes the better part of an hour — pushes both by digest to GHCR and combines them into one multi-architecture tag with provenance and an SBOM. In parallel it builds the binaries for x86_64/aarch64 Linux and macOS that php artisan reverb-rs:binary downloads, and attaches them to the GitHub release.

Compatibility

Verified by examples/conformance.rs, which drives a fixed script against a running server and prints every frame and response body with socket IDs masked. Run it against Laravel Reverb and against reverb-rs and diff the transcripts:

cargo run --release --example conformance -- --addr 127.0.0.1:8080 > php.txt
cargo run --release --example conformance -- --addr 127.0.0.1:8081 > rust.txt
diff php.txt rust.txt

Of 77 transcript lines, two differ — both cosmetic, both listed under Deliberate differences. Everything else is byte-identical, down to Symfony's JSON_HEX_TAG|HEX_AMP|HEX_APOS|HEX_QUOT escaping of API bodies and the plain-text Not found. / Method not allowed. / Payload too large. failure bodies.

Implemented in full:

  • Routes — GET /app/{appKey}, POST /apps/{appId}/events, POST /apps/{appId}/batch_events, GET /apps/{appId}/connections, GET /apps/{appId}/channels, GET /apps/{appId}/channels/{channel}, GET /apps/{appId}/channels/{channel}/users, POST /apps/{appId}/users/{userId}/terminate_connections, GET /up, all under the configured path prefix.
  • Channels — public, private, presence, cache, private-cache and presence-cache, including Reverb's prefix matching quirks (cache and private are matched without a trailing dash).
  • Auth — HMAC-SHA256 subscription signatures and the full Pusher request-signing scheme, with body MD5 and the 600-second timestamp tolerance.
  • Client events — client-* whispers with all / members / disabled policies, payload rebuilding and authenticated user_id injection.
  • Presence — member_added / member_removed, de-duplication by user across connections, and the roster in subscription_succeeded.
  • Cache channels — last-payload replay, pusher:cache_miss, and the rule that internal events never overwrite the cache.
  • Error codes — 4001, 4004, 4009, 4200, 4201, 4301 with Reverb's exact messages.
  • Connection management — origin allow-lists with wildcards, connection quotas, per-connection message rate limiting, max message size, ping/pong over both pusher:ping and WebSocket control frames, and the 60-second prune/ping sweep.
  • Horizontal scaling — Redis pub/sub fan-out, cross-node terminate_connections, and distributed metrics gathering for the channel endpoints.
  • Laravel events — all five relayed back to your application, with per-event opt-in and sampling for the two that fire per frame.
  • Request limits — max_request_size enforced with Reverb's Payload too large. 413, and its Not found. / Method not allowed. bodies for unrouted paths and wrong methods.
  • TLS, including Herd and Valet certificate discovery from REVERB_HOST; graceful shutdown on SIGINT/SIGTERM; multi-application tenancy.

Configuration

php artisan reverb-rs:start exports everything config/reverb.php resolves to and hands it over, so the config file is the source of truth — including things a .env cannot express: several applications declared inline, a custom application provider resolving them from a database, the options.tls block, and which cache store reverb:restart signals through. To inspect or pre-generate it:

php artisan reverb-rs:config --pretty > reverb-rs.json
REVERB_CONFIG_FILE=reverb-rs.json reverb-rs

Run directly without that file, and every REVERB_* and REDIS_* variable is read with the same name and default Reverb uses, so an existing .env works as-is. Command-line flags mirror reverb:start:

reverb-rs --host 0.0.0.0 --port 8080 --path /ws --hostname reverb.example.com --debug

For the multi-application config provider, export the reverb.apps.apps array to JSON and point REVERB_APPS_FILE at it.

A few knobs have no Reverb equivalent. The defaults are what the benchmark settled on and are worth leaving alone unless you are measuring:

REVERB_WS_READ_BUFFER Inbound framing buffer, preallocated per connection. Default 1024.
REVERB_WS_WRITE_BUFFER Outbound bytes to accumulate before writing. Larger coalesces more frames per syscall but leaves more resident. Default 2048.
REVERB_SEND_QUEUE_DEPTH Frames a slow client may fall behind before being disconnected. Default 1024.
REVERB_LISTEN_BACKLOG listen(2) queue depth. Default 4096; too small costs reconnecting clients a one-second SYN retransmit.
REVERB_MAINTENANCE_INTERVAL Seconds between ping/prune sweeps. Default 60, matching Reverb.
REVERB_SERVER_TLS_CERT / _KEY Terminate TLS in the server.
REVERB_APP_ALLOWED_ORIGINS Comma-separated origin allow-list.

See .env.example for the rest.

Observability

Reverb dispatches five events from inside the server process, and Pulse, Telescope and any listeners you wrote hang off them. reverb-rs publishes the same five to Redis; the baggins800/reverb-rs companion package re-dispatches them in your application as the real Laravel\Reverb\Events\* objects, so all of that keeps working:

REVERB_EVENTS_ENABLED=true
REVERB_EVENTS_TYPES=all
php artisan reverb-rs:relay     # alongside your app, like a queue worker

The Pulse Connections card needs nothing at all: ReverbConnections runs inside pulse:check, not inside the server, and reads GET /apps/{id}/connections over HTTP — which reverb-rs serves identically. Only the Messages card needs the relay.

message_sent and message_received fire once per delivered frame, so they are counted always and relayed only when asked for. Measured at 1000 subscribers:

Setting Fan-out Events dropped
Relay off 1,552k msg/s —
Lifecycle events only (default) 1,597k msg/s none
all, sample rate 1 1,263k msg/s 43%, Redis could not keep up
all, sample rate 0.05 1,459k msg/s none

The relay sheds load rather than slowing the server: on a saturated queue it drops events, warns once, and reports the total as events_dropped on GET /apps/{id}/counters. Watch that counter after turning message events on, and lower REVERB_EVENTS_SAMPLE_RATE if it climbs. At a few thousand frames a second none of this applies — everything is relayed exactly.

GET /apps/{appId}/counters is a reverb-rs addition, signed like every other endpoint, reporting cumulative messages_sent, messages_received and events_dropped for your own dashboards.

What is not covered

  • Listeners that write to a connection. A relayed event carries a connection you can read — its ID, origin and application are faithful — but send(), control() and terminate() throw, because the socket lives in the server process. Broadcast to the channel, or use the HTTP API's terminate_connections endpoint.
  • Applications that change while the server runs. A custom ApplicationProvider backed by a database is exported correctly by reverb-rs:config, but the export is a snapshot: adding a tenant needs a restart, where Reverb would have picked it up on the next connection.
  • verify_peer and passphrase in the options.tls array. The certificate and key are read; client-certificate verification and encrypted private keys are not.
  • reverb:restart on cache stores other than file and redis. Those two are read directly; database, memcached and the rest are not, so the server logs a warning at startup and you stop it with a signal instead.
  • Mixed-language Redis clusters. Reverb PHP-serializes the Application object into its pub/sub envelope; reverb-rs sends the application ID as JSON. The envelope is otherwise the same shape, so a scaled cluster must be all-Rust or all-PHP — which matters only during a rolling migration. Fixing it is a contained change to src/pubsub.rs.

Deliberate differences

Where behaviour diverges on purpose rather than by omission:

  1. GET /apps/{id}/channels orders its keys by name. Reverb emits them in channel-creation order, which a sharded concurrent map cannot reproduce without giving up what makes broadcast fast. Sorting is at least deterministic. Same channels, same values; JSON object member order carries no meaning and no Pusher client depends on it.

  2. Allow: GET,HEAD where Reverb sends Allow: GET. HEAD is implied by GET in HTTP and axum serves it; Reverb answers HEAD with a 405. Strictly more permissive, so nothing that worked before breaks.

  3. max_connections counts every socket, not just subscribed ones. Reverb derives its count from channel membership, so a client that connects and never subscribes is invisible to the quota. A limit that does not limit is a bug; reverb-rs enforces the real number. The /connections endpoint still reports Reverb's channel-derived count, so the API is unchanged.

  4. Ping and prune sweep every socket. Reverb only visits connections that joined a channel, so an idle unsubscribed client is never pinged and never reclaimed. reverb-rs sweeps all of them.

  5. A client that cannot keep up is disconnected. Each connection has a bounded outbound queue (REVERB_SEND_QUEUE_DEPTH, 1024 frames). Overflowing it closes the connection rather than buffering without limit, which is what lets one stalled subscriber take down a PHP node.

Tests

cargo test

# The scaling tests need Redis and are skipped without it.
REVERB_TEST_REDIS_URL=redis://127.0.0.1:6379 cargo test --test scaling

# The Laravel and relay tests additionally drive a real PHP process.
./laravel/tests/setup-test-app.sh /tmp/reverb-rs-test-app
REVERB_TEST_REDIS_URL=redis://127.0.0.1:6379 \
REVERB_TEST_PHP_APP=/tmp/reverb-rs-test-app \
  cargo test

132 tests, all in Rust. The PHP — both Laravel's broadcaster and the companion package — is driven from here rather than carrying a second test framework. Unit tests cover protocol formatting, signing, channel classification and metrics merging. tests/protocol.rs drives a live server through the Pusher handshake, all six channel types, presence membership, cache replay, client events, rate limiting, origin checks and quotas. tests/api.rs covers every HTTP endpoint and its failure modes. tests/scaling.rs stands up a two-node cluster on one Redis channel and checks cross-node broadcast, socket exclusion, cluster-wide metrics and remote termination. tests/events.rs asserts that all five Laravel events are emitted at the moments Reverb emits them, and freezes the JSON envelope the PHP package decodes. tests/relay.rs spawns the real companion package — Laravel boots, subscribes to Redis and dispatches Reverb's own event classes — then asserts on what Laravel actually saw: the five events, the rebuilt channel subclasses, that a relayed connection refuses to be written to, that a throwing listener cannot stop the relay, and that unknown applications and malformed payloads are discarded.

tests/config.rs runs the real reverb-rs:config export against a Laravel app and starts a server from it, covers reverb:restart, and boots the server through php artisan reverb-rs:start. tests/laravel.rs is the one that matters for a migration: it runs Laravel's own Broadcast::connection('reverb') against reverb-rs and asserts that broadcasts reach subscribers, that toOthers() excludes the right socket, that the signatures /broadcasting/auth returns are accepted for private and presence channels, and that the Pusher SDK's info endpoints answer correctly.

The PHP-facing assertions were checked by mutation — breaking the companion package in three places fails three different tests — so they are known to bite rather than merely pass. The expected frames are taken verbatim from Reverb's own test suite.

Layout

File
src/server.rs Protocol core: lifecycle, subscriptions, fan-out, signing
src/channel.rs The six channel flavours and their subscribers
src/registry.rs Sharded per-application channel and socket registry
src/conn.rs One connection: outbound queue, liveness, rate limit
src/protocol.rs Pusher frame formatting and error codes
src/http.rs HTTP API and Pusher request signing
src/ws.rs WebSocket upgrade and the per-connection loop
src/metrics.rs Channel statistics, local and merged across nodes
src/pubsub.rs Redis scaling
src/events.rs Counters and the event relay
src/restart.rs Watching the cache key reverb:restart writes
laravel/ Composer package: artisan commands and the event relay
examples/benchmark.rs Starts both servers and writes benchmark.md
examples/bench.rs Load generator for a single server
Dockerfile, docker-compose.yml Container build and a deployment with Redis
.github/workflows/ Tests on every push; images and binaries on a tag
src/config.rs config/reverb.php-compatible configuration

License

MIT, matching Laravel Reverb.