Laravel Reverb: WebSocket в Laravel 13 — каналы, Echo и продакшен

Laravel Reverb: WebSocket в Laravel 13 — каналы, Echo и продакшен
содержание

Введение

Статус заказа без перезагрузки страницы, уведомления, счётчики, чат — задачи для WebSocket. В Laravel для них есть собственный сервер — Reverb. Его разрабатывает команда Laravel, написан он на PHP поверх ReactPHP и использует протокол Pusher. Благодаря этому Reverb без доработок встаёт под Laravel Broadcasting (трансляцию событий) и клиентскую библиотеку Laravel Echo, а платный Pusher и отдельный Node-сервер становятся не нужны.

Раньше ту же роль играл beyondcode/laravel-websockets — о нём была прежняя статья про laravel-websockets. С 7 февраля 2024 года репозиторий в архиве, а README сам направляет к Reverb. Здесь вся цепочка «событие → канал → браузер → продакшен» разобрана на Laravel 13.x и Reverb 1.12. Понадобятся Laravel 11 или новее (Reverb работает на PHP 8.2+, сам Laravel 13 — на PHP 8.3+), Node.js для сборки фронта и воркер очереди.

WebSocket нужен не всегда. Если сервер лишь изредка сообщает что-то клиенту, проще обойтись SSE или периодическим опросом. Есть и внешние серверы — Centrifugo, Mercure: с Laravel они совместимы, но их инфраструктуру придётся поднимать и сопровождать отдельно.

Как это устроено: схема в пять шагов

По сути Broadcasting — модель pub/sub, тот же паттерн «Канал событий»: событие публикуется в канал с именем, а отправитель понятия не имеет, кто на этот канал подписан. Путь одного события выглядит так:

  1. Приложение вызывает событие с интерфейсом ShouldBroadcast.
  2. Событие ставится в очередь; воркер забирает его и HTTP-запросом передаёт в Reverb (путь /apps).
  3. Reverb раздаёт сообщение всем WebSocket-соединениям, которые подписаны на этот канал (путь /app).
  4. В браузере сообщение принимает Echo и вызывает обработчик.
  5. Если канал приватный или presence, Echo до подписки отправляет POST на /broadcasting/auth, а Laravel проверяет правило из routes/channels.php.

Отсюда видно, что без воркера очереди до Reverb не доходит ни одно событие, а без правила в routes/channels.php не откроется ни один приватный канал.

В .env есть две группы переменных с почти одинаковыми названиями, и многие проблемы с подключением начинаются с того, что их путают:

Переменные Что означают Пример в продакшене
REVERB_SERVER_HOST, REVERB_SERVER_PORT адрес и порт, на которых слушает сам Reverb 127.0.0.1, 8080
REVERB_HOST, REVERB_PORT, REVERB_SCHEME адрес, по которому к Reverb обращаются Laravel и браузер ws.example.ru, 443, https

Установка

php artisan install:broadcasting --reverb

С флагом --reverb команда не спрашивает, какой драйвер выбрать. Дальше она сама ставит laravel/reverb и вызывает reverb:install, создаёт routes/channels.php, config/broadcasting.php и config/reverb.php, регистрирует каналы в bootstrap/app.php, кладёт resources/js/echo.js и устанавливает npm-пакеты laravel-echo и pusher-js (в проектах на Vue и React — @laravel/echo-vue / @laravel/echo-react). Когда фронт живёт в отдельном репозитории, npm-часть отключается флагом --without-node.

Composer-пакеты команда устанавливает с --with-all-dependencies, и на свежем Laravel 13 с Guzzle 8 это важно: Reverb 1.12 требует guzzlehttp/psr7 2.x, поэтому Guzzle откатится до 7.x. Если же ставить пакет руками через composer require laravel/reverb без -W, Composer остановится с ошибкой.

После установки в .env окажутся такие переменные (ключи генерируются случайно):

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=127716
REVERB_APP_KEY=случайная-строка
REVERB_APP_SECRET=случайная-строка
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=http

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

Секретом из этой пары является только REVERB_APP_SECRET — он не должен покидать сервер. REVERB_APP_KEY, напротив, публичный: через VITE_REVERB_APP_KEY он попадает в JavaScript. Для разработки запускаются три процесса:

