Architecture

Building a Reactive Notification System with Laravel and WebSocket: Architecture, Queues, and Scaling

Ruslan Ismailov Published 14 min read
B

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-jobs and --max-time to 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:

  1. Data model: extended with status, priority, group_key, expires_at fields; indexes created.
  2. Channels authenticated: private channels protected via Broadcast::channel().
  3. Broadcast implemented: notification implements toBroadcast() and broadcastOn().
  4. All channels are asynchronous: notification implements ShouldQueue.
  5. Queues separated by priority: the real-time queue is not blocked by heavy email jobs.
  6. Laravel Reverb configured with Redis scaling for horizontal scalability.
  7. Nginx/load balancer correctly proxies WebSocket connections (Upgrade headers).
  8. Retries and backoff configured for each channel.
  9. Dead letter queue monitored automatically with alerts.
  10. Laravel Horizon running under Supervisor with separate supervisor groups.
  11. Load testing completed: the WebSocket server handles the target number of concurrent connections.
  12. 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 →