Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Communication & Real-time/Real-time Updates

Realtime & WebSockets Guide

Last Updated: 2026-01-15 Status: Implemented Plan Reference: 024-realtime-websockets.md, 065-real-time-updates-enhancement.md, 082-real-time-updates-improvement.md


Overview

The Realtime system provides instant updates to users through WebSockets. Features include live notifications, presence indicators, real-time collaboration, and instant data synchronization without page refreshes.


Table of Contents

  1. Features
  2. How It Works
  3. Channels
  4. Client-Side Usage
  5. Technical Architecture
  6. Related Features

Features

Live Notifications

  • Instant notification delivery
  • Badge count updates
  • Toast notifications
  • No polling required

Presence System

  • See who's online
  • Typing indicators
  • User activity status
  • Last seen timestamps

Real-Time Data

  • Live dashboard updates
  • Instant list refreshes
  • Form collaboration
  • Comment threads

Collaboration

  • Multi-user editing awareness
  • Conflict prevention
  • Live cursors (optional)
  • Shared state

How It Works

Broadcasting Flow

1. Server Event → 2. Laravel Event → 3. Broadcaster → 4. WebSocket Server
                                                              ↓
5. Client JS ← 6. Pusher/Laravel Echo ← 7. WebSocket Connection

Connection Lifecycle

  1. Page loads, Echo connects to WebSocket server
  2. Client authenticates (for private channels)
  3. Client subscribes to relevant channels
  4. Server broadcasts events
  5. Client receives and handles events

Channels

Public Channels

No authentication required.

ChannelPurposeEvents
portalGlobal announcementsAnnouncement

Private Channels

User authentication required.

ChannelPurposeEvents
App.Models.User.{id}User notificationsNotificationReceived
client.{clientId}Client updatesInvoiceCreated, ProjectUpdated

Presence Channels

Track who's subscribed.

ChannelPurposeMembers
presence-project.{projectId}Project viewersActive users
presence-document.{documentId}Document editorsCollaborators
presence-onlineOnline usersAll logged in

Client-Side Usage

Setting Up Laravel Echo

// resources/js/bootstrap.js
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

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: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
    authEndpoint: '/broadcasting/auth',
});

Listening for Notifications

// Listen for user notifications
Echo.private(`App.Models.User.${userId}`)
    .notification((notification) => {
        console.log('New notification:', notification);
        updateNotificationBell();
        showToast(notification.message);
    });

Subscribing to Private Channels

// Listen for client events
Echo.private(`client.${clientId}`)
    .listen('InvoiceCreated', (e) => {
        console.log('New invoice:', e.invoice);
        refreshInvoiceList();
    })
    .listen('ProjectUpdated', (e) => {
        console.log('Project updated:', e.project);
        updateProjectCard(e.project);
    });

Presence Channels

// Join project presence channel
Echo.join(`presence-project.${projectId}`)
    .here((users) => {
        // Called when joining with current users
        console.log('Users viewing:', users);
        showActiveUsers(users);
    })
    .joining((user) => {
        // Called when user joins
        console.log('User joined:', user);
        addActiveUser(user);
    })
    .leaving((user) => {
        // Called when user leaves
        console.log('User left:', user);
        removeActiveUser(user);
    })
    .listen('CommentAdded', (e) => {
        addComment(e.comment);
    });

Leaving Channels

// Leave specific channel
Echo.leave(`presence-project.${projectId}`);

// Leave all channels (logout)
Echo.disconnect();

Technical Architecture

Configuration

This project uses Laravel Reverb as the WebSocket server.

Environment Variables:

# Backend Reverb config
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=client-portal
REVERB_APP_KEY=local-reverb-key
REVERB_APP_SECRET=local-reverb-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http

# Frontend Vite config (production values)
VITE_REVERB_APP_KEY=prod-reverb-key-xxx
VITE_REVERB_HOST=portal.yourdomain.com
VITE_REVERB_PORT=443
VITE_REVERB_SCHEME=https

