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