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 BroadcastsToTenant trait which provides:
    • tenantChannel(?string $suffix) — returns PrivateChannel('tenant.{id}') or PrivateChannel('tenant.{id}.{suffix}')
    • userChannel(int $userId) — returns PrivateChannel('user.{userId}')
    • globalChannel() — returns Channel('global')
    • broadcastWith() — automatically merges broadcastPayload() with _tenant_id and _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

  1. 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.

  2. Broadcast auth outside tenancy middleware — /api/v1/broadcasting/auth queries the central tenant_users table. If tenancy middleware ran, it would switch to the tenant DB which doesn't have this table.

  3. Events broadcast via queue — ShouldBroadcast events go through the Redis queue (processed by Horizon), keeping request latency low.

  4. Dynamic import for SSR safety — Echo and Pusher use browser APIs (window, WebSocket). The EchoProvider uses import() 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.