Building a Reactive Notification System with Laravel and WebSocket: Architecture, Queues, and Scaling
Introduction: Why Real-Time Notifications Require a Dedicated Architecture
Notifications in modern web applications come in several fundamentally different types: in-app (the bell counter), email, push (browser and mobile), and SMS. Each has different requirements for delivery speed, reliability, and transport channel.
The key question: can you get away with polling — periodically querying the server every N seconds? Technically yes, but the cost is obvious: database load grows linearly with the number of users, delivery latency is unacceptable for chats and alerts, and scaling becomes a nightmare. That's exactly why real-time notifications require a dedicated architectural branch: a persistent connection (WebSocket or SSE) plus an asynchronous server-side processing pipeline.
In this article, we'll walk through building such a system on top of Laravel, Redis, and Laravel Reverb — from the data model to horizontal scaling in production.
Stack Overview: How the Pieces Fit Together
The stack consists of several layers, each solving its own problem:
- Laravel Notifications — a high-level API for sending notifications through different channels (mail, database, broadcast, Slack, etc.).
- Laravel Broadcasting — a mechanism for publishing events to WebSocket channels; works on top of Pusher, Ably, or your own server drivers.
- Redis — plays a dual role: a job queue (driver
redis) and a Pub/Sub transport between the application and the WebSocket server. - Laravel Reverb — a native first-party WebSocket server introduced in Laravel 11; replaces Soketi and third-party solutions.
- Queue Workers — handle heavy channels (email, SMS) asynchronously without blocking the HTTP request.
The data flow looks like this: HTTP request or domain event → Laravel creates a notification → writes to DB and dispatches to queue → worker processes the channel → Redis Pub/Sub → Reverb → WebSocket → browser.
Designing the Notification Model
The default notifications table created by Laravel is minimal. For a production system, it's worth extending it.
-- Migration for the extended notifications table
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(255) NOT NULL, -- notification class
notifiable_type VARCHAR(255) NOT NULL,
notifiable_id BIGINT UNSIGNED NOT NULL,
data JSON NOT NULL,
status ENUM('pending','sent','read','archived') DEFAULT 'pending',
priority TINYINT DEFAULT 0, -- 0=normal, 1=high, 2=critical
group_key VARCHAR(128) NULL, -- for grouping similar notifications
channel VARCHAR(64) NOT NULL DEFAULT 'database',
read_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_notifiable (notifiable_type, notifiable_id, status),
INDEX idx_group (group_key),
INDEX idx_priority (priority, created_at)
);
The group_key field allows collapsing similar notifications: instead of "10 new comments," the user sees a single card with a count. The priority field controls processing order in the queue — critical notifications are routed to a separate high-priority queue.
The model in Laravel:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Notifications\DatabaseNotification;
class Notification extends DatabaseNotification
{
use HasUuids;
protected $casts = [
'data' => 'array',
'read_at' => 'datetime',
'expires_at' => 'datetime',
];
public function scopeUnread($query)
{
return $query->whereNull('read_at')->where('status', '!=', 'archived');
}
public function scopeHighPriority($query)
{
return $query->where('priority', '>=', 1)->orderByDesc('priority');
}
public function markAsRead(): void
{
$this->update(['read_at' => now(), 'status' => 'read']);
}
}
Laravel Broadcasting and Channels: Private, Presence, Authentication
Broadcasting in Laravel is built around the concept of channels. There are three types:
- Public — anyone can subscribe; suitable for global announcements.
- Private — requires user authentication; the standard for personal notifications.
- Presence — an extension of private; the server knows who is currently online; used for "X users are reading" features.
Example notification with broadcast support:
<?php
namespace App\Notifications;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;
class OrderStatusChanged extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private Order $order) {}
public function via(object $notifiable): array
{
return ['database', 'broadcast'];
}
public function toDatabase(object $notifiable): array
{
return [
'order_id' => $this->order->id,
'status' => $this->order->status,
'message' => "Order #{$this->order->number} status changed to {$this->order->status}",
];
}
public function toBroadcast(object $notifiable): BroadcastMessage
{
return new BroadcastMessage([
'id' => $this->id,
'type' => 'order.status_changed',
'payload' => $this->toDatabase($notifiable),
]);
}
public function broadcastOn(): array
{
return [new \Illuminate\Broadcasting\PrivateChannel(
'users.' . $this->notifiable->id
)];
}
// Custom event name on the client side
public function broadcastAs(): string
{
return 'notification.new';
}
}
Authorization for private channels is configured in routes/channels.php:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('users.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
// Presence channel for a team
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
if ($user->teams->contains($teamId)) {
return ['id' => $user->id, 'name' => $user->name];
}
});
Redis as Transport: Pub/Sub Between the Application and the WebSocket Server
When Laravel publishes an event to a broadcast channel, it serializes it to JSON and publishes it to a Redis channel via PUBLISH. The WebSocket server is subscribed to that channel via SUBSCRIBE and forwards the message to all connected clients on the appropriate channel.
This is the classic Redis Pub/Sub pattern, and it's critical for scaling: multiple application instances can publish messages independently, while the WebSocket server acts as the single delivery point.
Configuration in config/broadcasting.php:
<?php
return [
'default' => env('BROADCAST_DRIVER', 'reverb'),
'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'),
],
],
],
];
For Redis as the queue transport in config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => true, // important: dispatch after a successful transaction commit
],
Integrating Laravel Reverb: Configuration and Launch
Laravel Reverb is a native WebSocket server written in PHP on top of ReactPHP. It was introduced in Laravel 11 and solves the main pain point — dependency on Pusher or the need to maintain a separate Node.js service.
Installation:
composer require laravel/reverb
php artisan reverb:install
Key environment variables in .env:
BROADCAST_DRIVER=reverb
REVERB_APP_ID=my-app
REVERB_APP_KEY=my-key
REVERB_APP_SECRET=my-secret
REVERB_HOST=0.0.0.0
REVERB_PORT=8080
REVERB_SCHEME=http
# For horizontal scaling
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
Running in production via Supervisor:
[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/reverb.log
When scaling horizontally, Reverb uses Redis to synchronize state across multiple server instances. This is enabled via scaling in config/reverb.php:
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'driver' => 'redis',
'redis' => [
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
],
],
Asynchronous Delivery: Queues, Batching, Heavy Channels
Email and SMS are expensive operations: calls to external APIs, potential timeouts, rate limits. Sending them synchronously within an HTTP request is an anti-pattern. Laravel addresses this through the ShouldQueue interface.
Queue priority separation strategy:
// Running workers for different queues
// High-priority (in-app, broadcast) — fast workers
php artisan queue:work redis --queue=notifications-critical,notifications-high,default
// Slow channels (email, SMS) — separate pool
php artisan queue:work redis --queue=notifications-email,notifications-sms --timeout=60
Batching notifications via Bus::batch() allows tracking group delivery:
<?php
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Notification;
$users = User::whereIn('id', $targetIds)->cursor();
$jobs = collect();
foreach ($users as $user) {
$jobs->push(new SendNotificationJob($user, new CampaignNotification($campaign)));
}
Bus::batch($jobs)
->then(function (Batch $batch) use ($campaign) {
$campaign->markAsDelivered();
})
->catch(function (Batch $batch, \Throwable $e) {
Log::error('Batch notification failed', ['error' => $e->getMessage()]);
})
->onQueue('notifications-email')
->dispatch();
Scaling: Workers, Multiple Instances, Stateless Approach
As load increases, the system must scale horizontally. A few key principles:
- Stateless application: the Laravel application does not store WebSocket connection state — that's Reverb's responsibility. The application only publishes events to Redis.
- Multiple Reverb instances: with Redis-based scaling enabled, all instances synchronize channel state. The load balancer (Nginx/HAProxy) distributes WebSocket connections — sticky sessions are not required, since Reverb synchronizes itself via Redis Pub/Sub.
- Worker scaling: add workers horizontally. Use
--max-jobsand--max-timeto prevent memory leaks.
Example Nginx configuration for WebSocket proxying:
upstream reverb_servers {
# stateless — no sticky sessions needed thanks to Redis scaling
least_conn;
server reverb-1:8080;
server reverb-2:8080;
server reverb-3:8080;
}
server {
listen 443 ssl;
server_name ws.example.com;
location / {
proxy_pass http://reverb_servers;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
In a containerized environment (Kubernetes / Docker Swarm), horizontal scaling via a microservices approach feels natural: Reverb pods scale independently of PHP-FPM pods, with Redis serving as the shared message bus.
Reliability: Retries, Dead Letter Queue, Monitoring
A reliable notification system must survive failures in external services. A few best practices:
Retries with Exponential Backoff
<?php
namespace App\Notifications;
use Illuminate\Notifications\Notification;
class EmailAlertNotification extends Notification
{
public int $tries = 5;
public int $maxExceptions = 3;
public function backoff(): array
{
// 1 min → 5 min → 15 min → 30 min → 60 min
return [60, 300, 900, 1800, 3600];
}
public function retryUntil(): \DateTime
{
return now()->addHours(6);
}
}
Dead Letter Queue
Messages that have exhausted all retry attempts are moved to the failed_jobs table. Set up monitoring for this table:
// In AppServiceProvider or a dedicated monitoring service
Schedule::command('queue:failed-jobs-check')
->everyFiveMinutes()
->withoutOverlapping();
// Custom command
public function handle(): void
{
$failedCount = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subHour())
->count();
if ($failedCount > 10) {
// Alert to Slack/PagerDuty
Notification::route('slack', config('alerts.slack_webhook'))
->notify(new QueueHealthAlert($failedCount));
}
}
Monitoring with Laravel Horizon
Laravel Horizon is an essential tool for production systems using Redis queues. It provides a visual dashboard with throughput metrics, error rates, and processing times.
// config/horizon.php — splitting workers by queue
'environments' => [
'production' => [
'supervisor-notifications-realtime' => [
'connection' => 'redis',
'queue' => ['notifications-critical', 'notifications-high'],
'balance' => 'auto',
'processes' => 8,
'tries' => 3,
],
'supervisor-notifications-async' => [
'connection' => 'redis',
'queue' => ['notifications-email', 'notifications-sms'],
'balance' => 'simple',
'processes' => 4,
'tries' => 5,
'timeout' => 120,
],
],
],
Conclusion: Production-Ready Notification System Checklist
Before releasing the system to production, go through this checklist:
- Data model: extended with
status,priority,group_key,expires_atfields; indexes created. - Channels authenticated: private channels protected via
Broadcast::channel(). - Broadcast implemented: notification implements
toBroadcast()andbroadcastOn(). - All channels are asynchronous: notification implements
ShouldQueue. - Queues separated by priority: the real-time queue is not blocked by heavy email jobs.
- Laravel Reverb configured with Redis scaling for horizontal scalability.
- Nginx/load balancer correctly proxies WebSocket connections (Upgrade headers).
- Retries and backoff configured for each channel.
- Dead letter queue monitored automatically with alerts.
- Laravel Horizon running under Supervisor with separate supervisor groups.
- Load testing completed: the WebSocket server handles the target number of concurrent connections.
- Notification expiry policy implemented: old records are archived or deleted.
A reactive notification system is not a single feature — it's a full subsystem with its own architecture, fault tolerance, and operational requirements. Investing in the right foundation pays off when load grows tenfold.
By using Laravel, Redis, and Reverb together, you get a production-ready stack that covers most scenarios — from a small SaaS to a high-load platform. The key is proper separation of responsibilities: the application publishes, Redis transports, Reverb delivers.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →