{"id":320,"date":"2026-06-12T13:02:21","date_gmt":"2026-06-12T13:02:21","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/laravel-reverb-in-production-what-i-pulled-out-of-pusher\/"},"modified":"2026-06-12T13:02:21","modified_gmt":"2026-06-12T13:02:21","slug":"laravel-reverb-in-production-what-i-pulled-out-of-pusher","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/laravel-reverb-in-production-what-i-pulled-out-of-pusher\/","title":{"rendered":"Laravel Reverb in Production: What I Pulled Out of Pusher"},"content":{"rendered":"<p>Confession: I shipped a product launch two years ago with my realtime features running on Pusher&rsquo;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.<\/p>\n<p>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 <a href=\"https:\/\/laravel.com\/docs\/12.x\/reverb\" rel=\"nofollow noopener\" target=\"_blank\">Laravel Reverb<\/a>, the first-party WebSocket server that ships with Laravel now. This is the post I wish I&rsquo;d read before I did it: what actually changed, what didn&rsquo;t, and the production stuff nobody mentions in the quickstart.<\/p>\n<h2 id=\"what-reverb-actually-is-and-what-it-replaced\">What Reverb actually is, and what it replaced<\/h2>\n<p>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&rsquo;t really know the difference. You point it at your own server instead of Pusher&rsquo;s cloud and most of your client code stays put.<\/p>\n<p>Before Reverb existed I&rsquo;d used two approaches, both of which I&rsquo;m a little embarrassed by. The first was polling, because I didn&rsquo;t want to think about WebSockets at all:<\/p>\n<pre><code class=\"language-js\">\/\/ the really old way: poll every few seconds and hope\nsetInterval(async () =&gt; {\n  const res = await fetch('\/api\/notifications\/unread');\n  const { count } = await res.json();\n  updateBadge(count);\n}, 5000);\n<\/code><\/pre>\n<p>This works until you have a few thousand tabs open across your users, at which point you&rsquo;re paying for a steady drumbeat of requests that mostly return &ldquo;nope, nothing new.&rdquo; It&rsquo;s wasteful and it feels laggy because nothing is actually instant. Five seconds is a long time when someone&rsquo;s staring at a chat window.<\/p>\n<p>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&rsquo;s server. For a hobby project that&rsquo;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 <a href=\"https:\/\/pusher.com\/channels\/pricing\/\" rel=\"nofollow noopener\" target=\"_blank\">Pusher pricing page<\/a> yourself.<\/p>\n<h2 id=\"the-before-my-pusher-setup\">The before: my Pusher setup<\/h2>\n<p>Here&rsquo;s roughly what my broadcasting config looked like in the Pusher era. Nothing exotic:<\/p>\n<pre><code class=\"language-php\">\/\/ config\/broadcasting.php\n'connections' =&gt; [\n    'pusher' =&gt; [\n        'driver' =&gt; 'pusher',\n        'key' =&gt; env('PUSHER_APP_KEY'),\n        'secret' =&gt; env('PUSHER_APP_SECRET'),\n        'app_id' =&gt; env('PUSHER_APP_ID'),\n        'options' =&gt; [\n            'cluster' =&gt; env('PUSHER_APP_CLUSTER'),\n            'useTLS' =&gt; true,\n        ],\n    ],\n],\n<\/code><\/pre>\n<p>And the client, pointed at Pusher&rsquo;s cloud:<\/p>\n<pre><code class=\"language-js\">\/\/ resources\/js\/echo.js\nimport Echo from 'laravel-echo';\nimport Pusher from 'pusher-js';\n\nwindow.Pusher = Pusher;\nwindow.Echo = new Echo({\n  broadcaster: 'pusher',\n  key: import.meta.env.VITE_PUSHER_APP_KEY,\n  cluster: import.meta.env.VITE_PUSHER_APP_CLUSTER,\n  forceTLS: true,\n});\n<\/code><\/pre>\n<p>The events themselves were already framework-standard. An event that implements <code>ShouldBroadcast<\/code> doesn&rsquo;t care who&rsquo;s delivering the message:<\/p>\n<pre><code class=\"language-php\">class OrderShipped implements ShouldBroadcast\n{\n    public function __construct(public Order $order) {}\n\n    public function broadcastOn(): array\n    {\n        return [new PrivateChannel('orders.'.$this-&gt;order-&gt;user_id)];\n    }\n}\n<\/code><\/pre>\n<p>I want to flag that last code block because it&rsquo;s the reason the migration turned out to be smaller than I&rsquo;d built it up to be in my head. The events don&rsquo;t change. The channel authorization doesn&rsquo;t change. The only thing that changes is who&rsquo;s holding the socket open.<\/p>\n<h2 id=\"the-after-switching-to-reverb\">The after: switching to Reverb<\/h2>\n<p>On a fresh Laravel app you can do the whole thing with one command, which still feels slightly fake to me:<\/p>\n<pre><code class=\"language-bash\">php artisan install:broadcasting\n# installs Reverb, sets BROADCAST_CONNECTION=reverb,\n# and wires up Echo in your resources\/js for you\n<\/code><\/pre>\n<p>On my older app I did it by hand, which was still short:<\/p>\n<pre><code class=\"language-bash\">composer require laravel\/reverb\nphp artisan reverb:install\n<\/code><\/pre>\n<p>The config gains a <code>reverb<\/code> connection. Notice how much it rhymes with the Pusher block above, because it&rsquo;s the same protocol underneath:<\/p>\n<pre><code class=\"language-php\">\/\/ config\/broadcasting.php\n'reverb' =&gt; [\n    'driver' =&gt; 'reverb',\n    'key' =&gt; env('REVERB_APP_KEY'),\n    'secret' =&gt; env('REVERB_APP_SECRET'),\n    'app_id' =&gt; env('REVERB_APP_ID'),\n    'options' =&gt; [\n        'host' =&gt; env('REVERB_HOST'),\n        'port' =&gt; env('REVERB_PORT', 443),\n        'scheme' =&gt; env('REVERB_SCHEME', 'https'),\n        'useTLS' =&gt; env('REVERB_SCHEME', 'https') === 'https',\n    ],\n],\n<\/code><\/pre>\n<p>The client moves from &ldquo;Pusher&rsquo;s cluster&rdquo; to &ldquo;my host.&rdquo; Same Echo object, different address:<\/p>\n<pre><code class=\"language-js\">\/\/ resources\/js\/echo.js\nimport Echo from 'laravel-echo';\nimport Pusher from 'pusher-js';\n\nwindow.Pusher = Pusher;\nwindow.Echo = new Echo({\n  broadcaster: 'reverb',\n  key: import.meta.env.VITE_REVERB_APP_KEY,\n  wsHost: import.meta.env.VITE_REVERB_HOST,\n  wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,\n  wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,\n  forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',\n  enabledTransports: ['ws', 'wss'],\n});\n<\/code><\/pre>\n<p>Then you start the server:<\/p>\n<pre><code class=\"language-bash\">php artisan reverb:start\n<\/code><\/pre>\n<p>That&rsquo;s it. My <code>OrderShipped<\/code> event didn&rsquo;t get touched. My channel auth routes didn&rsquo;t get touched. I changed two config files and an env block, and the realtime features I&rsquo;d been renting now ran on the same box as everything else. If you want the canonical version of all this, the <a href=\"https:\/\/laravel.com\/docs\/12.x\/broadcasting\" rel=\"nofollow noopener\" target=\"_blank\">Laravel broadcasting docs<\/a> cover the event and channel side properly.<\/p>\n<h2 id=\"what-actually-got-better-and-what-didnt\">What actually got better, and what didn&rsquo;t<\/h2>\n<p>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.<\/p>\n<p>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&rsquo;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.<\/p>\n<p>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&rsquo;t benchmark this rigorously, so take it as a vibe, not a measurement.<\/p>\n<p>What got worse: I now own an always-on process. Pusher&rsquo;s whole pitch is that you never think about the server. With Reverb, if the process dies, your realtime dies, and that&rsquo;s on me. The first week I learned this the dumb way when a deploy didn&rsquo;t restart the daemon and notifications silently stopped again. Different cause, same frozen badge, same brief panic.<\/p>\n<p>The thing I expected to be painful and wasn&rsquo;t: queues. Broadcasting an event still goes through your queue worker when the event is queued, so if your jobs aren&rsquo;t reliable, your realtime isn&rsquo;t either. I&rsquo;d already spent a while getting that part trustworthy, which I wrote about in <a href=\"https:\/\/abrarqasim.com\/blog\/laravel-queues-2026-defaults-i-stopped-trusting-in-production\" rel=\"noopener\">my post on Laravel queue defaults<\/a>, and that work paid off here directly. If you&rsquo;re moving to Reverb and your queue setup is shaky, fix that first.<\/p>\n<h2 id=\"running-it-in-production-without-it-falling-over\">Running it in production without it falling over<\/h2>\n<p>The quickstart gets you a server running in your terminal. Production needs three more things, and none of them are hard once you&rsquo;ve seen them.<\/p>\n<p>First, keep the process alive. <code>php artisan reverb:start<\/code> needs a supervisor. I use a systemd service that restarts on failure and on boot, so a crashed daemon or a server reboot doesn&rsquo;t take realtime down with it. This is the single most important step, because it&rsquo;s the failure I actually hit.<\/p>\n<p>Second, terminate TLS in front of it. Browsers want a secure WebSocket (<code>wss:\/\/<\/code>), 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&rsquo;d copy theirs rather than hand-roll it.<\/p>\n<p>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 <code>REVERB_SCALING_ENABLED=true<\/code> and point it at Redis. I&rsquo;m not at that scale on most projects, but it&rsquo;s reassuring that the path exists and doesn&rsquo;t involve rewriting anything.<\/p>\n<p>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 &ldquo;nothing is broadcasting.&rdquo; I lost an afternoon to a stale <code>.env<\/code> on one of two boxes. Check that first when private channels go quiet.<\/p>\n<h2 id=\"if-you-want-to-try-this-this-week\">If you want to try this this week<\/h2>\n<p>Spin up a throwaway Laravel app, run <code>php artisan install:broadcasting<\/code>, and wire one toast notification to a <code>ShouldBroadcast<\/code> 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&rsquo;s the moment Reverb stopped feeling like infrastructure I had to be afraid of.<\/p>\n<p>If you&rsquo;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 <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">freelance work<\/a>, and the pattern is almost always the same: the migration is easy, the &ldquo;who restarts the daemon at 3am&rdquo; question is the real one.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I moved my realtime features off Pusher to self-hosted Laravel Reverb. Here&#8217;s the actual code diff, what got better, and the production gotchas that bit me.<\/p>\n","protected":false},"author":2,"featured_media":319,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I moved my realtime features off Pusher to self-hosted Laravel Reverb. Here's the actual code diff, what got better, and the production gotchas that bit me.","rank_math_focus_keyword":"laravel reverb","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[173,52],"tags":[362,56,360,53,361,231],"class_list":["post-320","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-laravel","category-php","tag-broadcasting","tag-laravel","tag-laravel-reverb","tag-php","tag-realtime","tag-websockets"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/320","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=320"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/320\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/319"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=320"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=320"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=320"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}