Build a Real-Time Dashboard in Laravel + Vue 3 + Reverb: The Complete Tutorial Nobody Has Written Yet

WebSocket connection in Vue, broadcasting from Laravel, live chart updates with Chart.js, presence channels for online user indicators, reconnection handling, and the authentication flow that makes it all secure — a full-stack real-time feature from zero to production.


Most real-time tutorials stop at “here’s how to broadcast an event and listen to it.” That’s the easy part. What they skip: how to make the WebSocket connection survive network interruptions, how to authenticate private and presence channels correctly, how to update a Chart.js chart in real time without causing memory leaks, how to show which users are currently viewing the dashboard, and what the production deployment looks like for Reverb specifically. This post covers all of it — a complete project dashboard that shows live metrics, updates charts as events arrive, indicates who’s online, and handles disconnections gracefully.


What We’re Building

A project analytics dashboard with four real-time features:

1. Live metrics panel
   → Task completion count, active user count, queue depth
   → Updates in real time when data changes
   → Animated number transitions on update

2. Live activity feed
   → Shows team events as they happen (task created, comment added, status changed)
   → Newest items appear at the top
   → Maximum 50 items displayed (older ones drop off)

3. Live chart
   → Task completions per hour, last 24 hours
   → Chart.js line chart updates as new completions arrive
   → No page reload required

4. Online user presence
   → Shows who's currently viewing the dashboard
   → Avatars appear/disappear as users join/leave
   → "3 people viewing" indicator

Backend Setup: Laravel Reverb

composer require laravel/reverb
php artisan reverb:install

reverb:install publishes the config, adds environment variables, and installs the @laravel/echo and pusher-js npm packages.

# .env
BROADCAST_CONNECTION=reverb

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

# In production:
# REVERB_HOST=reverb.yourdomain.com
# REVERB_PORT=443
# REVERB_SCHEME=https
// config/broadcasting.php
'connections' => [
    'reverb' => [
        'driver'  => 'reverb',
        'key'     => env('REVERB_APP_KEY'),
        'secret'  => env('REVERB_APP_SECRET'),
        'app_id'  => env('REVERB_APP_ID'),
        'options' => [
            'host'   => env('REVERB_HOST', '0.0.0.0'),
            'port'   => env('REVERB_PORT', 8080),
            'scheme' => env('REVERB_SCHEME', 'http'),
            'useTLS' => env('REVERB_SCHEME', 'http') === 'https',
        ],
    ],
],

Enable broadcasting routes for channel authorization:

// bootstrap/app.php
->withRouting(
    web: __DIR__.'/../routes/web.php',
    api: __DIR__.'/../routes/api.php',
    channels: __DIR__.'/../routes/channels.php', // ← add this
    commands: __DIR__.'/../routes/console.php',
)

The Events

Three events drive the dashboard:

// app/Events/Dashboard/MetricsUpdated.php
namespace App\Events\Dashboard;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MetricsUpdated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly int    $projectId,
        public readonly array  $metrics,
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("project.{$this->projectId}.dashboard"),
        ];
    }

    public function broadcastAs(): string
    {
        return 'metrics.updated';
    }

    public function broadcastWith(): array
    {
        return [
            'metrics'    => $this->metrics,
            'updated_at' => now()->toIso8601String(),
        ];
    }
}
// app/Events/Dashboard/ActivityOccurred.php
class ActivityOccurred implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly int    $projectId,
        public readonly array  $activity,
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("project.{$this->projectId}.dashboard"),
        ];
    }

    public function broadcastAs(): string
    {
        return 'activity.occurred';
    }

    public function broadcastWith(): array
    {
        return [
            'id'          => $this->activity['id'] ?? uniqid(),
            'type'        => $this->activity['type'],
            'description' => $this->activity['description'],
            'user'        => $this->activity['user'],
            'occurred_at' => now()->toIso8601String(),
        ];
    }
}
// app/Events/Dashboard/TaskCompleted.php
class TaskCompleted implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public readonly int    $projectId,
        public readonly string $taskTitle,
        public readonly int    $userId,
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel("project.{$this->projectId}.dashboard"),
        ];
    }

    public function broadcastAs(): string
    {
        return 'task.completed';
    }
}

