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
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
- Page loads, Echo connects to WebSocket server
- Client authenticates (for private channels)
- Client subscribes to relevant channels
- Server broadcasts events
- Client receives and handles events
Channels
Public Channels
No authentication required.
| Channel | Purpose | Events |
|---|---|---|
portal | Global announcements | Announcement |
Private Channels
User authentication required.
| Channel | Purpose | Events |
|---|---|---|
App.Models.User.{id} | User notifications | NotificationReceived |
client.{clientId} | Client updates | InvoiceCreated, ProjectUpdated |
Presence Channels
Track who's subscribed.
| Channel | Purpose | Members |
|---|---|---|
presence-project.{projectId} | Project viewers | Active users |
presence-document.{documentId} | Document editors | Collaborators |
presence-online | Online users | All 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
| Event | Channel | Purpose |
|---|---|---|
InvoiceCreated | client.{id} | New invoice notification |
InvoiceStatusChanged | client.{id} | Invoice status update |
ProjectUpdated | client.{id} | Project changes |
FileUploaded | client.{id} | New file available |
NotificationReceived | User.{id} | Generic notification |
CommentAdded | project.{id} | New comment |
Routes
// Broadcasting authentication
Broadcast::routes(['middleware' => ['auth']]);
Production Deployment
Reverb on Production
This project uses Laravel Reverb. In production:
- Reverb runs behind Apache/Nginx - Proxy WebSocket connections to Reverb
- Use WSS (TLS) - Configure
VITE_REVERB_SCHEME=httpsandVITE_REVERB_PORT=443 - 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>
Related Features
Dependencies
| Feature | Relationship |
|---|---|
| Authentication | Channel authorization |
| Notifications | Broadcast notifications |
Complementary Features
| Feature | Description |
|---|---|
| Webhooks | External event delivery |
| Client Collaboration | Real-time collaboration |
| Activity Logging | Event tracking |
Best Practices
For Performance
- Limit broadcast data to essential fields
- Use queue driver for broadcasts
- Debounce frequent updates
- Leave channels when not needed
For Security
- Always authorize private channels
- Validate user access in channel auth
- Don't broadcast sensitive data
- Use presence wisely (exposes user lists)
Troubleshooting
| Issue | Solution |
|---|---|
| Connection fails | Check Reverb credentials and host/port config |
| Auth fails | Verify /broadcasting/auth route is accessible |
| Events not received | Check channel name matches exactly |
| Presence empty | Verify return format in channel auth |
| CSP blocks WebSocket | Add wss:// URL to Content Security Policy |
| Workers not running | Check 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:
- Check
/var/log/supervisor/for error logs - Ensure PHP path is absolute:
/usr/bin/phpnot justphp - 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:
| Service | Location | Purpose |
|---|---|---|
PresenceService | app/Services/RealTime/ | User presence tracking with TTL |
ActivityFeedService | app/Services/RealTime/ | Real-time activity feed management |
DataSyncService | app/Services/RealTime/ | Data version syncing for offline support |
ChannelAuthorizationService | app/Services/RealTime/ | Centralized channel authorization |
New Broadcast Events
| Event | Channels | Purpose |
|---|---|---|
ProjectUpdated | project.{id}, client.{id} | Project changes |
FileUploaded | project.{id} | File upload notifications |
FileDeleted | project.{id} | File deletion notifications |
InvoiceStatusChanged | invoice.{id}, client.{id}, admin | Invoice updates |
TimeEntryUpdated | User.{id}, project.{id} | Time tracking sync |
ActivityFeedUpdated | admin, client.{id} | Activity feed updates |
UserPresenceChanged | presence.online | Presence changes |
JavaScript Components
| Component | Path | Purpose |
|---|---|---|
RealtimeManager | resources/js/realtime/RealtimeManager.js | Connection management |
ProjectUpdates | resources/js/components/realtime/ProjectUpdates.js | Project page updates |
ActivityFeed | resources/js/components/realtime/ActivityFeed.js | Live activity feed |
ConnectionStatus | resources/js/components/realtime/ConnectionStatus.js | Connection 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
| Endpoint | Method | Description |
|---|---|---|
/api/sync/heartbeat | POST | Update presence |
/api/sync/online-users | GET | Get online users |
/api/sync/activities | GET | Get activity feed |
/api/sync/start-viewing | POST | Mark viewing resource |
/api/sync/stop-viewing | POST | Stop viewing resource |
Additional Channels
| Channel | Purpose |
|---|---|
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
| Column | Type | Description |
|---|---|---|
| user_id | bigint | User FK |
| location | string | Current location |
| status | string | online/offline |
| last_seen_at | timestamp | Last activity |
realtime_events - Event logging for debugging
| Column | Type | Description |
|---|---|---|
| event_id | string | Unique event ID |
| channel | string | Channel name |
| event_type | string | Event type |
| payload | json | Event data |
| delivered | boolean | Delivery 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
- Notifications - Notification system
- Client Collaboration - Collaboration features
- Background Jobs - Queue for broadcasts
- Time Tracking - Timer sync integration