Laravel Reverb: запускаем собственный WebSocket-сервер для real-time приложений на Laravel — PROG-TIME

Laravel Reverb: запускаем собственный WebSocket-сервер для real-time приложений на Laravel

29.05.2026

WebSocket-связь давно перестала быть экзотикой: чаты, нотификации, живые дашборды и совместное редактирование документов давно стали стандартом. До недавнего времени Laravel-разработчикам приходилось выбирать между платным Pusher, самодельной обвязкой вокруг Soketi или экспериментами с Ratchet и Swoole. С релизом Laravel Reverb ситуация изменилась: команда Laravel выпустила официальный WebSocket-сервер, который полностью совместим с протоколом Pusher, отлично интегрируется с Laravel Echo и при этом работает на привычном PHP-стеке.

В этой статье разберём весь путь: установку, настройку broadcasting, фронтенд на Echo, приватные и presence-каналы, запуск Reverb как сервиса, масштабирование через Redis Pub/Sub и пару практических трюков для прода.

Что такое Laravel Reverb

Reverb — это самостоятельный WebSocket-сервер, написанный на PHP с использованием ReactPHP. Он реализует протокол Pusher Channels, поэтому в качестве клиента можно использовать стандартный laravel-echo с драйвером pusher-js, не подключая никаких сторонних SDK. Reverb разрабатывается командой Laravel и официально поддерживается в составе фреймворка, что снимает большую часть рисков при выборе технологии для long-running соединений.

Ключевые отличия от альтернатив:

  • работает на PHP без отдельной Node.js-инфраструктуры;
  • горизонтально масштабируется через Redis Pub/Sub;
  • поддерживает public-, private- и presence-каналы из коробки;
  • интегрируется с системой broadcasting Laravel без костылей;
  • есть встроенные метрики и Pulse-карточка для мониторинга.

Требования и установка

Для запуска понадобится PHP 8.2+, Laravel 11+ и расширения ext-sockets и ext-pcntl. Если вы используете официальные docker-образы php:8.3-cli или php:8.3-fpm, оба расширения нужно собрать вручную — об этом ниже.

Устанавливаем Reverb через artisan-команду install:

php artisan install:broadcasting

Artisan задаст несколько вопросов: какой драйвер использовать (выбираем reverb), нужно ли установить Node-зависимости (соглашаемся, чтобы получить актуальные версии laravel-echo и pusher-js), а также добавить ли пример Vue/React-компонента. После выполнения команды:

  • в config/broadcasting.php появится секция reverb;
  • в .env добавятся переменные REVERB_APP_ID, REVERB_APP_KEY, REVERB_APP_SECRET, REVERB_HOST, REVERB_PORT, REVERB_SCHEME;
  • будет включён BroadcastServiceProvider и подключён файл routes/channels.php;
  • в resources/js/bootstrap.js появится готовая инициализация Echo.

Запускаем сервер:

php artisan reverb:start --host=0.0.0.0 --port=8080

В консоли появится строка вида INFO Starting server on 0.0.0.0:8080 (development). Теперь Reverb принимает WebSocket-соединения на порту 8080.

Конфигурация broadcasting

В .env переключаем драйвер вещания на Reverb:

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=local-app
REVERB_APP_KEY=base64:randomly-generated-key
REVERB_APP_SECRET=base64:another-random-secret
REVERB_HOST=ws.example.com
REVERB_PORT=443
REVERB_SCHEME=https

Обратите внимание: для прода REVERB_HOST должен указывать на ваш публичный домен, а соединение завернуто в TLS. На локальной машине удобнее работать с localhost, 8080 и схемой http.

Раздел config/reverb.php позволяет описать несколько приложений (мультитенантный сценарий), задать лимиты пинга, активировать scaling через Redis и разрешить определённые origin-домены:

'apps' => [
    'apps' => [
        [
            'app_id' => env('REVERB_APP_ID'),
            'key' => env('REVERB_APP_KEY'),
            'secret' => env('REVERB_APP_SECRET'),
            'options' => [
                'host' => env('REVERB_HOST'),
                'port' => env('REVERB_PORT', 443),
                'scheme' => env('REVERB_SCHEME', 'https'),
                'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
            ],
            'allowed_origins' => ['app.example.com'],
            'ping_interval' => 60,
            'max_message_size' => 10_000,
        ],
    ],
],