Channel Authorization

Private and presence channels require authorization. Unauthenticated or unauthorized users cannot subscribe.

// routes/channels.php
use App\Models\Project;

// Private channel — any member of the project can subscribe
Broadcast::channel('project.{projectId}.dashboard', function ($user, int $projectId) {
    $project = Project::find($projectId);

    if (!$project) return false;

    // User must be a member of the project
    return $project->members()->where('user_id', $user->id)->exists();
});

// Presence channel — same authorization, but returns user data for online indicators
Broadcast::channel('project.{projectId}.presence', function ($user, int $projectId) {
    $project = Project::find($projectId);

    if (!$project) return false;

    if (!$project->members()->where('user_id', $user->id)->exists()) {
        return false;
    }

    // Return user data — this becomes available to all presence channel subscribers
    return [
        'id'         => $user->id,
        'name'       => $user->name,
        'avatar_url' => $user->avatar_url,
    ];
});

The authorization endpoint URL needs to be registered:

// routes/api.php (or web.php)
Route::middleware('auth:sanctum')->group(function () {
    Broadcast::routes(['middleware' => ['auth:sanctum']]);
});

Broadcasting From the Application

Events are dispatched from Observers, Listeners, or wherever the application state changes:

// app/Observers/TaskObserver.php
class TaskObserver
{
    public function updated(Task $task): void
    {
        if ($task->wasChanged('status') && $task->status === 'completed') {
            // Broadcast task completion
            TaskCompleted::dispatch(
                $task->project_id,
                $task->title,
                auth()->id() ?? $task->updated_by,
            );

            // Update and broadcast the metrics
            $this->broadcastMetrics($task->project_id);

            // Broadcast the activity
            ActivityOccurred::dispatch($task->project_id, [
                'type'        => 'task_completed',
                'description' => "\"{$task->title}\" was completed",
                'user'        => [
                    'id'   => auth()->id(),
                    'name' => auth()->user()?->name,
                ],
            ]);
        }
    }

    private function broadcastMetrics(int $projectId): void
    {
        $project = Project::find($projectId);
        if (!$project) return;

        MetricsUpdated::dispatch($projectId, [
            'tasks_completed'  => $project->tasks()->where('status', 'completed')->count(),
            'tasks_total'      => $project->tasks()->count(),
            'active_members'   => $project->members()->count(),
            'completion_rate'  => $project->completionRate(),
        ]);
    }
}
// Register the observer in AppServiceProvider::boot()
Task::observe(TaskObserver::class);

Frontend: Vue 3 Composable for WebSocket Connection

The Echo connection lives in a composable that manages the connection lifecycle:

// composables/useEcho.ts
import { ref, onUnmounted } from 'vue'
import Echo from 'laravel-echo'
import Pusher from 'pusher-js'

// Global Echo instance — shared across all composables
let echoInstance: Echo | null = null

function createEchoInstance(): Echo {
    return 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 ?? 8080,
        wssPort:       import.meta.env.VITE_REVERB_PORT ?? 443,
        forceTLS:      (import.meta.env.VITE_REVERB_SCHEME ?? 'http') === 'https',
        enabledTransports: ['ws', 'wss'],

        // Authentication for private and presence channels
        authEndpoint:  '/broadcasting/auth',
        auth: {
            headers: {
                Authorization: `Bearer ${getAuthToken()}`,
                'X-CSRF-TOKEN':  getMetaContent('csrf-token'),
            },
        },
    })
}

function getAuthToken(): string {
    // Get from localStorage, cookie, or your auth store
    return localStorage.getItem('auth_token') ?? ''
}

function getMetaContent(name: string): string {
    return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') ?? ''
}

export function useEcho() {
    if (!echoInstance) {
        echoInstance = createEchoInstance()
    }

    return echoInstance
}

export function disconnectEcho(): void {
    echoInstance?.disconnect()
    echoInstance = null
}

The Dashboard Composable

