Real-Time & Broadcasting
Commstate uses Laravel Reverb as its WebSocket server and Laravel Echo on the frontend for real-time, bidirectional communication. Events are broadcast through Redis queues (processed by Horizon) and delivered to connected clients over WebSocket channels.
Architecture Overview
Backend Event (e.g. order created)
│
▼
ShouldBroadcast event dispatched
│
▼
Redis queue (processed by Horizon)
│
▼
Reverb WebSocket server
│
▼
Private channel: user.{id} / tenant.{id}
│
▼
Frontend Echo client receives event
│
▼
React context updates UI in real-time
Stack
| Component | Technology | Role |
|---|---|---|
| WebSocket server | Laravel Reverb | Pusher-protocol WebSocket server on port 8080 |
| Queue processor | Laravel Horizon | Processes BroadcastEvent jobs from Redis |
| Backend client | Laravel Broadcasting | Dispatches events to channels |
| Frontend client | Laravel Echo + pusher-js | Subscribes to channels, receives events |
| Transport | Redis | Queue backend for broadcast jobs |
Channel Architecture
Commstate defines four channel types, all authorized via the central tenant_users table:
| Channel Pattern | Type | Purpose | Auth Check |
|---|---|---|---|
tenant.{tenantId} |
Private | Tenant-wide events (all members) | $user->belongsToTenant($tenantId) |
tenant.{tenantId}.{module} |
Private | Module-scoped events (e.g. workflows, orders) | $user->belongsToTenant($tenantId) |
user.{userId} |
Private | Personal events (notifications) | $user->id === $userId |
presence-tenant.{tenantId} |
Presence | Who's online in a tenant | $user->belongsToTenant($tenantId) |
Channel authorization is defined in routes/channels.php and runs on the central database — the /api/v1/broadcasting/auth endpoint is registered outside InitializeTenancyByRequestData middleware so it can query the tenant_users pivot table directly.
Configuration
Backend
config/broadcasting.php — Sets Reverb as the default broadcaster:
'default' => env('BROADCAST_CONNECTION', 'reverb'),
config/reverb.php — Reverb server configuration (host, port, app credentials, Redis scaling).
Environment variables (.env):
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=commstate
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST=0.0.0.0
REVERB_PORT=8080
REVERB_SCHEME=http
Frontend
Environment variables (baked at build time via NEXT_PUBLIC_*):
NEXT_PUBLIC_REVERB_APP_KEY=your-app-key
NEXT_PUBLIC_REVERB_HOST=localhost
NEXT_PUBLIC_REVERB_PORT=5352
NEXT_PUBLIC_REVERB_SCHEME=http
The Echo client is created in frontend/lib/echo.ts and managed by the EchoProvider context (frontend/contexts/echo-context.tsx), which:
- Connects on authentication (token + tenant available)
- Reconnects when the tenant context switches (super admin switching tenants)
- Disconnects on logout
- Uses dynamic import to avoid SSR issues
Creating Broadcast Events
All tenant-aware events should extend TenantBroadcastEvent:
use App\Broadcasting\TenantBroadcastEvent;
class OrderShipped extends TenantBroadcastEvent
{
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [$this->tenantChannel('orders')];
}
public function broadcastAs(): string
{
return 'order.shipped';
}
protected function broadcastPayload(): array
{
return [
'order_id' => $this->order->id,
'tracking_number' => $this->order->tracking_number,
];
}
}
The TenantBroadcastEvent base class (app/Broadcasting/TenantBroadcastEvent.php):
- Implements
ShouldBroadcast— events are queued, not sent synchronously - Uses the
BroadcastsToTenanttrait which provides:tenantChannel(?string $suffix)— returnsPrivateChannel('tenant.{id}')orPrivateChannel('tenant.{id}.{suffix}')userChannel(int $userId)— returnsPrivateChannel('user.{userId}')globalChannel()— returnsChannel('global')broadcastWith()— automatically mergesbroadcastPayload()with_tenant_idand_timestamp
Listening on the Frontend
useTenantChannel
Subscribe to tenant-scoped or module-scoped events:
import { useTenantChannel } from "@/hooks/use-channel";
// Listen to all tenant events
useTenantChannel(".order.shipped", (data) => {
console.log("Order shipped:", data.order_id);
});
// Listen to module-scoped events
useTenantChannel(".workflow.completed", (data) => {
console.log("Workflow done:", data.execution_id);
}, "workflows");
useUserChannel
Subscribe to personal events (notifications, etc.):
import { useUserChannel } from "@/hooks/use-channel";
useUserChannel(".notification.created", (data) => {
console.log("New notification:", data.notification.title);
});
useTenantPresence
Track who's online in a tenant:
import { useTenantPresence } from "@/hooks/use-channel";
useTenantPresence({
onHere: (members) => setOnlineMembers(members),
onJoining: (member) => addMember(member),
onLeaving: (member) => removeMember(member),
});
Note: Event names use a leading
.(e.g.,.order.shipped) to skip Laravel's default event namespace prefix.
Module Extensibility
Modules can register custom broadcast channels programmatically via the ChannelRegistry singleton:
// In your module's ServiceProvider boot() method:
use App\Broadcasting\ChannelRegistry;
$registry = app(ChannelRegistry::class);
$registry->registerTenantChannel('inventory', function ($user, $tenantId) {
return $user->belongsToTenant($tenantId)
&& $user->hasPermission('products.manage_inventory');
});
Or modules can simply call Broadcast::channel() directly in their service provider.
Docker Setup
Services
| Container | Role | Port |
|---|---|---|
commstate-reverb |
Reverb WebSocket server | 8080 (internal), 5352 (mapped) |
commstate-horizon |
Queue processor for broadcast events | — |
commstate-nginx |
Proxies /app path to Reverb for WebSocket upgrade |
5350 |
Nginx WebSocket Proxy
Nginx proxies WebSocket connections at the /app path (Reverb uses the Pusher protocol endpoint /app/{key}):
location /app {
proxy_pass http://reverb:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600;
proxy_send_timeout 3600;
}
Development
The composer dev script starts Reverb alongside other services:
composer dev # Starts: server, queue, logs, vite, reverb
Key Design Decisions
-
No BroadcastTenancyBootstrapper — Tenant isolation is enforced at the channel naming + authorization level, not by prefixing the broadcast driver. A single Reverb server serves all tenants.
-
Broadcast auth outside tenancy middleware —
/api/v1/broadcasting/authqueries the centraltenant_userstable. If tenancy middleware ran, it would switch to the tenant DB which doesn't have this table. -
Events broadcast via queue —
ShouldBroadcastevents go through the Redis queue (processed by Horizon), keeping request latency low. -
Dynamic import for SSR safety — Echo and Pusher use browser APIs (
window,WebSocket). TheEchoProviderusesimport()to load the Echo module only on the client side.
Troubleshooting
Kubernetes: broadcasts require explicit egress to Reverb
The cluster runs default-deny-egress against every pod, so being allowed to
reach Reverb is a separate permission from Reverb being allowed to listen.
allow-reverb opens ingress on 8080; the senders need a matching egress
rule, which lives in k8s/base/per-ring/network-policies.yaml on allow-api,
allow-horizon and allow-scheduler:
- to:
- podSelector:
matchLabels:
app: reverb
ports:
- port: 8080
Without it, broadcasting fails in a genuinely misleading way, because the
WebSocket keeps working. Clients connect through nginx and the NodePort —
inbound, allowed — so Echo reports connected, the handshake succeeds, and the
browser console looks healthy. Only the outbound POST that publishes an event
is blocked.
The two halves then fail differently:
| Path | Symptom |
|---|---|
ShouldBroadcastNow |
Broadcasts inside the request, so a dropped POST throws — the endpoint returns 500. Starting a widget chat is where this usually shows up. |
Queued (ShouldBroadcast) |
The job fails. No 500, no message delivered, nothing user-facing to explain it. |
The give-away in the api logs:
Pusher error: cURL error 7: Failed to connect to reverb port 8080
for http://reverb:8080/apps/<id>/events
Verify from inside an api pod — a 404 is the healthy answer (it connected;
/ simply has no route). "Could not connect" means the policy is missing:
kubectl exec -n commstate deploy/api -- curl -s -o /dev/null \
-w '%{http_code}\n' http://reverb:8080/
Network policy changes do not ship with a merge
deploy-k8s rolls out images only — it deliberately cannot apply manifests,
because the scoped team-editor ServiceAccount lacks the permissions. A merge
that changes a NetworkPolicy will pass CI, deploy green, and change nothing.
Apply infra manifests explicitly:
bin/k8s-deploy.sh # or:
kubectl apply -n commstate -k k8s/overlays/default
Confirm the rule reached every overlay rather than trusting inheritance:
for o in default rings/stable rings/canary rings/edge; do
echo -n "$o: "
kubectl kustomize "k8s/overlays/$o" | grep -c 'app: reverb'
done
Reverb runs the api image
reverb has no image of its own — it runs reverb:start out of api, the same
way scheduler runs schedule:work out of horizon. It must therefore appear
in the deploy job's pin list, or it sits on a stale :latest indefinitely while
api moves on. A healthy pod and a successful handshake say nothing about whether
the code behind it is current.