'scaling' => [
    'enabled' => env('REVERB_SCALING_ENABLED', false),
    'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
    'server' => [
        'url' => env('REDIS_URL'),
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'port' => env('REDIS_PORT', 6379),
        'username' => env('REDIS_USERNAME'),
        'password' => env('REDIS_PASSWORD'),
        'database' => env('REDIS_DB', 0),
    ],
],

Первое событие и broadcast

Создадим событие, которое будет уходить во все подключённые браузеры. Например, новое сообщение в чате:

php artisan make:event MessageSent

Заполняем класс:

namespace App\Events;

use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageSent implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Message $message) {}

    public function broadcastOn(): array
    {
        return [new Channel('chat.' . $this->message->room_id)];
    }

    public function broadcastWith(): array
    {
        return [
            'id'       => $this->message->id,
            'body'     => $this->message->body,
            'author'   => $this->message->user->name,
            'sent_at'  => $this->message->created_at->toIso8601String(),
        ];
    }
}

Дальше — отправляем событие из контроллера:

public function store(StoreMessageRequest $request, Room $room)
{
    $message = $room->messages()->create([
        'user_id' => $request->user()->id,
        'body'    => $request->validated('body'),
    ]);

    broadcast(new MessageSent($message))->toOthers();

    return response()->noContent();
}

Метод toOthers() исключает из доставки сокет-отправителя — это удобно, когда отправитель уже добавил сообщение в локальное состояние через optimistic UI.

Frontend на 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,
    wssPort: import.meta.env.VITE_REVERB_PORT,
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

Не забываем продублировать переменные с префиксом VITE_ в .env, иначе Vite не отдаст их в бандл:

VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"

Подписываемся на канал из любого компонента:

window.Echo.channel(`chat.${roomId}`)
    .listen('MessageSent', (event) => {
        store.commit('chat/append', event);
    });

Приватные и presence-каналы

Public-каналы хороши для публичных лент, но в большинстве приложений нужна авторизация. Laravel предоставляет два специальных типа каналов:

  • Private — доступ только авторизованным пользователям с подтверждённым правом подписаться;
  • Presence — то же самое плюс список присутствующих участников.

Описываем правила в routes/channels.php:

use App\Models\Room;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('chat.{roomId}', function ($user, int $roomId) {
    return Room::findOrFail($roomId)->hasMember($user);
});

Broadcast::channel('presence-room.{roomId}', function ($user, int $roomId) {
    if (! Room::findOrFail($roomId)->hasMember($user)) {
        return false;
    }

    return [
        'id'     => $user->id,
        'name'   => $user->name,
        'avatar' => $user->avatar_url,
    ];
});

В событии меняем тип канала:

use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Broadcasting\PresenceChannel;

public function broadcastOn(): array
{
    return [
        new PrivateChannel('chat.' . $this->message->room_id),
        new PresenceChannel('presence-room.' . $this->message->room_id),
    ];
}

На фронте используем private() и join():

window.Echo.private(`chat.${roomId}`)
    .listen('MessageSent', handler);

window.Echo.join(`presence-room.${roomId}`)
    .here((users) => store.commit('presence/init', users))
    .joining((user) => store.commit('presence/add', user))
    .leaving((user) => store.commit('presence/remove', user));

Авторизация подписки выполняется HTTP-запросом на /broadcasting/auth, который Laravel поднимает автоматически. Если приложение работает на нескольких поддоменах, не забудьте корректно настроить Sanctum или сессионные cookie.

Запуск Reverb как сервиса

В разработке достаточно выполнить php artisan reverb:start, но на проде сервер должен подниматься автоматически и переживать падения. Самый простой вариант — systemd-юнит:

# /etc/systemd/system/reverb.service
[Unit]
Description=Laravel Reverb WebSocket server
After=network.target redis.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
Restart=always
RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=15
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Перезагружаем systemd и включаем юнит:

sudo systemctl daemon-reload
sudo systemctl enable --now reverb.service
sudo systemctl status reverb.service

Альтернатива — Supervisor с конфигурацией autorestart=true и stopwaitsecs=10. Принцип тот же: один процесс php artisan reverb:start, ровно как и для очередей.

Reverb в Docker

Удобно держать Reverb в отдельном контейнере, который шарит код с PHP-FPM, но запускает другой процесс. Минимальный Dockerfile (расширения для long-running PHP):

FROM php:8.3-cli-alpine

RUN apk add --no-cache linux-headers $PHPIZE_DEPS \
    && docker-php-ext-install sockets pcntl \
    && apk del $PHPIZE_DEPS

WORKDIR /app
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer

CMD ["php", "artisan", "reverb:start", "--host=0.0.0.0", "--port=8080"]