// composables/useDashboard.ts
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useEcho } from './useEcho'
import type { Channel, PresenceChannel } from 'laravel-echo'

interface Metrics {
    tasks_completed: number
    tasks_total:     number
    active_members:  number
    completion_rate: number
}

interface Activity {
    id:          string
    type:        string
    description: string
    user:        { id: number; name: string }
    occurred_at: string
}

interface OnlineUser {
    id:         number
    name:       string
    avatar_url: string | null
}

export function useDashboard(projectId: number) {
    const echo = useEcho()

    // State
    const metrics       = ref<Metrics | null>(null)
    const activities    = ref<Activity[]>([])
    const onlineUsers   = ref<OnlineUser[]>([])
    const isConnected   = ref(false)
    const isReconnecting = ref(false)
    const connectionError = ref<string | null>(null)

    const completionPercentage = computed(() =>
        metrics.value
            ? Math.round((metrics.value.tasks_completed / metrics.value.tasks_total) * 100)
            : 0
    )

    // Track channel references for cleanup
    let dashboardChannel: Channel | null = null
    let presenceChannel: PresenceChannel | null = null

    function subscribeToChannels(): void {
        // Private channel for dashboard data
        dashboardChannel = echo
            .private(`project.${projectId}.dashboard`)
            .listen('.metrics.updated', (data: { metrics: Metrics }) => {
                metrics.value = data.metrics
            })
            .listen('.activity.occurred', (data: Activity) => {
                // Prepend new activity, keep max 50
                activities.value = [data, ...activities.value].slice(0, 50)
            })
            .listen('.task.completed', () => {
                // Task completed event — metrics will arrive via metrics.updated
                // Just trigger any animations here
            })
            .error((error: any) => {
                console.error('Dashboard channel error:', error)
                connectionError.value = 'Connection error. Retrying...'
                isConnected.value     = false
            })

        // Presence channel for online users
        presenceChannel = echo
            .join(`project.${projectId}.presence`)
            .here((users: OnlineUser[]) => {
                // Called with all current members when you join
                onlineUsers.value = users
                isConnected.value = true
                connectionError.value = null
            })
            .joining((user: OnlineUser) => {
                // Called when a new member joins
                if (!onlineUsers.value.find(u => u.id === user.id)) {
                    onlineUsers.value.push(user)
                }
            })
            .leaving((user: OnlineUser) => {
                // Called when a member leaves
                onlineUsers.value = onlineUsers.value.filter(u => u.id !== user.id)
            })
            .error((error: any) => {
                console.error('Presence channel error:', error)
            })
    }

    function unsubscribeFromChannels(): void {
        echo.leave(`project.${projectId}.dashboard`)
        echo.leave(`project.${projectId}.presence`)
        dashboardChannel = null
        presenceChannel  = null
    }

    onMounted(() => {
        subscribeToChannels()
    })

    onUnmounted(() => {
        unsubscribeFromChannels()
    })

    return {
        metrics,
        activities,
        onlineUsers,
        isConnected,
        isReconnecting,
        connectionError,
        completionPercentage,
    }
}

Reconnection Handling

Laravel Echo with Pusher JS handles reconnection automatically via the underlying WebSocket library. But the application needs to know when reconnection is happening to show the right UI state:

// Add to useEcho.ts — connection state monitoring
export function useEchoConnectionState() {
    const state = ref<'connected' | 'connecting' | 'disconnected' | 'failed'>('connecting')

    const echo = useEcho()

    // Access the underlying Pusher connector
    const pusher = (echo.connector as any).pusher

    pusher.connection.bind('connected', () => {
        state.value = 'connected'
    })

    pusher.connection.bind('connecting', () => {
        state.value = 'connecting'
    })

    pusher.connection.bind('disconnected', () => {
        state.value = 'disconnected'
        // Pusher JS will attempt reconnection automatically
    })

    pusher.connection.bind('failed', () => {
        state.value = 'failed'
        // Reconnection attempts exhausted
    })

    pusher.connection.bind('unavailable', () => {
        state.value = 'disconnected'
    })

    return { connectionState: state }
}
<!-- Connection status indicator in the dashboard -->
<div v-if="connectionState !== 'connected'" class="connection-status-banner">
    <div v-if="connectionState === 'connecting'" class="flex items-center text-yellow-700 bg-yellow-50 px-4 py-2 rounded-lg">
        <div class="animate-spin w-4 h-4 border-2 border-yellow-500 border-t-transparent rounded-full mr-2"></div>
        Connecting to real-time updates...
    </div>
    <div v-else-if="connectionState === 'disconnected'" class="flex items-center text-orange-700 bg-orange-50 px-4 py-2 rounded-lg">
        <svg class="w-4 h-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
        </svg>
        Reconnecting... Updates paused.
    </div>
    <div v-else-if="connectionState === 'failed'" class="flex items-center text-red-700 bg-red-50 px-4 py-2 rounded-lg">
        Real-time updates unavailable.
        <button @click="reconnect" class="ml-2 underline">Try again</button>
    </div>