Broadcasting Config: config/broadcasting.php

'reverb' => [
    'driver' => 'reverb',
    'key' => env('REVERB_APP_KEY'),
    'secret' => env('REVERB_APP_SECRET'),
    'app_id' => env('REVERB_APP_ID'),
    'options' => [
        'host' => env('REVERB_HOST'),
        'port' => env('REVERB_PORT', 443),
        'scheme' => env('REVERB_SCHEME', 'https'),
        'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
    ],
    'client_options' => [],
],

Important: Reverb runs as a separate process. In production, use Supervisor to keep it running:

[program:client-portal-reverb]
process_name=%(program_name)s
command=/usr/bin/php /var/www/client-portal-laravel/artisan reverb:start --host=127.0.0.1 --port=8080
user=www-data
autostart=true
autorestart=true

Events

Location: app/Events/

// Example broadcast event
class InvoiceCreated implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(
        public Invoice $invoice
    ) {}

    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('client.' . $this->invoice->client_id),
        ];
    }

    public function broadcastWith(): array
    {
        return [
            'id' => $this->invoice->id,
            'invoice_number' => $this->invoice->invoice_number,
            'total' => $this->invoice->total,
        ];
    }

    public function broadcastAs(): string
    {
        return 'InvoiceCreated';
    }
}

Channel Authorization

Location: routes/channels.php

// Private channel authorization
Broadcast::channel('client.{clientId}', function (User $user, int $clientId) {
    return $user->isAdmin() || $user->belongsToClient($clientId);
});

// User notification channel
Broadcast::channel('App.Models.User.{id}', function (User $user, int $id) {
    return $user->id === $id;
});

// Presence channel authorization
Broadcast::channel('presence-project.{projectId}', function (User $user, int $projectId) {
    $project = Project::find($projectId);

    if ($user->isAdmin() || $user->belongsToClient($project->client_id)) {
        return [
            'id' => $user->id,
            'name' => $user->name,
            'avatar' => $user->avatar_url,
        ];
    }

    return false;
});

Broadcasting Events

// Broadcast immediately
event(new InvoiceCreated($invoice));

// Queue the broadcast
InvoiceCreated::dispatch($invoice);

// Broadcast to specific socket
broadcast(new InvoiceCreated($invoice))->toOthers();

Available Events

EventChannelPurpose
InvoiceCreatedclient.{id}New invoice notification
InvoiceStatusChangedclient.{id}Invoice status update
ProjectUpdatedclient.{id}Project changes
FileUploadedclient.{id}New file available
NotificationReceivedUser.{id}Generic notification
CommentAddedproject.{id}New comment

Routes

// Broadcasting authentication
Broadcast::routes(['middleware' => ['auth']]);

Production Deployment

Reverb on Production

This project uses Laravel Reverb. In production:

  1. Reverb runs behind Apache/Nginx - Proxy WebSocket connections to Reverb
  2. Use WSS (TLS) - Configure VITE_REVERB_SCHEME=https and VITE_REVERB_PORT=443
  3. Supervisor manages the process - Keeps Reverb running

Apache WebSocket proxy config:

# Enable proxy modules
ProxyPass /app ws://127.0.0.1:8080/app
ProxyPassReverse /app ws://127.0.0.1:8080/app

Alternative: Soketi (Open Source Pusher)

If you prefer a standalone WebSocket server:

# docker-compose.yml
services:
  soketi:
    image: quay.io/soketi/soketi:latest-16-alpine
    ports:
      - "6001:6001"
    environment:
      SOKETI_DEBUG: '1'
      SOKETI_DEFAULT_APP_ID: app-id
      SOKETI_DEFAULT_APP_KEY: app-key
      SOKETI_DEFAULT_APP_SECRET: app-secret

Frontend Integration

Vue.js Component

<template>
  <div>
    <span v-for="user in activeUsers" :key="user.id">
      {{ user.name }}
    </span>
  </div>
</template>