php artisan reverb:start --debug   # слушает 0.0.0.0:8080, --debug печатает трафик
php artisan queue:work             # воркер передаёт события в Reverb
npm run dev

Начиная с Laravel 13.32 install:broadcasting знает и флаг --mercure — это другой драйвер. Mercure, к примеру, встроен в FrankenPHP.

Первое событие: ShouldBroadcast и каналы

Заготовку события создаёт php artisan make:event OrderStatusUpdated. Статус заказа должен видеть только его владелец, поэтому канал сразу делается приватным; для публичного канала отличается лишь одна строка:

namespace App\Events;

use App\Models\Order;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class OrderStatusUpdated implements ShouldBroadcast, ShouldDispatchAfterCommit
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        // для публичного канала: new Channel('orders')
        return [new PrivateChannel('orders.'.$this->order->id)];
    }

    public function broadcastAs(): string
    {
        return 'order.updated';
    }

    public function broadcastWith(): array
    {
        // отдаём клиенту только нужные поля, а не всю модель
        return [
            'id' => $this->order->id,
            'status' => $this->order->status,
        ];
    }
}

Что здесь за что отвечает:

  • broadcastOn() перечисляет каналы. Тип канала задаёт класс: Channel — публичный, PrivateChannel — приватный, PresenceChannel — канал с присутствием.
  • broadcastWith() нужен прежде всего ради безопасности. Если его не определить, в канал уйдут все публичные свойства события — здесь это модель Order целиком, со всеми полями.
  • broadcastAs() задаёт имя события order.updated вместо имени класса. Слушать такое имя на клиенте нужно с ведущей точкой: .order.updated.
  • ShouldDispatchAfterCommit придерживает событие до коммита транзакции. Иначе воркер может разослать его раньше, клиент запросит свежие данные и получит ещё старые.
  • ShouldBroadcastNow (вместо ShouldBroadcast) отправляет событие сразу, минуя очередь. Для отладки это удобно, но HTTP-ответ из-за этого становится медленнее.

Событие отправляется одним из двух способов:

OrderStatusUpdated::dispatch($order);

// разослать всем, кроме того, кто внёс изменение
broadcast(new OrderStatusUpdated($order))->toOthers();

toOthers() пригодится, когда автор изменения уже обновил интерфейс у себя и повторное событие ему ни к чему. Без трейта InteractsWithSockets метод не работает: Laravel читает socket id из заголовка X-Socket-ID и просит Reverb пропустить это соединение при рассылке. Запросы через глобальный window.axios (а также jQuery и Turbo) Echo дополняет заголовком сам, а при работе через fetch его придётся передать явно:

await fetch(`/orders/${orderId}/status`, {
    method: 'PATCH',
    headers: {
        'Content-Type': 'application/json',
        'X-Socket-ID': window.Echo.socketId(),
        // web-маршрутам нужен ещё и X-CSRF-TOKEN
    },
    body: JSON.stringify({ status: 'shipped' }),
});

Если серверов Reverb несколько, toOthers() работает корректно начиная с Reverb 1.11.

Приватный канал и авторизация

Кто имеет право слушать канал, решает правило в routes/channels.php. Параметры в имени канала связываются с моделями так же, как в маршрутах:

use App\Models\Order;
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('orders.{order}', function (User $user, Order $order) {
    return $user->id === $order->user_id;
});

Что происходит при вызове Echo.private('orders.42'): клиент передаёт на /broadcasting/auth свой socket id и имя канала private-orders.42. Laravel ищет подходящее правило, подставляет в колбэк текущего пользователя и заказ, и если ответ true — возвращает подпись, по которой Reverb разрешит подписку. Когда вместо подписи приходит 403, причин обычно три: колбэк вернул false, пользователь не вошёл в гарде этого маршрута или для такого имени канала правила нет вовсе.

Из коробки /broadcasting/auth входит в группу web и опирается на сессию и cookie. SPA или мобильному приложению с токеном Sanctum это не подходит, и маршрут регистрируется по-другому: в bootstrap/app.php из withRouting() убирают channels: и добавляют withBroadcasting():

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        health: '/up',
    )
    ->withBroadcasting(
        __DIR__.'/../routes/channels.php',
        ['prefix' => 'api', 'middleware' => ['api', 'auth:sanctum']],
    )
    ->create();