</div>

Live Chart With Chart.js

The chart updates as task completion events arrive. The key challenge: Chart.js charts must be properly destroyed to prevent memory leaks when the component unmounts.

<!-- components/TaskCompletionChart.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Chart, type ChartConfiguration } from 'chart.js/auto'

interface HourlyData {
    hour:  string  // "2026-03-15T14:00:00"
    count: number
}

const props = defineProps<{
    data:       HourlyData[]
    projectId:  number
}>()

const canvasRef = ref<HTMLCanvasElement | null>(null)
let chartInstance: Chart | null = null

function buildChartData(data: HourlyData[]) {
    return {
        labels: data.map(d => {
            const date = new Date(d.hour)
            return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })
        }),
        datasets: [{
            label:           'Tasks Completed',
            data:            data.map(d => d.count),
            borderColor:     '#3b82f6',
            backgroundColor: 'rgba(59, 130, 246, 0.1)',
            borderWidth:     2,
            tension:         0.4,
            fill:            true,
            pointRadius:     4,
            pointHoverRadius: 6,
        }],
    }
}

function initChart(): void {
    if (!canvasRef.value) return

    const config: ChartConfiguration = {
        type: 'line',
        data: buildChartData(props.data),
        options: {
            responsive:          true,
            maintainAspectRatio: false,
            animation: {
                duration: 300, // Fast animation for real-time updates
            },
            plugins: {
                legend: { display: false },
                tooltip: {
                    callbacks: {
                        label: (ctx) => `${ctx.parsed.y} tasks completed`,
                    },
                },
            },
            scales: {
                y: {
                    beginAtZero: true,
                    ticks: { stepSize: 1 },
                    grid: { color: 'rgba(0,0,0,0.05)' },
                },
                x: {
                    grid: { display: false },
                },
            },
        },
    }

    chartInstance = new Chart(canvasRef.value, config)
}

// Update chart data without recreating the instance
function updateChart(newData: HourlyData[]): void {
    if (!chartInstance) return

    const chartData = buildChartData(newData)
    chartInstance.data.labels   = chartData.labels
    chartInstance.data.datasets[0].data = chartData.datasets[0].data
    chartInstance.update('active') // 'active' = animate the update
}

// Watch for data changes from the parent (as new completions arrive)
watch(() => props.data, (newData) => {
    updateChart(newData)
}, { deep: true })

onMounted(() => {
    initChart()
})

onUnmounted(() => {
    // CRITICAL: destroy the chart instance to prevent memory leak
    // Chart.js holds canvas references internally — not destroying causes
    // the canvas to accumulate hidden chart instances
    chartInstance?.destroy()
    chartInstance = null
})
</script>

<template>
    <div class="relative h-64">
        <canvas ref="canvasRef"></canvas>
    </div>
</template>

Updating the chart from the WebSocket event:

In the parent dashboard component, when a task.completed event arrives, update the hourly data array:

// In useDashboard.ts — add to the .listen('.task.completed') handler
.listen('.task.completed', () => {
    // Find the current hour bucket and increment its count
    const currentHour = new Date()
    currentHour.setMinutes(0, 0, 0)
    const hourKey = currentHour.toISOString()

    const hourIndex = hourlyData.value.findIndex(h => h.hour === hourKey)
    if (hourIndex !== -1) {
        // Increment the current hour's count (non-mutating)
        hourlyData.value = hourlyData.value.map((h, i) =>
            i === hourIndex ? { ...h, count: h.count + 1 } : h
        )
    }
})

