From laravel-echo-server to laravel-websockets to Laravel Reverb

This post replaces “Transitioning from Laravel Echo Server to Laravel WebSockets”, which I wrote for the Oh Dear blog on 4 December 2018. The full 2018 story is below. I added what happened to both packages since, what Oh Dear runs today, and how I’d set this up in 2026 with Laravel Reverb.

At Oh Dear , the website monitoring tool I co-founded, we finished moving our websocket server from laravel-echo-server (Node) to laravel-websockets (pure PHP) in December 2018.

Both of those packages are archived now. Laravel’s own answer is Laravel Reverb . And Oh Dear itself? It stopped running a websocket server altogether in 2020.

Oh Dear in 2018: laravel-echo-server and Redis#

Oh Dear is built on Laravel , so we already ran a fair bit of Node during the build phase. Our frontend JavaScript and CSS got compiled via webpack, through Laravel Mix . In a way, node was already part of the stack.

Part of the smooth user experience (if we say so ourselves ;-)) came from websockets. They gave users instant feedback in their dashboard and on our homepage. To make that work, we had always used laravel-echo-server, a node implementation of a websocket server.

You could feed that websocket server in 2 ways: through a Redis queue, or by publishing messages directly over HTTP. We used Redis, which meant this happened for every event:

  1. Laravel publishes a message to a Redis channel
  2. The echo-server listens for new events being stored there
  3. The echo-server relays those to its subscribers/clients

This worked very well for us, without any issues.

But we found ourselves in the unique spot to test an even simpler approach: run the websocket server fully in PHP, without the need for Node.

Moving the websocket server to PHP#

Our initial reaction was “but surely PHP can’t handle the load a Node process could, right?”.

Well, to get started we benchmarked the laravel-websockets package using Artillery . The PHP implementation handled our load with great ease and stayed under 50MB of memory.

Performance, during our testing, was on par with the Node implementation.

Since we weren’t losing anything, we decided to remove Node from our production machines and run the entire websocket stack in PHP.

Adding TLS and Supervisor#

Our setup already used Nginx as a TLS proxy and Supervisor to keep all our workers running. So the building blocks were in place for the new websocket server.

We configured both Nginx and Supervisor to handle the TLS part and to keep the process running. That took very little time.

The code changes from socket.io to Pusher#

Code-wise, the change was a piece of cake. Unless we forgot something, it consisted of:

  • Remove all references to /js/socket.io.js in our frontend. We no longer needed the socket.io client for our websockets.
  • Install laravel-websockets and follow its install instructions.
  • Change our frontend code from socket.io to the Pusher broadcaster in Laravel Echo:
     window.Echo = new Echo({
-        broadcaster: 'socket.io',
+        broadcaster: 'pusher',
         key: window.pusherKey,
-        host: window.pusherHost,
+        wsHost: window.pusherHost,
+        wsPort: window.pusherPort,
+        disableStats: true,
     });
  • Change our broadcast driver from Redis to Pusher in .env:
- BROADCAST_DRIVER=redis
+ BROADCAST_DRIVER=pusher
+ PUSHER_ENDPOINT=socket.ohdear.app
+ PUSHER_ENDPOINT_SCHEME=https

That was enough to move to laravel-websockets. (If you’re reading this with a current Laravel app: BROADCAST_DRIVER was renamed to BROADCAST_CONNECTION in the Laravel 11 skeleton.)

What we gained in 2018#

The biggest gain was in our development process. We only had to run php artisan websockets:serve to get a local websocket server going, instead of dealing with the (rather confusing) configuration of laravel-echo-server. (The 2018 post said websocket:serve. The real command in laravel-websockets is plural.)

We also simplified our server setup and fully relied on PHP, without Node, for running websockets. Managing less software is always a win, especially from a security angle: no more keeping track of the Node ecosystem and the dependencies of the echo-server.

For us, it was a no-brainer to make the switch.

Both packages are archived now#

Fast forward to 2026, and neither package is maintained.

laravel-echo-server is archived on GitHub. Its last npm release is 1.6.3, from January 2022.

laravel-websockets is archived too. Its last release is 1.14.1, from August 2023. The last commit, on 7 February 2024, added this notice to the README:

Laravel WebSockets is no longer maintained. If you are looking for a PHP-based WebSocket solution, check out Laravel Reverb which is also built on top of ReactPHP and allows you to horizontally scale the WebSocket server.

So if you followed the 2018 advice, you’re running a package that won’t get another fix.

What Oh Dear runs today: no websockets at all#

I went through the Oh Dear codebase as it is today. There’s no laravel-websockets, no Reverb and no Pusher SDK in composer.json, and no laravel-echo or pusher-js in package.json.

