Real-time stack
YumKiosk is a real-time product — the agent sees a new session within a second of the customer tapping Order, the kiosk sees the cart update as the agent builds it, the owner dashboard sees kiosks go online and offline live. This is powered by Laravel Reverb, a first-party WebSocket server that speaks the Pusher protocol, with a polling fallback for environments where WebSockets don't work.
The architecture
[ Client (agent / owner / kiosk) ]
|
┌────────┴────────┐
| |
▼ ▼
WebSocket HTTP long-poll
(Reverb) (Laravel API)
| |
└────────┬────────┘
▼
[ Laravel app ]
|
▼
[ MySQL + Redis ]
Reverb is a separate process (php artisan reverb:start) running on 127.0.0.1:8080, proxied by nginx at agent.yumkiosk.com/ws/. Every real-time event is dispatched by the Laravel app via broadcast(new SomeEvent(...)), which Reverb then fans out to all subscribers of the relevant channel.
Channels
YumKiosk uses three channel types:
Private channels
Prefixed with private-. Require authentication. Used for:
- private-agent.{id} — events for a specific agent (new incoming session, status changes).
- private-kiosk.{id} — events for a specific kiosk (order updates, session state transitions).
- private-session.{id} — events scoped to a single session.
Authentication happens via POST /broadcasting/auth before subscription — the client sends the channel name and their auth cookie, and the Laravel app returns a signed token the client passes to Reverb.
Presence channels
Prefixed with presence-. Also require authentication, and additionally publish a list of who's currently connected. Used for:
- presence-owner.{id} — the owner's main dashboard, tracking which agents are online and which kiosks are connected. When the owner loads the page, they see a live roster.
Public channels
Prefixed with nothing special. Anyone can subscribe. Currently unused in YumKiosk, but reserved for future public-facing dashboards.
Events
A few key events that drive the UX:
- IncomingSession — fired when a kiosk starts a session. Sent to
private-agent.{id}for every eligible agent. The agent dashboard pops up the card. - SessionAccepted — fired when an agent accepts. Sent to
private-kiosk.{id}so the kiosk transitions to the live-session state, and toprivate-agent.{id}for the other agents so their card is removed from the queue. - OrderUpdated — fired whenever the cart changes. Sent to
private-session.{id}so both the kiosk and the agent stay in sync. - AgentStatusChanged — fired when an agent toggles Available/Busy/Offline. Sent to
presence-owner.{id}for the owner dashboard. - KioskPaired, KioskOnline, KioskOffline — kiosk lifecycle events. Sent to
presence-owner.{id}.
Each event has a payload, usually a Laravel Resource class that serializes the relevant model.
Polling fallback
Some environments can't use WebSockets — captive portals, corporate firewalls, old Android tablets with outdated Chrome. For these, we fall back to long-polling:
- Kiosks poll
/api/public/sessions/{id}/statusevery 2 seconds while waiting for a session to be accepted, and/api/public/sessions/{id}/orderevery 2 seconds during an active session. - Agent dashboards poll
/api/sessions/incomingevery 3 seconds when the WebSocket isn't connected.
The polling fallback is automatic — the client library detects WebSocket connection failure and starts polling without user intervention. There's a small "Connection: WebSocket" or "Connection: Polling" indicator in the corner of the debug view so developers can tell which mode they're in.
Polling is obviously worse for battery, latency, and server load, so we treat it as a last resort. Under normal conditions, 95%+ of clients run over WebSocket.
Scaling
Reverb is single-process by default, but it supports horizontal scaling via a Redis pub/sub bridge. When we outgrow a single Reverb instance, we'll add more instances behind a load balancer and flip the bridge on. The application code doesn't change — events still broadcast the same way.
For now, a single Reverb process comfortably handles several thousand concurrent connections, which is well above where we are.
Connection limits and timeouts
Reverb drops idle connections after 120 seconds of no activity. Clients send a ping every 30 seconds to keep their connection alive. If a ping fails, the client reconnects with exponential backoff up to 30 seconds.
During a reconnect, any events that were broadcast are not queued — the client misses them and has to re-sync from the REST API when it reconnects. This is fine for live dashboards but means one-shot events (like an incoming session card) can be missed during a reconnect. The polling fallback catches these cases.