Online User Presence Indicators

<!-- components/OnlineUsersIndicator.vue -->
<script setup lang="ts">
interface OnlineUser {
    id:         number
    name:       string
    avatar_url: string | null
}

const props = defineProps<{
    users:      OnlineUser[]
    maxVisible: number
}>()

const visibleUsers = computed(() => props.users.slice(0, props.maxVisible))
const hiddenCount  = computed(() => Math.max(0, props.users.length - props.maxVisible))
</script>

<template>
    <div class="flex items-center">
        <!-- Stacked avatars -->
        <div class="flex -space-x-2">
            <transition-group
                name="user-avatar"
                tag="div"
                class="flex -space-x-2"
            >
                <div
                    v-for="user in visibleUsers"
                    :key="user.id"
                    class="relative"
                    :title="user.name"
                >
                    <img
                        v-if="user.avatar_url"
                        :src="user.avatar_url"
                        :alt="user.name"
                        class="w-8 h-8 rounded-full border-2 border-white ring-2 ring-green-400"
                    >
                    <div
                        v-else
                        class="w-8 h-8 rounded-full border-2 border-white ring-2 ring-green-400 bg-blue-500 flex items-center justify-center text-white text-xs font-medium"
                    >
                        {{ user.name.charAt(0).toUpperCase() }}
                    </div>
                    <!-- Online indicator dot -->
                    <span class="absolute bottom-0 right-0 w-2.5 h-2.5 bg-green-400 border-2 border-white rounded-full"></span>
                </div>
            </transition-group>

            <!-- Hidden count bubble -->
            <div
                v-if="hiddenCount > 0"
                class="w-8 h-8 rounded-full border-2 border-white bg-gray-100 flex items-center justify-center text-xs font-medium text-gray-600"
            >
                +{{ hiddenCount }}
            </div>
        </div>

        <!-- Text indicator -->
        <span class="ml-3 text-sm text-gray-500">
            <span class="inline-block w-2 h-2 bg-green-400 rounded-full mr-1 animate-pulse"></span>
            {{ users.length }} {{ users.length === 1 ? 'person' : 'people' }} viewing
        </span>
    </div>
</template>

<style scoped>
.user-avatar-enter-active,
.user-avatar-leave-active {
    transition: all 0.3s ease;
}
.user-avatar-enter-from {
    opacity: 0;
    transform: scale(0.5);
}
.user-avatar-leave-to {
    opacity: 0;
    transform: scale(0.5);
}
</style>

The Complete Dashboard Page

<!-- pages/ProjectDashboard.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useEchoConnectionState } from '@/composables/useEcho'
import { useDashboard } from '@/composables/useDashboard'
import TaskCompletionChart from '@/components/TaskCompletionChart.vue'
import OnlineUsersIndicator from '@/components/OnlineUsersIndicator.vue'

const props = defineProps<{ projectId: number }>()

// Initialize dashboard real-time features
const {
    metrics,
    activities,
    onlineUsers,
    isConnected,
    hourlyData,
    completionPercentage,
} = useDashboard(props.projectId)

// Connection state monitoring
const { connectionState } = useEchoConnectionState()

// Initial data loaded via HTTP (not WebSocket)
const isLoading = ref(true)

onMounted(async () => {
    // Load initial data via REST API
    const [metricsData, activitiesData, chartData] = await Promise.all([
        fetch(`/api/projects/${props.projectId}/metrics`).then(r => r.json()),
        fetch(`/api/projects/${props.projectId}/activities?limit=50`).then(r => r.json()),
        fetch(`/api/projects/${props.projectId}/completions/hourly`).then(r => r.json()),
    ])

    metrics.value    = metricsData.data
    activities.value = activitiesData.data
    hourlyData.value = chartData.data

    isLoading.value = false
})