Теперь авторизация живёт по адресу /api/broadcasting/auth. Оставлять одновременно channels: и withBroadcasting() не стоит: маршрут и правила зарегистрируются дважды. На стороне клиента в Echo 2.x есть опция bearerToken — с ней Echo сам подставит заголовок Authorization: Bearer … в запрос авторизации. Тот же результат вручную даёт auth: { headers: { Authorization: 'Bearer …' } }.

window.Echo = new Echo({
    broadcaster: 'reverb',
    // ...прочие опции из echo.js
    authEndpoint: '/api/broadcasting/auth',
    bearerToken: localStorage.getItem('api_token'),
});

Токен считывается один раз — в момент создания экземпляра. Если пользователь вошёл позже, Echo создают заново.

Presence-канал: кто сейчас онлайн

Presence-канал — это приватный канал, который вдобавок знает, кто в нём находится. Поэтому колбэк возвращает не true, а массив с данными пользователя; null или false означают отказ. Например, так можно показать менеджеров, у которых в админке открыт тот же заказ:

Broadcast::channel('order-editors.{order}', function (User $user, Order $order) {
    if ($user->is_manager) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});
Echo.join(`order-editors.${orderId}`)
    .here((users) => renderEditors(users))      // все, кто уже в канале
    .joining((user) => addEditor(user))
    .leaving((user) => removeEditor(user));

Индикатор «печатает…» можно сделать вообще без участия приложения. Клиенты шлют друг другу client events прямо через Reverb: whisper('typing', {...}) отправляет, listenForWhisper('typing', ...) принимает. Разрешено ли это, определяет ключ accept_client_events_from в config/reverb.php (переменная REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM). Значение по умолчанию members пропускает события только от участников приватных и presence-каналов, all — от кого угодно, а любое другое значение, скажем none, их выключает.

Клиент: Echo, Livewire, Vue/React

Установщик генерирует resources/js/echo.js: там подключается Pusher-клиент, а настройки берутся из VITE_REVERB_*. Подписка на канал заказа добавляется ниже:

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'],
});

// слушаем приватный канал заказа
window.Echo.private(`orders.${orderId}`)
    .listen('.order.updated', (e) => {
        document.querySelector('#order-status').textContent = e.status;
    });

В Livewire JavaScript не нужен — хватает атрибута #[On] с префиксом echo-private:. Вместо {order.id} подставляется значение из публичного свойства компонента, а имя события после запятой начинается с точки, потому что в событии определён broadcastAs():

namespace App\Livewire;

use App\Models\Order;
use Livewire\Attributes\On;
use Livewire\Component;

class OrderStatus extends Component
{
    public Order $order;

    #[On('echo-private:orders.{order.id},.order.updated')]
    public function refreshStatus(array $payload): void
    {
        $this->order->refresh();
    }

    public function render()
    {
        return view('livewire.order-status');
    }
}

Публичный канал слушают с префиксом echo:, presence — с echo-presence:. Во Vue и React ту же работу выполняет хук useEcho() из @laravel/echo-vue / @laravel/echo-react, причём без дополнительных указаний он подписывается на приватный канал:

import { useEcho } from '@laravel/echo-vue';

useEcho(`orders.${orderId}`, '.order.updated', (e) => {
    status.value = e.status;
});

Продакшен

Nginx и .env

Наружу Reverb не смотрит: он слушает внутренний порт, а внешние соединения принимает Nginx. Тот снимает TLS (wss) и проксирует запрос дальше, пробрасывая заголовки Upgrade и Connection. Проще всего отдать Reverb отдельный поддомен:

server {
    listen 443 ssl;
    server_name ws.example.ru;
    # ssl_certificate ... те же, что у основного сайта

    location / {   # сюда попадают и /app (WebSocket), и /apps (HTTP API)
        proxy_http_version 1.1;
        proxy_set_header Host $http_host;
        proxy_set_header Scheme $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";

        proxy_pass http://127.0.0.1:8080;
    }
}