The websocket server left in April 2020. Freek Van der Herten, my co-founder at Oh Dear, rebuilt the Oh Dear dashboard with Livewire , and the same branch removed beyondcode/laravel-websockets, laravel-echo and config/websockets.php.

The parts of the app that need to stay fresh now use Livewire’s wire:poll . About two dozen Blade views use it, most with an interval between 5 and 20 seconds:

<div wire:poll.5s>
    ...
</div>

For a monitoring dashboard, a refresh every few seconds is good enough. And there’s no websocket server to keep alive, proxy or scale at all.

Laravel Reverb: what I’d use today#

If you do need push, for chat, live collaboration or anything that can’t wait for the next poll, Laravel Reverb is the answer in 2026. It’s first-party, built by the Laravel team, MIT licensed and built on ReactPHP , just like laravel-websockets was.

The first beta was tagged on 12 March 2024, the day Laravel 11 came out. Reverb 1.0 followed on 2 July 2024. The latest release as I write this is 1.12.0, from 22 September 2026.

Reverb speaks the Pusher protocol. That’s why the frontend still uses pusher-js under Laravel Echo, the same trick laravel-websockets used in 2018.

I ran everything below on a fresh app: Laravel 13.36.0, PHP 8.5.4, Reverb 1.12.0, laravel-echo 2.6.1 and pusher-js 8.6.0.

Installing Reverb with install:broadcasting#

Broadcasting isn’t enabled in a new Laravel app. One Artisan command installs Reverb, publishes the config, and writes the .env values:

$ composer create-project laravel/laravel reverb-test
$ cd reverb-test
$ php artisan install:broadcasting --reverb --no-interaction
 INFO Published 'broadcasting' configuration file.
 INFO Published 'channels' route file.
./composer.json has been updated
Running composer update laravel/reverb --with-all-dependencies
...
 - Downgrading guzzlehttp/guzzle (8.2.0 => 7.15.5)
 - Downgrading guzzlehttp/promises (3.0.2 => 2.5.3)
 - Downgrading guzzlehttp/psr7 (3.1.0 => 2.13.1)
 - Locking laravel/reverb (v1.12.0)
 - Locking pusher/pusher-php-server (7.3.0)
...
 INFO Reverb installed successfully.
 INFO Installing and building Node dependencies.

Reverb pulls in pusher/pusher-php-server, because the server-side broadcaster talks the Pusher protocol too.

And it downgraded Guzzle from 8 to 7. Reverb 1.12.0 requires guzzlehttp/psr7 ^2.6, and Guzzle 8 needs psr7 ^3.1. On a fresh app that’s harmless. In an app that already depends on Guzzle 8 features, Composer will refuse instead.

The last step, installing the Node packages, crashed on my box:

   Symfony\Component\Process\Exception\RuntimeException

  TTY mode requires /dev/tty to be read/writable.

I ran it over a non-interactive shell, and the installer wants a TTY for its npm step. Everything before it had already finished. Running the two commands it wanted to run fixes it, and that’s also what you’d do in a provisioning script (or pass --without-node):

$ npm install --save-dev laravel-echo pusher-js --ignore-scripts
$ npm run build

This is what the installer added to .env. The ID, key and secret are random values it generated for this throwaway app:

BROADCAST_CONNECTION=reverb
REVERB_APP_ID=744204
REVERB_APP_KEY=aot7kagnt0ep0wqb2hkl
REVERB_APP_SECRET=9dhbsmmszlbtdkyj5kmz
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

It also created config/reverb.php, routes/channels.php, registered the channels file in bootstrap/app.php, and wrote resources/js/echo.js:

import Echo from 'laravel-echo';

import Pusher from 'pusher-js';
window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'reverb',
    key: import.meta.env.VITE_REVERB_APP_KEY,
    wsHost: import.meta.env.VITE_REVERB_HOST,
    wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Compare that to the 2018 diff above: broadcaster: 'reverb' instead of 'pusher', the same key and wsHost/wsPort idea, and the values now come from Vite instead of window globals.

Running reverb:start and sending an event#

To test it end to end, I added a public broadcast event:

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;

class CheckFinished implements ShouldBroadcastNow
{
    public function __construct(public string $check, public string $result) {}

    public function broadcastOn(): Channel
    {
        return new Channel('checks');
    }
}

I used ShouldBroadcastNow so the test doesn’t need a queue worker. In a real app you’d use ShouldBroadcast and let your queue handle it.

The client side is one line on top of the generated echo.js:

window.Echo.channel('checks').listen('CheckFinished', (e) => {
    console.log(e.check, e.result);
});