// Animated number update
function formatNumber(n: number): string {
    return n.toLocaleString()
}
</script>

<template>
    <div class="min-h-screen bg-gray-50">
        <!-- Header -->
        <div class="bg-white border-b border-gray-200 px-6 py-4">
            <div class="flex items-center justify-between">
                <h1 class="text-xl font-semibold text-gray-900">Project Dashboard</h1>
                <OnlineUsersIndicator :users="onlineUsers" :max-visible="5" />
            </div>

            <!-- Connection status -->
            <div v-if="connectionState !== 'connected'" class="mt-2">
                <span v-if="connectionState === 'connecting'" class="text-sm text-yellow-600 flex items-center">
                    <div class="animate-spin w-3 h-3 border border-yellow-500 border-t-transparent rounded-full mr-2"></div>
                    Connecting to live updates...
                </span>
                <span v-else-if="connectionState === 'disconnected'" class="text-sm text-orange-600">
                    ⚠ Reconnecting — updates paused
                </span>
                <span v-else-if="connectionState === 'failed'" class="text-sm text-red-600">
                    ✕ Real-time updates unavailable
                </span>
            </div>
        </div>

        <div v-if="isLoading" class="flex items-center justify-center h-96">
            <div class="animate-spin w-8 h-8 border-4 border-blue-500 border-t-transparent rounded-full"></div>
        </div>

        <div v-else class="p-6 space-y-6">
            <!-- Metrics row -->
            <div class="grid grid-cols-1 md:grid-cols-4 gap-4">
                <div class="bg-white rounded-xl border border-gray-200 p-5">
                    <p class="text-sm text-gray-500 font-medium">Tasks Completed</p>
                    <p class="text-3xl font-bold text-gray-900 mt-1 tabular-nums transition-all">
                        {{ formatNumber(metrics?.tasks_completed ?? 0) }}
                    </p>
                    <p class="text-xs text-gray-400 mt-1">of {{ metrics?.tasks_total ?? 0 }} total</p>
                </div>

                <div class="bg-white rounded-xl border border-gray-200 p-5">
                    <p class="text-sm text-gray-500 font-medium">Completion Rate</p>
                    <p class="text-3xl font-bold text-blue-600 mt-1 tabular-nums">
                        {{ completionPercentage }}%
                    </p>
                    <div class="w-full bg-gray-200 rounded-full h-1.5 mt-2">
                        <div
                            class="bg-blue-500 h-1.5 rounded-full transition-all duration-500"
                            :style="{ width: `${completionPercentage}%` }"
                        ></div>
                    </div>
                </div>

                <div class="bg-white rounded-xl border border-gray-200 p-5">
                    <p class="text-sm text-gray-500 font-medium">Active Members</p>
                    <p class="text-3xl font-bold text-gray-900 mt-1">
                        {{ metrics?.active_members ?? 0 }}
                    </p>
                </div>

                <div class="bg-white rounded-xl border border-gray-200 p-5">
                    <p class="text-sm text-gray-500 font-medium">Viewing Now</p>
                    <p class="text-3xl font-bold text-green-600 mt-1">
                        {{ onlineUsers.length }}
                    </p>
                    <p class="text-xs text-gray-400 mt-1">on this dashboard</p>
                </div>
            </div>

            <!-- Chart and Activity Feed row -->
            <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
                <!-- Chart -->
                <div class="lg:col-span-2 bg-white rounded-xl border border-gray-200 p-5">
                    <div class="flex items-center justify-between mb-4">
                        <h2 class="text-sm font-semibold text-gray-700">Task Completions (Last 24h)</h2>
                        <span
                            v-if="isConnected"
                            class="flex items-center text-xs text-green-600 font-medium"
                        >
                            <span class="w-1.5 h-1.5 bg-green-500 rounded-full mr-1.5 animate-pulse"></span>
                            Live
                        </span>
                    </div>
                    <TaskCompletionChart
                        :data="hourlyData"
                        :project-id="projectId"
                    />
                </div>

                <!-- Activity feed -->
                <div class="bg-white rounded-xl border border-gray-200 p-5">
                    <h2 class="text-sm font-semibold text-gray-700 mb-4">Live Activity</h2>

                    <div class="space-y-3 overflow-y-auto max-h-64">
                        <transition-group name="activity-item" tag="div" class="space-y-3">
                            <div
                                v-for="activity in activities"
                                :key="activity.id"
                                class="flex items-start space-x-3"
                            >
                                <div class="w-7 h-7 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0 text-xs font-medium text-blue-700">
                                    {{ activity.user.name.charAt(0) }}
                                </div>
                                <div class="min-w-0 flex-1">
                                    <p class="text-sm text-gray-700 leading-tight">
                                        <span class="font-medium">{{ activity.user.name }}</span>
                                        {{ activity.description }}
                                    </p>
                                    <p class="text-xs text-gray-400 mt-0.5">
                                        {{ new Date(activity.occurred_at).toLocaleTimeString() }}
                                    </p>
                                </div>
                            </div>
                        </transition-group>

                        <div v-if="activities.length === 0" class="text-sm text-gray-400 text-center py-8">
                            No activity yet. Activity will appear here as it happens.
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<style scoped>
.activity-item-enter-active {
    transition: all 0.3s ease;
}
.activity-item-enter-from {
    opacity: 0;
    transform: translateY(-10px);
}
.activity-item-leave-to {
    opacity: 0;
}
</style>

