baggins800 / reverb-rs
Laravel integration for reverb-rs, a drop-in Rust replacement for the Laravel Reverb WebSocket server.
Requires
- php: ^8.2
- illuminate/console: ^10.47|^11.0|^12.0|^13.0
- illuminate/contracts: ^10.47|^11.0|^12.0|^13.0
- illuminate/redis: ^10.47|^11.0|^12.0|^13.0
- illuminate/support: ^10.47|^11.0|^12.0|^13.0
- laravel/reverb: ^1.0
Requires (Dev)
None
Suggests
- laravel/pulse: Needed only for ReverbRs\Pulse\Recorders\Messages, when sampling events in the server.
Provides
None
Conflicts
None
Replaces
None
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 (
cacheandprivateare 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 withall/members/ disabled policies, payload rebuilding and authenticateduser_idinjection. - Presence —
member_added/member_removed, de-duplication by user across connections, and the roster insubscription_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:pingand 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_sizeenforced with Reverb'sPayload too large.413, and itsNot found./Method not allowed.bodies for unrouted paths and wrong methods. - TLS, including Herd and Valet certificate discovery from
REVERB_HOST; graceful shutdown onSIGINT/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()andterminate()throw, because the socket lives in the server process. Broadcast to the channel, or use the HTTP API'sterminate_connectionsendpoint. - Applications that change while the server runs. A custom
ApplicationProviderbacked by a database is exported correctly byreverb-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_peerandpassphrasein theoptions.tlsarray. The certificate and key are read; client-certificate verification and encrypted private keys are not.reverb:restarton cache stores other thanfileandredis. Those two are read directly;database,memcachedand 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
Applicationobject into its pub/sub envelope;reverb-rssends 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 tosrc/pubsub.rs.
Deliberate differences
Where behaviour diverges on purpose rather than by omission:
-
GET /apps/{id}/channelsorders 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. -
Allow: GET,HEADwhere Reverb sendsAllow: GET.HEADis implied byGETin HTTP and axum serves it; Reverb answersHEADwith a 405. Strictly more permissive, so nothing that worked before breaks. -
max_connectionscounts 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-rsenforces the real number. The/connectionsendpoint still reports Reverb's channel-derived count, so the API is unchanged. -
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-rssweeps all of them. -
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.