javascript

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

  • пятница, 15 мая 2026 г. в 00:00:14
https://habr.com/ru/articles/1035046/

В первой части мы разобрались, что 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:orders
chat:room:45
dashboard: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-архитектуры.