<script>
export default {
  data() {
    return {
      activeUsers: []
    }
  },
  mounted() {
    Echo.join(`presence-project.${this.projectId}`)
      .here(users => this.activeUsers = users)
      .joining(user => this.activeUsers.push(user))
      .leaving(user => {
        this.activeUsers = this.activeUsers.filter(u => u.id !== user.id)
      });
  },
  beforeUnmount() {
    Echo.leave(`presence-project.${this.projectId}`);
  }
}
</script>

Alpine.js Integration

<div x-data="notifications()" x-init="init()">
  <span x-text="unreadCount"></span>
</div>

<script>
function notifications() {
  return {
    unreadCount: 0,
    init() {
      Echo.private(`App.Models.User.${userId}`)
        .notification((n) => {
          this.unreadCount++;
          this.showToast(n);
        });
    }
  }
}
</script>

Dependencies

FeatureRelationship
AuthenticationChannel authorization
NotificationsBroadcast notifications

Complementary Features

FeatureDescription
WebhooksExternal event delivery
Client CollaborationReal-time collaboration
Activity LoggingEvent tracking

Best Practices

For Performance

  1. Limit broadcast data to essential fields
  2. Use queue driver for broadcasts
  3. Debounce frequent updates
  4. Leave channels when not needed

For Security

  1. Always authorize private channels
  2. Validate user access in channel auth
  3. Don't broadcast sensitive data
  4. Use presence wisely (exposes user lists)

Troubleshooting

IssueSolution
Connection failsCheck Reverb credentials and host/port config
Auth failsVerify /broadcasting/auth route is accessible
Events not receivedCheck channel name matches exactly
Presence emptyVerify return format in channel auth
CSP blocks WebSocketAdd wss:// URL to Content Security Policy
Workers not runningCheck Supervisor status with supervisorctl status

Debug Mode

// Enable Pusher logging (Reverb uses Pusher protocol)
Pusher.logToConsole = true;

// Check connection state
console.log(Echo.connector.pusher.connection.state);

Common Errors

// "No current subscription" - channel not joined
Echo.join('presence-project.1'); // Join first

// "Forbidden" - auth failed
// Check routes/channels.php authorization

CSP WebSocket Blocking

If you see this error in the browser console:

Refused to connect to 'wss://portal.example.com/...' because it violates Content-Security-Policy

The Content Security Policy connect-src directive must include the WebSocket URL. This is handled in app/Http/Middleware/SecurityHeaders.php:

private function getReverbWsUrls(): string
{
    $host = config('broadcasting.connections.reverb.options.host');
    if (! $host) {
        return '';
    }
    return " wss://{$host} ws://{$host}";
}

After deploying CSP changes, clear config cache:

php artisan config:clear && php artisan config:cache
sudo supervisorctl restart all

Supervisor Issues

If Supervisor workers show FATAL state:

  1. Check /var/log/supervisor/ for error logs
  2. Ensure PHP path is absolute: /usr/bin/php not just php
  3. Verify file permissions for www-data user

Enhanced Real-time Services (Plan 065)

New Services

The following services were added to enhance real-time capabilities:

ServiceLocationPurpose
PresenceServiceapp/Services/RealTime/User presence tracking with TTL
ActivityFeedServiceapp/Services/RealTime/Real-time activity feed management
DataSyncServiceapp/Services/RealTime/Data version syncing for offline support
ChannelAuthorizationServiceapp/Services/RealTime/Centralized channel authorization

New Broadcast Events

EventChannelsPurpose
ProjectUpdatedproject.{id}, client.{id}Project changes
FileUploadedproject.{id}File upload notifications
FileDeletedproject.{id}File deletion notifications
InvoiceStatusChangedinvoice.{id}, client.{id}, adminInvoice updates
TimeEntryUpdatedUser.{id}, project.{id}Time tracking sync
ActivityFeedUpdatedadmin, client.{id}Activity feed updates
UserPresenceChangedpresence.onlinePresence changes