В документации Reverb в proxy_pass стоит http://0.0.0.0:8080, и расхождение здесь намеренное. 0.0.0.0 означает «слушать на всех интерфейсах» — это адрес для сервера, а не для подключения; подключаться надёжнее к 127.0.0.1. Если Nginx и Reverb работают на одной машине, сам Reverb тоже лучше привязать к 127.0.0.1 — тогда порт 8080 не будет виден из интернета:

REVERB_SERVER_HOST=127.0.0.1
REVERB_SERVER_PORT=8080

REVERB_HOST=ws.example.ru
REVERB_PORT=443
REVERB_SCHEME=https

Переменные VITE_* попадают в JavaScript на этапе сборки. После любой правки .env фронт пересобирается (npm run build).

Supervisor и деплой

[supervisord]
minfds=10000

[program:reverb]
command=php /var/www/shop/artisan reverb:start
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/www/shop/storage/logs/reverb.log

Параметр minfds нужен потому, что каждое соединение — это открытый файл, и Supervisor должен иметь право открыть их достаточно. Reverb работает как долгоживущий процесс и новый код после деплоя не подхватывает. Поэтому в скрипт деплоя включают php artisan reverb:restart: команда аккуратно закрывает соединения, а Supervisor запускает сервер заново. Воркер очереди тоже держат под Supervisor.

В Docker для Reverb заводят отдельный сервис на том же образе, что и приложение, с command: php artisan reverb:start и REVERB_SERVER_HOST=0.0.0.0. Внутри контейнера слушать нужно все интерфейсы: с 127.0.0.1 Nginx из соседнего контейнера до Reverb не доберётся.

Сколько соединений и как масштабировать

Предельного числа соединений документация не приводит, но перечисляет, во что можно упереться:

  • ulimit -n — сколько файлов может открыть пользователь; лимит настраивается в /etc/security/limits.conf;
  • stream_select, на котором по умолчанию построен цикл событий, как правило, ограничен 1024 открытыми файлами. Если соединений нужны тысячи, ставится расширение pecl install uv, и Reverb сам перейдёт на ext-uv;
  • у Nginx — worker_rlimit_nofile и worker_connections;
  • диапазон локальных портов системы (/proc/sys/net/ipv4/ip_local_port_range).

Когда одной машины уже не хватает, Reverb масштабируют горизонтально: REVERB_SCALING_ENABLED=true, один Redis на все экземпляры и балансировщик перед ними. Следить за нагрузкой помогают рекордеры Pulse ReverbConnections и ReverbMessages (они работают при запущенном pulse:check). Для тех, кто не хочет сопровождать сервер сам, есть управляемый Reverb в Laravel Cloud.

Можно ли закрыть сервер от анонимов

Полностью закрыть — нет. REVERB_APP_KEY лежит в JavaScript, так что подключиться к серверу и подписаться на публичный канал может любой посетитель страницы. Поэтому защита строится иначе:

  • чувствительные данные идут только через приватные и presence-каналы, доступ к которым решает /broadcasting/auth;
  • в config/reverb.php вместо 'allowed_origins' => ['*'] перечисляются домены сайта;
  • число соединений ограничивается через max_connections (REVERB_APP_MAX_CONNECTIONS), а для сообщений от клиентов включается rate_limiting.

Если не работает

Симптом Причина Что сделать
Браузер подключается к localhost:8080 в сборке остались старые VITE_REVERB_* исправить REVERB_HOST/PORT/SCHEME и пересобрать фронт
Событие отправлено, но до клиента не дошло воркер очереди не запущен держать php artisan queue:work под Supervisor, проверить QUEUE_CONNECTION
403 на /broadcasting/auth правило вернуло false, нет сессии или токена проверить колбэк в routes/channels.php, гард и middleware маршрута, bearerToken
WebSocket connection failed, 404 или 400 за Nginx /app не проксируется или не переданы Upgrade/Connection добавить заголовки и proxy_http_version 1.1
Mixed content, браузер блокирует ws:// сайт работает по https, а клиент — без TLS REVERB_SCHEME=https, REVERB_PORT=443, пересобрать фронт
После деплоя всё ведёт себя по-старому в памяти Reverb остался прежний код добавить php artisan reverb:restart в скрипт деплоя
В --debug событие видно, а слушатель молчит задан broadcastAs(), но имя слушают без точки .listen('.order.updated', ...)
Соединение закрывается сразу после открытия origin отсутствует в allowed_origins добавить домен сайта в config/reverb.php

