Построение реактивной системы уведомлений на Laravel и WebSocket: архитектура, очереди и масштабирование
Введение: почему real-time уведомления требуют отдельной архитектуры
Уведомления в современных веб-приложениях делятся на несколько принципиально разных типов: in-app (счётчик колокольчика), email, push (браузерные и мобильные) и SMS. Каждый из них имеет разные требования к скорости доставки, надёжности и каналу передачи.
Ключевой вопрос: можно ли обойтись polling — периодическим запросом к серверу каждые N секунд? Технически да, но цена очевидна: нагрузка на базу данных растёт линейно от количества пользователей, задержка доставки неприемлема для чатов и алертов, а масштабирование превращается в боль. Именно поэтому real-time уведомления требуют отдельной архитектурной ветки: постоянного соединения (WebSocket или SSE) плюс асинхронного конвейера обработки на стороне сервера.
В этой статье мы разберём, как построить такую систему на базе Laravel, Redis и Laravel Reverb — от модели данных до горизонтального масштабирования в продакшене.
Обзор стека: как части складываются в систему
Стек состоит из нескольких уровней, каждый из которых решает свою задачу:
- Laravel Notifications — высокоуровневый API для отправки уведомлений через разные каналы (mail, database, broadcast, Slack и т.д.).
- Laravel Broadcasting — механизм публикации событий в WebSocket-каналы; работает поверх драйверов Pusher, Ably или собственного сервера.
- Redis — двойная роль: очередь задач (driver
redis) и транспорт Pub/Sub между приложением и WebSocket-сервером. - Laravel Reverb — нативный первопартийный WebSocket-сервер, представленный в Laravel 11; заменяет Soketi и сторонние решения.
- Queue Workers — обрабатывают тяжёлые каналы (email, SMS) асинхронно, не блокируя HTTP-запрос.
Поток данных выглядит так: HTTP-запрос или событие домена → Laravel создаёт уведомление → пишет в БД и отправляет в очередь → воркер обрабатывает канал → Redis Pub/Sub → Reverb → WebSocket → браузер.
Проектирование модели уведомлений
Стандартная таблица notifications, которую создаёт Laravel, минималистична. Для production-системы её стоит расширить.
-- Миграция расширенной таблицы уведомлений
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type VARCHAR(255) NOT NULL, -- класс уведомления
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, -- для группировки похожих
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)
);
Поле group_key позволяет схлопывать однотипные уведомления: вместо «10 новых комментариев» пользователь видит одну карточку с числом. Поле priority управляет порядком обработки в очереди — критические уведомления попадают в отдельную высокоприоритетную очередь.
Модель в 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 и каналы: private, presence, аутентификация
Broadcasting в Laravel строится на концепции каналов. Три типа:
- Public — любой может подписаться; подходит для глобальных объявлений.
- Private — требует аутентификации пользователя; стандарт для персональных уведомлений.
- Presence — расширение private; сервер знает, кто сейчас онлайн; используется для «X пользователей читают».
Пример уведомления с поддержкой broadcast:
<?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' => "Заказ #{$this->order->number} изменил статус на {$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
)];
}
// Кастомное имя события на клиенте
public function broadcastAs(): string
{
return 'notification.new';
}
}
Авторизация private-каналов настраивается в routes/channels.php:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('users.{userId}', function ($user, $userId) {
return (int) $user->id === (int) $userId;
});
// Presence-канал для команды
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
if ($user->teams->contains($teamId)) {
return ['id' => $user->id, 'name' => $user->name];
}
});
Redis как транспорт: Pub/Sub между приложением и WebSocket-сервером
Когда Laravel публикует событие в broadcast-канал, он сериализует его в JSON и публикует в Redis-канал через PUBLISH. WebSocket-сервер подписан на этот канал через SUBSCRIBE и пересылает сообщение всем подключённым клиентам в нужном канале.
Это классический паттерн Redis Pub/Sub, и он критически важен для масштабирования: несколько инстансов приложения могут публиковать сообщения независимо, а WebSocket-сервер выступает единой точкой доставки.
Настройка в 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'),
],
],
],
];
Для Redis как транспорта очередей в config/queue.php:
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => true, // важно: отправляем после успешного коммита транзакции
],
Интеграция с Laravel Reverb: конфигурация и запуск
Laravel Reverb — нативный WebSocket-сервер, написанный на PHP поверх ReactPHP. Он появился в Laravel 11 и решает главную боль — зависимость от Pusher или необходимость поддерживать отдельный Node.js-сервис.
Установка:
composer require laravel/reverb
php artisan reverb:install
Ключевые переменные в .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
# Для горизонтального масштабирования
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
Запуск в продакшене через 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
При горизонтальном масштабировании Reverb использует Redis для синхронизации состояния между несколькими инстансами сервера. Это включается через scaling в config/reverb.php:
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'driver' => 'redis',
'redis' => [
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
],
],
Асинхронная отправка: очереди, батчинг, тяжёлые каналы
Email и SMS — дорогостоящие операции: обращение к внешнему API, возможные таймауты, rate limits. Отправлять их синхронно в HTTP-запросе — антипаттерн. Laravel решает это через интерфейс ShouldQueue.
Стратегия разделения очередей по приоритетам:
// Запуск воркеров для разных очередей
// Высокоприоритетные (in-app, broadcast) — быстрые воркеры
php artisan queue:work redis --queue=notifications-critical,notifications-high,default
// Медленные каналы (email, SMS) — отдельный пул
php artisan queue:work redis --queue=notifications-email,notifications-sms --timeout=60
Батчинг уведомлений через Bus::batch() позволяет отслеживать групповую отправку:
<?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();
Масштабирование: воркеры, несколько инстансов, stateless-подход
При росте нагрузки система должна масштабироваться горизонтально. Несколько принципов:
- Stateless приложение: Laravel-приложение не хранит состояние WebSocket-соединений — это задача Reverb. Приложение только публикует события в Redis.
- Несколько Reverb-инстансов: при включённом scaling через Redis все инстансы синхронизируют состояние каналов. Балансировщик (Nginx/HAProxy) распределяет WebSocket-соединения — здесь не нужны sticky sessions, так как Reverb сам синхронизируется через Redis Pub/Sub.
- Масштабирование воркеров: добавляйте воркеры горизонтально. Используйте
--max-jobsи--max-timeдля предотвращения утечек памяти.
Пример Nginx-конфигурации для WebSocket-проксирования:
upstream reverb_servers {
# stateless — без sticky sessions благодаря 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;
}
}
Для контейнерного окружения (Kubernetes / Docker Swarm) горизонтальное масштабирование через Microservices-подход выглядит естественно: Reverb-поды масштабируются независимо от PHP-FPM-подов, а Redis выступает общей шиной.
Надёжность: повторные попытки, dead letter queue, мониторинг
Надёжная система уведомлений должна переживать сбои внешних сервисов. Несколько практик:
Повторные попытки с экспоненциальной задержкой
<?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 мин → 5 мин → 15 мин → 30 мин → 60 мин
return [60, 300, 900, 1800, 3600];
}
public function retryUntil(): \DateTime
{
return now()->addHours(6);
}
}
Dead Letter Queue
Сообщения, исчерпавшие все попытки, попадают в таблицу failed_jobs. Настройте мониторинг этой таблицы:
// В AppServiceProvider или отдельном мониторинговом сервисе
Schedule::command('queue:failed-jobs-check')
->everyFiveMinutes()
->withoutOverlapping();
// Кастомная команда
public function handle(): void
{
$failedCount = DB::table('failed_jobs')
->where('failed_at', '>=', now()->subHour())
->count();
if ($failedCount > 10) {
// Алерт в Slack/PagerDuty
Notification::route('slack', config('alerts.slack_webhook'))
->notify(new QueueHealthAlert($failedCount));
}
}
Мониторинг с Laravel Horizon
Laravel Horizon — обязательный инструмент для production-систем с Redis-очередями. Он даёт визуальный дашборд, метрики throughput, ошибки и время обработки.
// config/horizon.php — разделение воркеров по очередям
'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,
],
],
],
Заключение: чек-лист production-ready системы уведомлений
Прежде чем выпускать систему в продакшен, пройдитесь по этому списку:
- Модель данных: расширена полями
status,priority,group_key,expires_at; индексы созданы. - Каналы аутентифицированы: private-каналы защищены через
Broadcast::channel(). - Broadcast реализован: уведомление реализует
toBroadcast()иbroadcastOn(). - Все каналы асинхронны: уведомление реализует
ShouldQueue. - Очереди разделены по приоритетам: realtime-очередь не блокируется тяжёлыми email-задачами.
- Laravel Reverb настроен с Redis-scaling для горизонтального масштабирования.
- Nginx/балансировщик правильно проксирует WebSocket-соединения (заголовки Upgrade).
- Повторные попытки и backoff настроены для каждого канала.
- Dead letter queue мониторится автоматически с алертами.
- Laravel Horizon запущен под Supervisor с раздельными supervisor-группами.
- Нагрузочное тестирование проведено: WebSocket-сервер держит целевое число одновременных соединений.
- Политика истечения срока уведомлений реализована: старые записи архивируются или удаляются.
Реактивная система уведомлений — это не одна фича, а полноценная подсистема со своей архитектурой, отказоустойчивостью и операционными требованиями. Инвестиции в правильный фундамент окупаются, когда нагрузка вырастает в 10 раз.
Используя Laravel, Redis и Reverb совместно, вы получаете production-ready стек, который покрывает большинство сценариев от небольшого SaaS до highload-платформы. Ключ — правильное разделение ответственности: приложение публикует, Redis транспортирует, Reverb доставляет.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →