Skip to main content
Vito Business OS uses Laravel Reverb as the WebSocket server and Laravel Echo as the browser client. Events broadcast from the server are received by connected clients instantly, without polling.

Architecture

HTTP Request


Laravel Queue Worker
    │  dispatches broadcast event

Laravel Reverb (WebSocket server, port 8080)
    │  pushes to subscribed channels

Laravel Echo (browser)
    │  calls your React listener

UI update
Broadcast events are queued — they never block the HTTP response. The queue worker (queue:work) deserializes the event and forwards it to Reverb, which fans it out to all subscribers on the target channel.

Channel topology

All channel names and their authorization logic live in a single file — ChannelRegistry — to keep naming consistent across events and authorization rules.
Channel patternAccessPurpose
tenant.{id}Any authenticated user belonging to the tenantOrder events (paid, cancelled, shipped, refunded)
tenant.{id}.adminsUsers with role seller or super_adminLow-stock alerts, new login security alerts
user.{id}The user themselvesPersonal notifications (login alert, profile update)
order.{id}The customer who placed the orderCustomer-facing order tracking

Authorization (routes/channels.php)

use App\Broadcasting\ChannelRegistry;
use Illuminate\Support\Facades\Broadcast;

// Tenant channel — any member of the tenant
Broadcast::channel(ChannelRegistry::TENANT_CHANNEL_PATTERN, function ($user, $id) {
    return $user->tenant_id === (int) $id;
});

// Admin-only channel — seller or super_admin only
Broadcast::channel(ChannelRegistry::TENANT_ADMIN_CHANNEL_PATTERN, function ($user, $id) {
    if ($user->tenant_id !== (int) $id) {
        return false;
    }
    return in_array($user->role, ['seller', 'super_admin'], true);
});

// User channel — the user themselves
Broadcast::channel(ChannelRegistry::USER_CHANNEL_PATTERN, function ($user, $id) {
    return $user->id === (int) $id;
});

// Order channel — the customer who placed the order
Broadcast::channel(ChannelRegistry::ORDER_CHANNEL_PATTERN, function ($user, $id) {
    $customerId = \App\Models\Order::where('id', $id)
        ->where('tenant_id', $user->tenant_id)
        ->value('customer_id');

    return $customerId !== null && $user->id === $customerId;
});
Never reference channel name strings directly in event classes. Always use ChannelRegistry::tenant(), ChannelRegistry::tenantAdmins(), ChannelRegistry::user(), or ChannelRegistry::order(). This ensures the channel name in the event and the authorization rule always match.

Broadcast events

All events live in app/Events/Broadcast/ and implement BroadcastableNotification.

OrderPaidBroadcast

Broadcast name: order.paid. Sent to tenant.{id}. Payload includes order number, formatted amount, payment method, and timestamp.

OrderCancelledBroadcast

Broadcast name: order.cancelled. Sent to tenant.{id}. Payload includes reason and a should_refund boolean.

OrderShippedBroadcast

Broadcast name: order.shipped. Sent to tenant.{id}. Payload includes carrier, tracking number, and estimated delivery.

OrderRefundedBroadcast

Broadcast name: order.refunded. Sent to tenant.{id}. Payload distinguishes partial vs. full refunds.

LowStockBroadcast

Broadcast name: inventory.stock.low. Sent to tenant.{id}.admins. Payload includes severity, current stock, and threshold.

NewLoginDetectedBroadcast

Broadcast name: security.login.new. Sent to both tenant.{id}.admins and user.{id}. Payload includes IP address and parsed device string.

BusinessProfileUpdated

Broadcast name: business.profile.updated. Sent to user.{id}. Contains pre-computed profile payload with no DB queries on broadcast.

Example event class

// app/Events/Broadcast/OrderPaidBroadcast.php

public function broadcastOn(): array
{
    return [ChannelRegistry::tenant($this->tenantId)];
}

public function broadcastAs(): string
{
    return 'order.paid';
}

public function broadcastWith(): array
{
    return [
        '_version'         => self::PAYLOAD_VERSION,
        '_timestamp'       => (new DateTimeImmutable)->format(DateTimeImmutable::ATOM),
        'title'            => __('notifications.order_paid.title', ['order' => $this->orderNumber]),
        'message'          => __('notifications.order_paid.message', [
            'amount' => $this->formatMoney($this->amountCents, $this->currency),
            'order'  => $this->orderNumber,
        ]),
        'status'           => 'paid',
        'icon'             => 'check-circle',
        'tenant_id'        => $this->tenantId,
        'order_id'         => $this->orderId,
        'order_number'     => $this->orderNumber,
        'amount_formatted' => $this->formatMoney($this->amountCents, $this->currency),
        'payment_method'   => $this->paymentMethod,
        'paid_at'          => $this->paidAt->format(DateTimeImmutable::ATOM),
    ];
}
All broadcast events set $tries = 1 and $timeout = 30 seconds, and expire after 60 seconds via retryUntil(). Stale events are discarded rather than retried.

Environment configuration

Add these variables to your .env file:
BROADCAST_CONNECTION=reverb

REVERB_APP_ID=your-app-id
REVERB_APP_KEY=your-app-key
REVERB_APP_SECRET=your-app-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http

# Exposed to Vite / frontend
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
For production, change REVERB_PORT to 443, REVERB_SCHEME to https, and set REVERB_HOST to your domain.

Frontend: Laravel Echo

Echo is initialized in resources/js/bootstrap.js and mounted on window.Echo:
// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

const reverbScheme = (import.meta.env.VITE_REVERB_SCHEME ?? 'https').toLowerCase().trim();

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 ?? 80,
    wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
    forceTLS: reverbScheme === 'https',
    enabledTransports: ['ws', 'wss'],
});
Reverb uses the Pusher wire protocol. The broadcaster: 'reverb' option and pusher-js dependency are both required even though you are not using Pusher’s hosted service.

Listening to events in React

import { useEffect } from 'react';

function useOrderPaid(tenantId: number, onPaid: (data: unknown) => void) {
    useEffect(() => {
        const channel = window.Echo
            .private(`tenant.${tenantId}`)
            .listen('.order.paid', (data: unknown) => {
                onPaid(data);
            });

        return () => {
            channel.stopListening('.order.paid');
            window.Echo.leave(`tenant.${tenantId}`);
        };
    }, [tenantId, onPaid]);
}
Event names registered with broadcastAs() must be prefixed with a . when passed to .listen(). The dot tells Echo the name is already fully qualified and should not be namespaced.
Listening to the admin-only channel for security alerts:
window.Echo
    .private(`tenant.${tenantId}.admins`)
    .listen('.security.login.new', (data) => {
        showAlert(`New login from ${data.ip_address} on ${data.device}`);
    });

Production setup

Start the Reverb server locally:
php artisan reverb:start
Start the queue worker:
php artisan queue:work redis --queue=exports,notifications,default
Reverb listens on ws://localhost:8080 by default.

Reverb scaling

For deployments running multiple PHP processes or servers, Reverb supports Redis-backed horizontal scaling:
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
When enabled, Reverb uses Redis pub/sub to fan events across all Reverb instances.

Build docs developers (and LLMs) love