Переход с laravel-websockets

Проекту, настроенному по прежней статье про laravel-websockets, переезд обойдётся в несколько шагов:

  1. Удалить пакет командой composer remove beyondcode/laravel-websockets, а вместе с ним config/websockets.php и таблицу статистики.
  2. Запустить php artisan install:broadcasting --reverb.
  3. Вместо BROADCAST_DRIVER=pusher прописать BROADCAST_CONNECTION=reverb: в Laravel 11 переменную переименовали.
  4. Учесть смену порта по умолчанию с 6001 на 8080 в Nginx и правилах файрвола.
  5. В настройках Echo поменять broadcaster: 'pusher' на 'reverb' и перейти на переменные VITE_REVERB_*.
  6. В конфиге Supervisor вместо websockets:serve запускать reverb:start.
  7. Встроенную панель статистики заменить на Pulse с рекордерами Reverb.

Сами события, каналы и правила в routes/channels.php остаются как были — протокол не изменился.

Тесты

Проверить, что код приложения отправляет событие, позволяет Event::fake(). Событие вызывается не в самом тесте, а в контроллере — здесь это маршрут из примера с fetch выше:

Event::fake([OrderStatusUpdated::class]);

// контроллер меняет статус и вызывает OrderStatusUpdated::dispatch()
$this->actingAs(User::find($order->user_id))
    ->patchJson("/orders/{$order->id}/status", ['status' => 'shipped'])
    ->assertOk();

Event::assertDispatched(
    OrderStatusUpdated::class,
    fn (OrderStatusUpdated $e) => $e->order->is($order),
);

А вот тест правил каналов легко написать так, что он ничего не проверяет. В phpunit.xml по умолчанию задано BROADCAST_CONNECTION=null, а у драйверов null и log метод авторизации пустой. В итоге /broadcasting/auth отвечает 200 кому угодно, и тест «посторонний получает 403» падает при правильном правиле. Одной смены config('broadcasting.default') в тесте мало: правила из routes/channels.php к этому моменту уже зарегистрированы в null-драйвере. Работает такой вариант — переключить драйвер и подключить правила повторно:

namespace Tests\Feature;

use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;

class OrderChannelTest extends TestCase
{
    use RefreshDatabase;

    protected function setUp(): void
    {
        parent::setUp();

        // null-драйвер правила не проверяет
        config([
            'broadcasting.default' => 'reverb',
            'broadcasting.connections.reverb.key' => 'test-key',
            'broadcasting.connections.reverb.secret' => 'test-secret',
            'broadcasting.connections.reverb.app_id' => 'test-app',
        ]);

        // правила попадают в драйвер, активный на момент подключения файла
        require base_path('routes/channels.php');
    }

    public function test_stranger_cannot_subscribe(): void
    {
        $order = Order::factory()->create();

        $this->actingAs(User::factory()->create())
            ->post('/broadcasting/auth', [
                'socket_id' => '1234.5678',
                'channel_name' => 'private-orders.'.$order->id,
            ])
            ->assertForbidden();
    }
}

Запускать Reverb для этого теста не требуется: при авторизации Laravel лишь подписывает ответ ключами и ни к какому серверу не обращается.

Итог

  • Reverb заменяет заархивированный laravel-websockets: ставится одной командой, говорит по протоколу Pusher, Echo менять не нужно.
  • Чувствительные данные — только в приватных каналах с правилом в routes/channels.php, а в broadcastWith() — лишь необходимые поля.
  • REVERB_SERVER_* описывает, где слушает сервер, REVERB_* — куда подключаться; после изменения VITE_* фронт пересобирается.
  • Для продакшена: Nginx с Upgrade, Supervisor с minfds, reverb:restart при деплое, ext-uv, если соединений тысячи.
  • На драйвере null тест авторизации каналов ничего не проверяет.

Похожие