The API Endpoints for Initial Data

// app/Http/Controllers/Api/DashboardController.php
class DashboardController extends Controller
{
    public function metrics(Project $project): JsonResponse
    {
        $this->authorize('view', $project);

        return response()->json([
            'data' => [
                'tasks_completed' => $project->tasks()->where('status', 'completed')->count(),
                'tasks_total'     => $project->tasks()->count(),
                'active_members'  => $project->members()->count(),
                'completion_rate' => $project->completionRate(),
            ],
        ]);
    }

    public function activities(Request $request, Project $project): JsonResponse
    {
        $this->authorize('view', $project);

        $activities = ActivityLog::where('project_id', $project->id)
            ->with('user:id,name,avatar_url')
            ->latest()
            ->limit($request->integer('limit', 50))
            ->get()
            ->map(fn ($log) => [
                'id'          => $log->id,
                'type'        => $log->event,
                'description' => $log->description,
                'user'        => [
                    'id'   => $log->user->id,
                    'name' => $log->user->name,
                ],
                'occurred_at' => $log->created_at->toIso8601String(),
            ]);

        return response()->json(['data' => $activities]);
    }

    public function hourlyCompletions(Project $project): JsonResponse
    {
        $this->authorize('view', $project);

        // Last 24 hours, grouped by hour
        $data = DB::select("
            SELECT
                DATE_FORMAT(completed_at, '%Y-%m-%dT%H:00:00') as hour,
                COUNT(*) as count
            FROM tasks
            WHERE project_id = ?
              AND status = 'completed'
              AND completed_at >= NOW() - INTERVAL 24 HOUR
            GROUP BY hour
            ORDER BY hour ASC
        ", [$project->id]);

        // Fill in missing hours with zero counts
        $hours = collect();
        for ($i = 23; $i >= 0; $i--) {
            $hour = now()->subHours($i)->startOfHour()->toISOString();
            $hours->push([
                'hour'  => $hour,
                'count' => collect($data)->firstWhere('hour', substr($hour, 0, 19))?->count ?? 0,
            ]);
        }

        return response()->json(['data' => $hours]);
    }
}

Production Deployment for Reverb

Reverb runs as a long-running process. In production it needs a process manager:

# /etc/supervisor/conf.d/reverb.conf
[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/reverb.log
supervisorctl reread
supervisorctl update
supervisorctl start reverb

Nginx proxy configuration (to serve Reverb through your main domain with TLS):

# /etc/nginx/sites-available/app.conf
server {
    listen 443 ssl;
    server_name yourdomain.com;

    # ... SSL configuration ...

    # Proxy WebSocket connections to Reverb
    location /app/ {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host       $host;
        proxy_read_timeout 60s;
        proxy_send_timeout 60s;
    }

    # Standard HTTP proxying for the Laravel app
    location / {
        try_files $uri $uri/ @php;
    }
}

Update environment for production WebSocket:

# .env (production)
REVERB_HOST=yourdomain.com
REVERB_PORT=443
REVERB_SCHEME=https

# Vue frontend (update vite.config.ts to inject these)
VITE_REVERB_HOST=yourdomain.com
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https

Horizontal Scaling

Reverb supports horizontal scaling via Redis publish/subscribe. When running multiple Laravel application servers, events broadcast from any server need to reach Reverb:

// config/reverb.php
'servers' => [
    'reverb' => [
        'host'       => env('REVERB_SERVER_HOST', '0.0.0.0'),
        'port'       => env('REVERB_SERVER_PORT', 8080),
        'scaling'    => [
            'enabled'    => true,
            'channel'    => 'reverb',
            'server'     => [
                'url'      => env('REDIS_URL'),
                'database' => env('REDIS_DB', '0'),
                'prefix'   => 'reverb',
            ],
        ],
    ],
],

With scaling enabled, events published from App-01 are picked up by Reverb running on App-02 via Redis Pub/Sub, and broadcast to all connected clients regardless of which app server dispatched the event.


Testing Real-Time Features

// tests/Feature/DashboardBroadcastTest.php
use App\Events\Dashboard\MetricsUpdated;
use App\Events\Dashboard\ActivityOccurred;
use Illuminate\Support\Facades\Event;

it('broadcasts MetricsUpdated when a task is completed', function () {
    Event::fake();

    $user    = User::factory()->create();
    $project = Project::factory()->hasMembers([$user])->create();
    $task    = Task::factory()->for($project)->create(['status' => 'in_progress']);

    $this->actingAs($user)
         ->patchJson("/api/tasks/{$task->id}", ['status' => 'completed'])
         ->assertOk();

    Event::assertDispatched(MetricsUpdated::class, function ($event) use ($project) {
        return $event->projectId === $project->id
            && isset($event->metrics['tasks_completed']);
    });
});

it('broadcasts ActivityOccurred on task completion', function () {
    Event::fake();

    $user    = User::factory()->create();
    $project = Project::factory()->hasMembers([$user])->create();
    $task    = Task::factory()->for($project)->create(['status' => 'in_progress']);

    $this->actingAs($user)
         ->patchJson("/api/tasks/{$task->id}", ['status' => 'completed'])
         ->assertOk();

    Event::assertDispatched(ActivityOccurred::class);
});

it('broadcasts to the correct private channel', function () {
    Event::fake();

    $project = Project::factory()->create();

    MetricsUpdated::dispatch($project->id, [
        'tasks_completed' => 5,
        'tasks_total'     => 10,
    ]);

    Event::assertDispatched(MetricsUpdated::class, function ($event) use ($project) {
        $channels = $event->broadcastOn();
        return collect($channels)->contains(
            fn ($ch) => $ch->name === "private-project.{$project->id}.dashboard"
        );
    });
});

What This Covers That Most Tutorials Don’t

Separate initial load from WebSocket updates. The dashboard loads initial data via HTTP on mount. The WebSocket handles incremental updates. This is the correct pattern — WebSockets aren’t an HTTP replacement for loading initial state.

Chart.js memory leak prevention. chartInstance?.destroy() in onUnmounted is the specific line that prevents memory leaks. Chart.js holds references to canvas elements internally. Without explicit destruction, each component mount creates a new chart instance that can’t be garbage collected.

Presence channels return user data, not just user IDs. The channel authorization for presence channels returns an object (name, avatar_url) that becomes available to all subscribers via .here() and .joining(). This is what enables the online user display without additional API calls.

Production Nginx configuration for WebSocket proxying. The Upgrade and Connection headers are required for WebSocket connections through Nginx. Missing either causes the WebSocket handshake to fail silently.

Horizontal scaling via Redis Pub/Sub. Multi-server deployments need events from any server to reach Reverb. The scaling configuration is what makes this work — without it, only events dispatched on the same server as Reverb are broadcast.

Leave a Reply

Your email address will not be published. Required fields are marked *