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

Страницы:  1

Ответить
 

Professor Seleznov


В первой части мы разобрались, что 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 архитектуре сайта участвуют три основных слоя:
  • Laravel backend — бизнес-логика, права доступа, база данных, события, очереди.
  • Centrifugo server — WebSocket-соединения, каналы, подписки, доставка сообщений.
  • Frontend client — подключение к Centrifugo, подписка на каналы, обновление интерфейса.
pic
Пошаговая схема 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 обработал событие и изменил статус заказа на странице с «Ожидает оплаты» на «Оплачен».
Архитектурная цепочка получается такой:
pic
Архитектурная цепочка 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-архитектуры.-Источник
 
Loading...
Error