Confession: I shipped a product launch two years ago with my realtime features running on Pusher’s free tier, and I found out what the 100-connection limit feels like at about 9:15pm, live, while people were actually using the thing. Notifications just stopped. No error, no crash, the badge counter quietly froze and I spent twenty minutes convinced my queue had died before I checked the Pusher dashboard and saw the connection graph flatlined against the ceiling.
I limped through that night by upgrading the plan from my phone. Then I sat on the Pusher bill for another year because switching felt scary. Last spring I finally moved everything to Laravel Reverb, the first-party WebSocket server that ships with Laravel now. This is the post I wish I’d read before I did it: what actually changed, what didn’t, and the production stuff nobody mentions in the quickstart.
What Reverb actually is, and what it replaced
Reverb is a WebSocket server written in PHP that speaks the Pusher protocol. That last part is the whole trick. Your front end already talks to Pusher through Laravel Echo, so when you swap in Reverb, Echo doesn’t really know the difference. You point it at your own server instead of Pusher’s cloud and most of your client code stays put.
Before Reverb existed I’d used two approaches, both of which I’m a little embarrassed by. The first was polling, because I didn’t want to think about WebSockets at all:
// the really old way: poll every few seconds and hope
setInterval(async () => {
const res = await fetch('/api/notifications/unread');
const { count } = await res.json();
updateBadge(count);
}, 5000);
This works until you have a few thousand tabs open across your users, at which point you’re paying for a steady drumbeat of requests that mostly return “nope, nothing new.” It’s wasteful and it feels laggy because nothing is actually instant. Five seconds is a long time when someone’s staring at a chat window.
The second approach was Pusher, which is genuinely good and I have nothing bad to say about the product. The problem was the pricing model and the fact that my realtime traffic lived on someone else’s server. For a hobby project that’s fine. For something with real users and spiky load, the connection caps start to matter, and you can read the current tiers on the Pusher pricing page yourself.
The before: my Pusher setup
Here’s roughly what my broadcasting config looked like in the Pusher era. Nothing exotic:
// config/broadcasting.php
'connections' => [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
],
And the client, pointed at Pusher’s cloud:
// resources/js/echo.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'pusher',
key: import.meta.env.VITE_PUSHER_APP_KEY,
cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,
forceTLS: true,
});
The events themselves were already framework-standard. An event that implements ShouldBroadcast doesn’t care who’s delivering the message:
class OrderShipped implements ShouldBroadcast
{
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('orders.'.$this->order->user_id)];
}
}
I want to flag that last code block because it’s the reason the migration turned out to be smaller than I’d built it up to be in my head. The events don’t change. The channel authorization doesn’t change. The only thing that changes is who’s holding the socket open.
The after: switching to Reverb
On a fresh Laravel app you can do the whole thing with one command, which still feels slightly fake to me:
php artisan install:broadcasting
# installs Reverb, sets BROADCAST_CONNECTION=reverb,
# and wires up Echo in your resources/js for you
On my older app I did it by hand, which was still short:
composer require laravel/reverb
php artisan reverb:install
The config gains a reverb connection. Notice how much it rhymes with the Pusher block above, because it’s the same protocol underneath:
// config/broadcasting.php
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 443),
'scheme' => env('REVERB_SCHEME', 'https'),
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
],
],
The client moves from “Pusher’s cluster” to “my host.” Same Echo object, different address:
// 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'],
});
Then you start the server:
php artisan reverb:start
That’s it. My OrderShipped event didn’t get touched. My channel auth routes didn’t get touched. I changed two config files and an env block, and the realtime features I’d been renting now ran on the same box as everything else. If you want the canonical version of all this, the Laravel broadcasting docs cover the event and channel side properly.
What actually got better, and what didn’t
The honest version: some things improved a lot, one thing got worse, and a couple of things I expected to be hard turned out fine.
The connection ceiling stopped being a number I worried about. Reverb on a small server handles thousands of concurrent connections without complaining, and the only limit is the hardware I’m already paying for. My realtime cost went from a monthly SaaS line item to roughly zero marginal cost, which for my traffic is the whole argument.
Latency felt the same, maybe a hair better, because the socket server now lives next to the app instead of bouncing through a cloud cluster. I didn’t benchmark this rigorously, so take it as a vibe, not a measurement.
What got worse: I now own an always-on process. Pusher’s whole pitch is that you never think about the server. With Reverb, if the process dies, your realtime dies, and that’s on me. The first week I learned this the dumb way when a deploy didn’t restart the daemon and notifications silently stopped again. Different cause, same frozen badge, same brief panic.
The thing I expected to be painful and wasn’t: queues. Broadcasting an event still goes through your queue worker when the event is queued, so if your jobs aren’t reliable, your realtime isn’t either. I’d already spent a while getting that part trustworthy, which I wrote about in my post on Laravel queue defaults, and that work paid off here directly. If you’re moving to Reverb and your queue setup is shaky, fix that first.
Running it in production without it falling over
The quickstart gets you a server running in your terminal. Production needs three more things, and none of them are hard once you’ve seen them.
First, keep the process alive. php artisan reverb:start needs a supervisor. I use a systemd service that restarts on failure and on boot, so a crashed daemon or a server reboot doesn’t take realtime down with it. This is the single most important step, because it’s the failure I actually hit.
Second, terminate TLS in front of it. Browsers want a secure WebSocket (wss://), so I run Reverb behind Nginx as a reverse proxy with the certificate on Nginx and plain WebSocket traffic on the loopback behind it. The Reverb docs have a sample Nginx block that handles the connection upgrade headers correctly, and I’d copy theirs rather than hand-roll it.
Third, if you outgrow one server, turn on scaling. Reverb can use Redis to share events across multiple Reverb processes, so a message published on one node reaches clients connected to another. You flip REVERB_SCALING_ENABLED=true and point it at Redis. I’m not at that scale on most projects, but it’s reassuring that the path exists and doesn’t involve rewriting anything.
One small gotcha worth saying out loud: your app server and your Reverb server both need to agree on the app key and secret, and if they drift, you get silent auth failures on private channels that look exactly like “nothing is broadcasting.” I lost an afternoon to a stale .env on one of two boxes. Check that first when private channels go quiet.
If you want to try this this week
Spin up a throwaway Laravel app, run php artisan install:broadcasting, and wire one toast notification to a ShouldBroadcast event. Open two browser tabs, trigger the event from a Tinker session, and watch it land in both without a refresh. That single loop took me about fifteen minutes and it’s the moment Reverb stopped feeling like infrastructure I had to be afraid of.
If you’re already on Pusher, do the swap on a branch, not in production at 9pm during a launch. Point your local Echo at Reverb, confirm your existing events still fire, then plan the systemd and Nginx pieces before you ship. The application code barely moves; the operational part is where your attention should go. I help teams untangle exactly this kind of self-hosting decision in my freelance work, and the pattern is almost always the same: the migration is easy, the “who restarts the daemon at 3am” question is the real one.