Архитектура Laravel + Centrifugo: кто за что отвечает в real-time системе

от автора

В первой части мы разобрались, что Real-time на Laravel-сайте нужен там, где интерфейс должен получать изменения без перезагрузки страницы: новые уведомления, смену статуса заказа, сообщения в чате, обновления виджетов, события в административной панели. Для таких задач классическая модель HTTP-запроса уже недостаточна, а polling создаёт лишнюю нагрузку на backend. Один из практичных вариантов решения — использовать Centrifugo как отдельный WebSocket-сервер рядом с Laravel-приложением.

В этой статье разберём архитектуру Laravel + Centrifugo: за что отвечает Laravel, какую роль выполняет Centrifugo, как frontend подключается к real-time каналу и как выглядит типовой сценарий публикации события, например при изменении статуса заказа.

Зачем разделять Laravel и Centrifugo

Laravel остаётся основным backend-приложением. Он принимает HTTP-запросы, обрабатывает бизнес-логику, проверяет права доступа, работает с базой данных, запускает очереди и формирует события приложения. Именно Laravel должен решать, что произошло в системе и кто имеет право это увидеть.

Centrifugo выполняет другую задачу. Он отвечает за WebSocket-соединения, каналы, подписки и доставку сообщений клиентам. Это не замена Laravel и не второй backend с бизнес-логикой. Centrifugo — real-time транспортный слой, который получает событие от Laravel и доставляет его подписчикам нужного канала.

Такое разделение особенно важно для поддержки проекта в будущем. Если смешать бизнес-логику, авторизацию и доставку WebSocket-сообщений в одном месте, система быстро станет хрупкой. На демо это выглядит бодро. В production потом начинается привычная археология: почему пользователь получил не своё событие, почему статус пришёл раньше сохранения в базе, почему frontend обновился, а данные в API ещё старые.

Правильная архитектура Laravel Centrifugo строится на простой идее: Laravel является источником истины, а Centrifugo — механизмом доставки изменений в реальном времени.

Общая схема архитектуры Laravel + Centrifugo

В real-time архитектуре сайта участвуют три основных слоя:

  1. Laravel backend — бизнес-логика, права доступа, база данных, события, очереди.

  2. Centrifugo server — WebSocket-соединения, каналы, подписки, доставка сообщений.

  3. Frontend client — подключение к Centrifugo, подписка на каналы, обновление интерфейса.

Схема работы Laravel и Centrifugo для real-time обновлений на сайте

Пошаговая схема real-time взаимодействия: Laravel отдаёт начальное состояние, frontend подключается к Centrifugo, подписывается на канал, получает событие и обновляет интерфейс без перезагрузки страницы.

Здесь важно понимать роль каждого слоя. HTTP остаётся основой для загрузки данных, выполнения команд и восстановления состояния. WebSocket через Centrifugo используется для мгновенной доставки изменений.

Например, страница заказа может сначала загрузить данные обычным HTTP-запросом:

{  "id": 7821,  "status": "pending",  "amount": 1500}

После этого frontend подписывается на real-time канал пользователя. Когда заказ будет оплачен, Laravel отправит событие в Centrifugo, а Centrifugo доставит его браузеру через WebSocket.

Laravel отвечает за бизнес-логику и события

Laravel не должен просто «проксировать» сообщения в WebSocket. Его задача глубже. Backend должен выполнить действие, проверить права, изменить состояние системы и только после этого создать событие.

Рассмотрим пример с оплатой заказа. Платёжная система отправляет webhook. Laravel проверяет входящие данные, находит заказ, меняет его статус и создаёт событие OrderStatusChanged.

Упрощённый пример контроллера:

namespace App\Http\Controllers\Payment;use App\Events\OrderStatusChanged;use App\Models\Order;use Illuminate\Http\JsonResponse;use Illuminate\Http\Request;class PaymentWebhookController{    public function __invoke(Request $request): JsonResponse    {        $order = Order::query()            ->where('payment_id', $request->input(key: 'payment_id'))            ->firstOrFail();        $order->update([            'status' => 'paid',        ]);        event(new OrderStatusChanged(            orderId: $order->id,            userId: $order->user_id,            status: 'paid',        ));        return response()->json(data: [            'success' => true,        ]);    }}

В реальном проекте здесь должны быть проверка подписи webhook, идемпотентность, транзакции, защита от повторной обработки и корректная обработка ошибок. Но архитектурный принцип уже виден: сначала Laravel меняет состояние данных, затем создаёт событие.

Само событие можно оформить отдельным классом:

