Real-Time Shopping Cart Count with Laravel Reverb: Broadcast Cart Updates via WebSockets

TL;DR: Dispatch a CartUpdated event after every cart mutation, broadcast it on a private Reverb channel, and listen with Laravel Echo. The badge updates on every open tab without polling. The three things that actually break this in production are the leading dot in .listen(), calling leave() in a component cleanup, and unprefixed channel names.
A cart badge that only updates on page refresh is a small thing that quietly costs conversions: a customer adds an item in one tab, switches to another, and the cart looks empty. Polling fixes it at the cost of a request every few seconds per visitor. WebSockets fix it properly.
This walks through the whole path — event, channel authorization, Echo subscription, cleanup, and the production concerns nobody mentions until they bite.
Why Reverb instead of Pusher
Reverb is Laravel's first-party WebSocket server, introduced in Laravel 11. It runs on your own machine with no per-message pricing, and it plugs into the same event broadcasting API you already use, so the application code is identical to what you would write for Pusher.
The tradeoff is honest: Pusher is someone else's problem to keep running, Reverb is yours. For a cart badge on a single-server store that is a good trade. For a globally distributed app where you do not want to run a socket process, it may not be.
Step 1 — Install Reverb
php artisan install:broadcasting
This installs Reverb, publishes the config, and writes your keys. Your .env gains:
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=my-app-id
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
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}"
Start it locally with debug output so you can watch subscriptions succeed or fail:
php artisan reverb:start --debug
Step 2 — The CartUpdated event
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CartUpdated implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public readonly string $cartToken,
public readonly int $itemCount,
public readonly float $total,
public readonly ?int $userId = null,
) {}
public function broadcastOn(): array
{
return [new PrivateChannel($this->channelName())];
}
public function broadcastAs(): string
{
return 'CartUpdated';
}
public function broadcastWith(): array
{
return [
'item_count' => $this->itemCount,
'total' => $this->total,
];
}
private function channelName(): string
{
return $this->userId
? "cart.user.{$this->userId}"
: "cart.guest.{$this->cartToken}";
}
}
Two details are doing real work here.
The constructor takes scalars, not a model. SerializesModels would re-query the cart when the event is handled, and if the row changed in between you broadcast a number that was never true. Passing the already-computed count means what you send is what you measured.
broadcastWith() ships two fields. The default payload is the whole public object graph. A cart badge needs a count. Sending less is faster and avoids leaking pricing internals to the browser.
ShouldBroadcast or ShouldBroadcastNow?
This choice matters more than it looks.
ShouldBroadcast pushes the broadcast onto your queue. The HTTP response returns immediately and a worker delivers the message. That is correct for anything expensive — but it means the badge updates only when a worker gets to it. If your queue backs up, or the worker is down, the cart badge silently stops updating while the rest of the site looks fine.
ShouldBroadcastNow delivers inline, during the request. The user sees the update immediately and there is no worker to babysit. The cost is that the request now waits on the socket write.
For a cart badge — small payload, latency-sensitive, useless if late — ShouldBroadcastNow is the right call. Reach for the queued version when the payload is large or the work before broadcasting is slow.
Step 3 — Authorize the private channel
A private channel is only private if the callback says no to the right people:
// routes/channels.php
Broadcast::channel('cart.user.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
Broadcast::channel('cart.guest.{token}', function ($user, $token) {
return hash_equals((string) session('cart.token'), (string) $token);
});
Use hash_equals for the guest token rather than ===. It is a secret compared against user input, so compare it in constant time.
Prefix your channel names before you need to
If your application is ever multi-tenant — or might be — put the tenant in the channel name from day one:
Broadcast::channel('{tenant}.cart.user.{userId}', function ($user, $tenant, $userId) {
return app(TenantContext::class)->id() === $tenant
&& (int) $user->id === (int) $userId;
});
Note the callback checks both halves. This is not theoretical: when several tenants share one Reverb instance, an unprefixed cart.user.5 is the same channel for tenant A's user 5 and tenant B's user 5. They receive each other's events. The bug is invisible in single-tenant testing and appears the day you onboard a second customer.
Retrofitting a prefix later means changing every broadcastOn(), every channel authorization, and every client subscription at once. Adding it up front costs nothing.
Step 4 — Fire the event after every mutation
Every path that changes the cart must dispatch, or the badge drifts out of sync — and a badge that is right most of the time is worse than one that is always stale, because nobody learns to distrust it.
public function add(Request $request): JsonResponse
{
$cart = $this->cart->add($request->validated());
CartUpdated::dispatch(
$cart->token,
$cart->items()->sum('qty'),
(float) $cart->total,
$request->user()?->id,
);
return response()->json(['cart' => $cart->fresh()]);
}
The reliable way to guarantee coverage is to dispatch from one place — a service method or a model observer — rather than from each controller action. Every controller that mutates the cart is a controller someone can forget.
Step 5 — Subscribe with Laravel Echo
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,
forceTLS: import.meta.env.VITE_REVERB_SCHEME === 'https',
enabledTransports: ['ws', 'wss'],
});
The leading dot that breaks everything
This is the single most common reason a correct-looking setup receives nothing:
// ❌ silently never fires when the event defines broadcastAs()
window.Echo.private(`cart.user.${userId}`)
.listen('CartUpdated', handleUpdate);
// ✅
window.Echo.private(`cart.user.${userId}`)
.listen('.CartUpdated', handleUpdate);
Without broadcastAs(), Echo expects the fully-qualified class name and namespaces it for you. The moment you define broadcastAs(), the name is literal — and a literal name must be prefixed with a dot so Echo does not namespace it. Nothing errors. The subscription succeeds. The handler simply never runs, which is a far worse failure than a crash.
Authenticating from an API client
Echo authenticates private channels by POSTing to /broadcasting/auth with the session cookie. A decoupled frontend or mobile app has no such cookie — it has a token:
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
authEndpoint: `${import.meta.env.VITE_APP_URL}/api/broadcasting/auth`,
auth: {
headers: {
Authorization: `Bearer ${localStorage.getItem('access_token') ?? ''}`,
},
},
});
Point authEndpoint at an API route guarded by your token middleware. If subscriptions fail with a 403 and the channel callback never runs, this is almost always why — the request arrived unauthenticated, so $user was null before your logic was reached.
Step 6 — Unsubscribe correctly
Here is a bug that only appears once two components listen to the same channel:
useEffect(() => {
if (!window.Echo || !userId) return;
const channel = window.Echo.private(`cart.user.${userId}`);
channel.listen('.CartUpdated', setCartCount);
return () => {
// ✅ stop only our listener
channel.stopListening('.CartUpdated');
// ❌ window.Echo.leave(`cart.user.${userId}`)
// leave() tears down the whole channel — every other
// component listening to it goes silent too.
};
}, [userId]);
The header badge and a cart drawer both subscribe. The drawer closes, its cleanup calls leave(), and the header badge stops updating — with no error anywhere. Use stopListening and leave the channel alone.
One more guard worth having: if the id is still loading, you subscribe to cart.user.undefined, which fails authorization every time.
if (!userId || String(userId).includes('undefined')) return;
Step 7 — REST fallback
WebSockets fail: corporate proxies block them, Reverb restarts during a deploy, phones sleep. The badge should still be correct on load.
// Fetch once on mount, then let the socket keep it fresh.
useEffect(() => {
fetch('/api/cart/count')
.then((r) => r.json())
.then((d) => setCartCount(d.item_count));
}, []);
The socket is an optimisation over a correct initial state, not a replacement for one. This also covers the reconnect gap — Echo re-subscribes automatically, but events broadcast while the socket was down are gone, so refetching on reconnect is worth wiring up too.
Running Reverb in production
Reverb is a long-running process, so it needs a supervisor:
[program:reverb]
command=php /var/www/your-app/artisan reverb:start
autostart=true
autorestart=true
startretries=999
startsecs=10
user=www-data
redirect_stderr=true
stdout_logfile=/var/www/your-app/storage/logs/reverb.log
startretries and startsecs are not padding. Supervisor's defaults are 3 retries and a 1-second success threshold, which means a brief database or Redis blip can burn all three retries in a few seconds and put the program into FATAL — where autorestart=true will not recover it. It stays dead until someone notices. Ask me how I know.
Nginx also needs to pass the upgrade headers through, or the connection never becomes a WebSocket:
location /app {
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;
}
When it does not work
| Symptom | Usual cause |
|---|---|
| Subscribed, handler never fires | Missing leading dot in .listen('.CartUpdated') |
| 403 on subscribe | Auth request has no session or token; check authEndpoint |
| Works, then stops after navigating | A component called leave() instead of stopListening() |
| Updates arrive late or not at all | ShouldBroadcast with a stalled queue worker |
| Users see each other's events | Unprefixed channel names in a multi-tenant app |
| Connection never upgrades | Nginx not passing Upgrade/Connection headers |
Run php artisan reverb:start --debug and watch. Every subscription attempt prints, successful or not, and that log answers most of the table above in seconds.
The cart schema these events broadcast from is covered separately in the complete shopping cart database design.
Frequently Asked Questions
Why does my Laravel Echo listener never fire even though the channel subscribes?
Should a broadcast event use ShouldBroadcast or ShouldBroadcastNow?
Why did my cart badge stop updating after closing another component?
Do I need to prefix Reverb channel names in a multi-tenant app?
How do I authenticate Reverb private channels from a decoupled frontend?
Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.
Was this post helpful?
Reviews & Ratings
Sign in to leave a review.