JavaScript Components

ComponentPathPurpose
RealtimeManagerresources/js/realtime/RealtimeManager.jsConnection management
ProjectUpdatesresources/js/components/realtime/ProjectUpdates.jsProject page updates
ActivityFeedresources/js/components/realtime/ActivityFeed.jsLive activity feed
ConnectionStatusresources/js/components/realtime/ConnectionStatus.jsConnection indicator

Presence Tracking

use App\Services\RealTime\PresenceService;

$service = app(PresenceService::class);

// Track user online
$service->setOnline($user, 'Projects');

// Check if online
$isOnline = $service->isOnline($userId);

// Get all online users
$onlineUsers = $service->getOnlineUsers();

// Track resource viewing
$service->setViewingResource($user, 'project', $projectId);
$viewers = $service->getUsersViewingResource('project', $projectId);

Activity Feed

use App\Services\RealTime\ActivityFeedService;

$service = app(ActivityFeedService::class);

// Add activity
$service->addActivity(
    type: 'project.created',
    description: 'created a new project',
    user: $user,
    subjectType: 'project',
    subjectId: $project->id,
    metadata: ['client_id' => $project->client_id]
);

// Get feeds
$globalFeed = $service->getGlobalFeed(limit: 50);
$clientFeed = $service->getClientFeed($clientId);
$projectFeed = $service->getProjectFeed($projectId);

API Endpoints

EndpointMethodDescription
/api/sync/heartbeatPOSTUpdate presence
/api/sync/online-usersGETGet online users
/api/sync/activitiesGETGet activity feed
/api/sync/start-viewingPOSTMark viewing resource
/api/sync/stop-viewingPOSTStop viewing resource

Additional Channels

ChannelPurpose
invoice.{id}Invoice-specific updates
timer.{userId}Cross-device timer sync
timesheet.{id}Timesheet workflow updates
workflow.{id}Workflow execution updates
presence.client.{id}Client viewer presence

TrackPresence Middleware

Apply to routes for automatic presence tracking:

Route::middleware(['auth', TrackPresence::class])->group(function () {
    // Routes with presence tracking
});

Database Tables

user_presence - Persistent presence storage

ColumnTypeDescription
user_idbigintUser FK
locationstringCurrent location
statusstringonline/offline
last_seen_attimestampLast activity

realtime_events - Event logging for debugging

ColumnTypeDescription
event_idstringUnique event ID
channelstringChannel name
event_typestringEvent type
payloadjsonEvent data
deliveredbooleanDelivery status

Sending Real-Time Notifications

RealtimeNotificationService

Use RealtimeNotificationService for sending notifications that should appear instantly in real-time:

use App\Services\RealtimeNotificationService;
use App\Notifications\YourNotification;

$service = app(RealtimeNotificationService::class);

// Single user
$service->notify($user, new YourNotification($data));

// Multiple users
$service->notifyMany($users, new YourNotification($data));

Important: Notification Queuing

For notifications to broadcast in real-time via RealtimeNotificationService, the notification class should NOT implement ShouldQueue. If the notification is queued, the service can't broadcast it immediately.

Correct approach for real-time:

// Good - synchronous, broadcasts immediately
class WorkflowNotification extends Notification
{
    public function via($notifiable): array
    {
        return ['database', 'mail'];
    }
}

Problematic for real-time:

// Bad - queued, broadcast happens too early
class WorkflowNotification extends Notification implements ShouldQueue
{
    // ...
}

If your notification is already being sent from a queue job (like workflow actions), the notification doesn't need ShouldQueue since it's already running asynchronously.

Workflow Notifications

Workflow actions use RealtimeNotificationService internally. The notification config supports both formats:

Formal format:

{
  "recipient_type": "role",
  "recipient_role": "admin",
  "title": "Stage Changed",
  "message": "Client moved to new stage"
}

Simplified format:

{
  "to": "admin",
  "title": "Stage Changed",
  "body": "Client moved to new stage"
}

See Also