namespace App\Events;class OrderStatusChanged{    public function __construct(        public readonly int $orderId,        public readonly int $userId,        public readonly string $status,    ) {    }}

Такой класс не должен знать ничего о Centrifugo. Это важная деталь. Событие описывает факт предметной области: статус заказа изменился. А куда потом этот факт уйдёт — в Centrifugo, email, лог аудита или аналитику — решают отдельные обработчики.

Публикация событий в Centrifugo через очередь

Публиковать событие в Centrifugo прямо из контроллера технически возможно, но архитектурно некорректно. Контроллер должен отвечать за HTTP-вход, а не за доставку real-time сообщений. Лучше использовать Laravel Events, listeners и queue jobs.

Listener может отправлять задачу в очередь:

namespace App\Listeners;use App\Events\OrderStatusChanged;use App\Jobs\PublishOrderStatusChangedToRealtime;class SendOrderStatusChangedToRealtime{    public function handle(OrderStatusChanged $event): void    {        PublishOrderStatusChangedToRealtime::dispatch(            orderId: $event->orderId,            userId: $event->userId,            status: $event->status,        )->afterCommit();    }}

Метод afterCommit() здесь принципиален. Real-time событие не должно уйти клиенту раньше, чем новое состояние будет сохранено в базе данных. Иначе пользователь может получить сообщение о статусе paid, обновить интерфейс, затем запросить заказ через API и увидеть старый статус pending. После этого начинается поиск «плавающего бага», хотя причина обычно банальна: событие было отправлено слишком рано.

Job публикации может выглядеть так:

namespace App\Jobs;use App\Services\Realtime\CentrifugoPublisher;use Illuminate\Contracts\Queue\ShouldQueue;class PublishOrderStatusChangedToRealtime implements ShouldQueue{    public function __construct(        public readonly int $orderId,        public readonly int $userId,        public readonly string $status,    ) {    }    public function handle(CentrifugoPublisher $publisher): void    {        $publisher->publish(            channel: '$users:' . $this->userId,            data: [                'type' => 'order.status.changed',                'orderId' => $this->orderId,                'status' => $this->status,            ],        );    }}

Такой подход даёт несколько преимуществ. Основной HTTP-запрос не зависит напрямую от доступности Centrifugo. Публикацию можно повторить при временной ошибке. Логику доставки проще тестировать. В будущем можно добавить retry, метрики, отдельные логи и разные типы real-time событий.

Centrifugo отвечает за WebSocket, каналы и доставку сообщений

Centrifugo в этой архитектуре не должен принимать бизнес-решения. Он не знает, почему заказ стал оплаченным, кто оплатил счёт и какие правила действуют для пользователя. Его задача — доставить сообщение в канал.

Канал — центральная сущность real-time обмена. Frontend подписывается на канал, а backend публикует туда сообщения.

Примеры каналов:

$users:15$orders:7821$admin:orderschat:room:45dashboard:payments

Для персональных данных обычно используют приватные каналы. Например, канал $users:15 предназначен для событий конкретного пользователя. В него можно отправлять уведомления, изменения статусов заказов, результаты фоновых задач и другие индивидуальные события.

Laravel может публиковать сообщения в Centrifugo через отдельный сервис:

namespace App\Services\Realtime;use Illuminate\Support\Facades\Http;class CentrifugoPublisher{    public function publish(        string $channel,        array $data,    ): void {        Http::withHeaders(headers: [            'Authorization' => 'apikey ' . config('services.centrifugo.api_key'),        ])->post(            url: config('services.centrifugo.api_url') . '/api/publish',            data: [                'channel' => $channel,                'data' => $data,            ],        )->throw();    }}

Сервис публикации лучше держать в одном месте. Не нужно размазывать HTTP-вызовы Centrifugo по контроллерам, моделям и listener-ам. Один сервис проще заменить, расширить и покрыть тестами.

Отдельно стоит следить за payload. В WebSocket-событие не нужно отправлять всю Eloquent-модель. Это риск утечки лишних данных и источник нестабильности контракта между backend и frontend.

Лучше отправлять минимальное событие:

{  "type": "order.status.changed",  "orderId": 7821,  "status": "paid"}

Если frontend нужны дополнительные данные, он может запросить актуальное состояние через обычный HTTP API. Real-time сообщает об изменении, HTTP восстанавливает полную картину.

Frontend подключается к Centrifugo и обновляет интерфейс

Frontend получает начальное состояние от Laravel, затем подключается к Centrifugo и подписывается на нужные каналы. После получения публикации он обновляет интерфейс.