А вот фрагмент docker-compose.yml, где Reverb стоит рядом с приложением и Redis:

services:
  app:
    build: .
    volumes:
      - ./:/app
    depends_on:
      - redis

  reverb:
    build:
      context: .
      dockerfile: docker/reverb.Dockerfile
    volumes:
      - ./:/app
    environment:
      REVERB_SCALING_ENABLED: "true"
      REDIS_HOST: redis
    depends_on:
      redis:
        condition: service_healthy
    ports:
      - "8080:8080"
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/up"]
      interval: 30s
      timeout: 5s
      retries: 3

  redis:
    image: redis:7-alpine
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

Reverb отдаёт healthcheck по адресу /up, что удобно как для Docker, так и для Kubernetes liveness/readiness-проб.

Масштабирование через Redis Pub/Sub

Один процесс Reverb отлично держит тысячи соединений, но рано или поздно потребуется горизонтальное масштабирование: несколько инстансов за балансировщиком. Чтобы события доходили до всех клиентов, независимо от того, к какому процессу они подключены, включаем scaling:

REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb
REDIS_HOST=redis
REDIS_PORT=6379

В этом режиме каждый процесс Reverb становится подписчиком Redis-канала и пересылает события всем своим клиентам. Балансировщик (Nginx, Traefik, HAProxy) должен поддерживать sticky-сессии — иначе клиент рискует переподключаться к разным процессам и терять presence-состояние.

Пример upstream-секции для Nginx:

upstream reverb_backend {
    ip_hash;
    server reverb-1:8080;
    server reverb-2:8080;
    server reverb-3:8080;
}

server {
    listen 443 ssl http2;
    server_name ws.example.com;

    ssl_certificate     /etc/letsencrypt/live/ws.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/ws.example.com/privkey.pem;

    location / {
        proxy_pass http://reverb_backend;
        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;
    }
}

Директива ip_hash обеспечивает sticky-балансировку, а большие таймауты не дают Nginx закрыть «висящие» соединения.

Дебаг и мониторинг

Reverb выводит подробные логи при запуске с флагом --debug:

php artisan reverb:start --debug

В логе видно подключения, подписки, исходящие события и ошибки авторизации — на этапе разработки этого достаточно. На проде логи лучше отправлять в системный journal через systemd либо собирать stdout контейнера через docker logging driver.

Если у вас подключён Laravel Pulse, добавьте рекордер событий Reverb в config/pulse.php:

'recorders' => [
    \Laravel\Reverb\Pulse\Recorders\ReverbConnections::class => [
        'sample_rate' => 1,
    ],
    \Laravel\Reverb\Pulse\Recorders\ReverbMessages::class => [
        'sample_rate' => 1,
    ],
],

На дашборде Pulse появятся карточки с числом активных соединений и пропускной способностью — это закрывает большинство вопросов «а что у нас сейчас с WebSocket-ами?».

Чек-лист для прода

  • включите TLS и проксируйте Reverb через Nginx/Traefik с поднятым Upgrade;
  • задайте allowed_origins и не оставляйте * в проде;
  • увеличьте LimitNOFILE в systemd и ulimit -n в контейнере — для тысяч соединений потребуется 65535 или больше;
  • включите REVERB_SCALING_ENABLED=true при работе нескольких процессов;
  • настройте мониторинг healthcheck-эндпойнта /up в Uptime Kuma, Pulse или Prometheus blackbox-exporter;
  • отделите Reverb от FPM-контейнера, чтобы релизы приложения не вызывали разрывы long-running соединений;
  • храните REVERB_APP_SECRET в секретах CI/CD, не в git.

Итоги

Laravel Reverb закрывает давнюю боль PHP-экосистемы: больше не нужно платить Pusher за каждое сообщение или поддерживать сторонние Node-серверы. Конфигурация умещается в пару переменных окружения, фронтенд работает на привычном Echo, а масштабирование сводится к включению Redis Pub/Sub и sticky-балансировки. Если ваше приложение уже использует Laravel и нуждается в real-time — миграция на Reverb займёт буквально несколько часов и избавит проект от лишней инфраструктуры.

В следующих статьях мы рассмотрим, как добавить к Reverb очереди и события Eloquent через shouldBroadcast(), а также покажем тонкую настройку Pulse для отслеживания деградаций. Если вы уже запускаете Reverb в проде — поделитесь опытом в комментариях, особенно интересны кейсы с большим числом одновременных соединений и нестандартными балансировщиками.