By default reverb:start listens on 0.0.0.0:8080, so on every interface. I want Nginx in front of it, so I bind it to localhost. With --debug you see every frame:

$ php artisan reverb:start --host=127.0.0.1 --port=8080 --debug

 INFO Starting server on 127.0.0.1:8080 (localhost).

 Connection Established .. 554109161.330232509
 Message Received .. 554109161.330232509

 1 {
 2 "event": "pusher:subscribe",
 3 "data": {
 4 "auth": "",
 5 "channel": "checks"
 6 }
 7 }

 Message Handled .. 554109161.330232509
 Broadcasting To .. checks

 1 {
 2 "event": "App\\Events\\CheckFinished",
 3 "data": {
 4 "check": "uptime",
 5 "result": "succeeded"
 6 },
 7 "channel": "checks"
 8 }

The pusher:subscribe event is the Pusher protocol at work. The listening Echo client printed {"check":"uptime","result":"succeeded"}.

Note the two host/port pairs in Reverb. REVERB_SERVER_HOST and REVERB_SERVER_PORT (or --host and --port) are where the server listens. REVERB_HOST and REVERB_PORT are where Laravel and the browser connect to, so in production that’s your public hostname on port 443.

Supervisor, Nginx and open files#

The production setup is the same pattern as the one I used for laravel-websockets : Supervisor keeps the process alive, Nginx terminates TLS.

A Supervisor program for Reverb:

[supervisord]
minfds=10000

[program:reverb]
command=/usr/bin/php /var/www/example.com/artisan reverb:start --host=127.0.0.1 --port=8080
numprocs=1
autostart=true
autorestart=true
stopwaitsecs=30
user=www-data

minfds raises the number of file descriptors Supervisor can open. Every websocket connection is an open file, so the default limit is the first one you’ll hit. I wrote about that for laravel-websockets as well, in increasing the number of open files for jobs managed by supervisord .

After a deploy, Reverb is still running your old code. php artisan reverb:restart tells it to stop gracefully, and Supervisor starts it again. I tested this with Supervisor 4.3.0 and its log shows exactly that:

INFO exited: reverb (exit status 0; expected)
INFO spawned: 'reverb' with pid 1399742

The Nginx side is a reverse proxy with the websocket Upgrade headers, like in 2018. Reverb accepts websocket connections on /app and handles API requests on /apps, so proxy both. A location / covers them:

location / {
    proxy_pass         http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header   Host $http_host;
    proxy_set_header   X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header   X-Forwarded-Proto $scheme;
    proxy_set_header   Upgrade $http_upgrade;
    proxy_set_header   Connection "Upgrade";
    proxy_read_timeout 60;
}

Put that inside your TLS server block. I validated it with nginx -t on nginx 1.31.7 and ran a client through it. The Nginx access log shows the handshake: "GET /app/aot7kagnt0ep0wqb2hkl?protocol=7&client=js&version=8.6.0&flash=false HTTP/1.1" 101.

One more limit, from the Reverb docs and not something I load tested: Reverb’s event loop uses stream_select by default, and stream_select is typically limited to 1,024 open files. Above roughly 1,000 concurrent connections, install the uv PECL extension and Reverb switches to it automatically.

Scaling Reverb across servers with Redis#

Redis is back in the picture, for a different job this time. In 2018 Redis sat between Laravel and the echo-server. With Reverb, Redis lets several Reverb servers share messages. A message that lands on one server is published to all the others through Redis pub/sub.

You turn it on with one variable:

REVERB_SCALING_ENABLED=true

Reverb then uses your app’s default Redis connection (REDIS_HOST, REDIS_PORT, …). All Reverb servers need to reach the same central Redis, and you run php artisan reverb:start on each of them behind a load balancer. I only ran a single server for this post, so this part comes from the Reverb docs , not from a test.

What about Pusher and Soketi?#

Because Reverb, laravel-websockets and the Pusher JS client all speak the same protocol, hosted Pusher Channels is still a valid option if you don’t want to run a server yourself. php artisan install:broadcasting --pusher sets that up and BROADCAST_CONNECTION=pusher switches to it.

Soketi is the other self-hosted Pusher-compatible server people used after laravel-websockets. It isn’t archived, but its last release is 1.6.1 from March 2024, and its own README says maintenance and support have been infrequent. For a new Laravel project I’d pick Reverb.

Polling first, Reverb when you need it#

In 2018 I was happy to drop Node and run websockets in PHP. In 2020 Oh Dear dropped the websocket server entirely and hasn’t missed it.

If your use case is “show the latest status every few seconds”, try wire:poll before you add a long-running server to your stack. If it has to be instant, Reverb is the first-party way to do it, and the Supervisor and Nginx setup from 2018 carries over almost unchanged.