Упрощённый пример подключения:

import { Centrifuge } from 'centrifuge';const centrifuge = new Centrifuge('wss://example.com/connection/websocket', {    getToken: async function () {        const response = await fetch('/realtime/connection-token', {            headers: {                Accept: 'application/json',            },            credentials: 'same-origin',        });        const data = await response.json();        return data.token;    },});const userChannel = '$users:15';const subscription = centrifuge.newSubscription(userChannel, {    getToken: async function () {        const response = await fetch('/realtime/subscription-token', {            method: 'POST',            headers: {                Accept: 'application/json',                'Content-Type': 'application/json',            },            credentials: 'same-origin',            body: JSON.stringify({                channel: userChannel,            }),        });        const data = await response.json();        return data.token;    },});subscription.on('publication', function (context) {    if (context.data.type === 'order.status.changed') {        updateOrderStatus(            context.data.orderId,            context.data.status,        );    }});subscription.subscribe();centrifuge.connect();

Этот пример показывает базовый принцип, но в production нельзя доверять имени канала, которое пришло с клиента. Пользователь может изменить '$users:15' на '$users:16'. Поэтому Laravel обязан проверять право подписки и выдавать token только на разрешённый канал.

Пример сценария: Laravel меняет статус заказа и публикует событие

Соберём весь процесс в один пример.

Пользователь открыл страницу заказа. Laravel отдал начальные данные по HTTP. Заказ находится в статусе pending. Frontend подключился к Centrifugo и подписался на канал пользователя $users:15.

Потом платёжная система отправила webhook. Laravel проверил запрос, нашёл заказ, изменил статус на paid, сохранил новое состояние в базе данных и создал событие OrderStatusChanged.

Listener отправил задачу в очередь после commit транзакции. Job вызвал CentrifugoPublisher и опубликовал сообщение:

{  "channel": "$users:15",  "data": {    "type": "order.status.changed",    "orderId": 7821,    "status": "paid"  }}

Centrifugo доставил сообщение всем активным подключениям, подписанным на канал $users:15. Если у пользователя открыто несколько вкладок, обновление могут получить все вкладки. Frontend обработал событие и изменил статус заказа на странице с «Ожидает оплаты» на «Оплачен».

Архитектурная цепочка получается такой:

Схема обработки webhook платежной системы через Laravel, очередь, Centrifugo и WebSocket

Архитектурная цепочка real-time обновления: webhook платёжной системы приходит в Laravel, backend проверяет запрос, меняет статус заказа, создаёт событие приложения, queue job публикует сообщение в Centrifugo, а frontend получает обновление через WebSocket.

Практические правила для устойчивой архитектуры

Чтобы real-time Laravel не превратился в набор случайных WebSocket-сообщений, стоит придерживаться нескольких правил.

  • Laravel должен оставаться источником истины. Все изменения состояния должны фиксироваться в базе данных или другом основном хранилище. WebSocket-событие не должно быть единственным доказательством того, что что-то произошло.

  • Centrifugo должен оставаться транспортом. В нём не нужно размещать бизнес-логику, сложную авторизацию или правила предметной области.

  • Публиковать события лучше после commit транзакции. Это снижает риск рассинхронизации между интерфейсом и API.

  • Payload должен быть минимальным и стабильным. Полная Eloquent-модель в real-time событии — плохая идея. Лучше передавать тип события, идентификатор сущности и несколько необходимых полей.

  • Frontend должен уметь восстановить состояние через HTTP. Если пользователь потерял соединение, закрыл ноутбук, вернулся из спящего режима или пропустил событие, интерфейс должен запросить актуальные данные у Laravel.

Заключение

Архитектура Laravel + Centrifugo строится на ясном разделении ответственности. Laravel отвечает за бизнес-логику, права доступа, события, очереди и состояние данных. Centrifugo отвечает за WebSocket-соединения, каналы, подписки и доставку сообщений. Frontend подключается к Centrifugo, получает публикации и обновляет интерфейс в реальном времени.

Такой подход позволяет добавить real-time обновления на сайт без разрушения backend-архитектуры. Laravel остаётся главным приложением и источником истины, а Centrifugo становится специализированным real-time слоем для доставки событий.

Для Laravel-проектов это особенно удобно: можно использовать привычные Events, listeners, jobs, очереди, конфигурацию и сервисный слой. В результате real-time становится не отдельной игрушкой сбоку, а нормальной частью web-архитектуры.

ссылка на оригинал статьи https://habr.com/ru/articles/1035046/