Створення табло результатів матчів у реальному часі за допомогою Laravel Reverb

Перекладено ШІ 0 Laravel News 29 липня, 2026

Створити табло результатів, яке оновлюється миттєво без перезавантаження сторінки, значно простіше з Laravel Reverb. Розбираємося, як за допомогою WebSockets транслювати події в реальному часі та водночас не перевантажувати сервер.

Фінал Чемпіонату світу позаду, але багато хто з нас провів останній місяць, не відриваючись від сторінок із результатами або стрічок соцмереж. Якщо ви не дивилися матч наживо, саме вкладка браузера була вашим «вікном» на стадіон. На одних сайтах доводилося постійно натискати F5 або Ctrl-R, щоб оновити рахунок. На інших — цифри змінювалися самі собою. У цьому туторіалі ми створимо саме такий застосунок, де гол миттєво з’являється на всіх відкритих вкладках без жодних перезавантажень.

Є два основні способи реалізувати такий функціонал, і важливо розуміти різницю між ними.

Polling (опитування) — це найпростіший метод. Браузер кожні кілька секунд запитує сервер про поточний рахунок. Це працює з будь-яким JSON-ендпоїнтом, не потребує додаткового софту та стійке до розривів зв’язку. Але є нюанс: клієнт постійно шле запити, навіть якщо на полі нічого не відбувається. Якщо ви оновлюєте дані кожні 10 секунд, гол може «запізнитися» для користувача на 9 секунд. А якщо робити це щосекунди — ви просто завалите сервер трафіком.

WebSocket працює інакше. Клієнт підключається один раз, а сервер надсилає дані лише тоді, коли вони з’являються. У періоди затишшя в мережі панує тиша, але щойно забивають гол — повідомлення миттєво долітає до всіх. Плата за це — необхідність тримати WebSocket-сервер із постійно відкритими з’єднаннями та трохи складніший frontend. Для табло з результатами, де важлива швидкість, це ідеальний вибір.

Laravel Reverb — це офіційний WebSocket-сервер, який підтримує протокол Pusher. Він інтегрується з функціями broadcasting у Laravel та Laravel Echo на фронтенді без сторонніх сервісів. Ми використаємо його для Kickoff — додатка для футбольних результатів із загальним табло та окремими сторінками матчів, де в реальному часі оновлюються рахунок, таймер і події гри.

Це реальний код із мого проєкту, а не просто приклад для статті. Я зосередився на складних моментах: що саме передавати в payload, звідки запускати події та як змусити frontend працювати коректно при увімкненому server-side rendering (SSR).

# Встановлення Reverb

Якщо у вашому застосунку ще не налаштовано broadcasting, одна команда зробить усе необхідне: встановить Reverb, створить конфігурацію та підготує Echo:

php artisan install:broadcasting --reverb

Це створить файл config/reverb.php, встановить laravel-echo та pusher-js, а також додасть налаштування в .env:

BROADCAST_CONNECTION=reverb
 
REVERB_APP_ID=
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_* потрібні для сервера, а VITE_REVERB_* дублюють їх для фронтенду, щоб Echo міг підключитися до системи.

Під час розробки WebSocket-сервер запускається паралельно з іншими процесами:

php artisan reverb:start

У Laravel 13.16 з’явилася команда php artisan dev, яка автоматично запускає сервер застосунку, черги, Vite та Reverb. Якщо ви використовуєте старішу версію, можна додати Reverb у скрипт dev у вашому composer.json через concurrently.

# Моделювання матчу

Для табло нам потрібні три стани. Модель Game відповідає за статус і таймер, Result — за рахунок, а GameEvent фіксує події: голи, картки чи заміни. Статус реалізований через Enum:

namespace App\Enums;
 
enum GameStatus: string
{
    case Scheduled = 'scheduled';
    case Live = 'live';
    case HalfTime = 'half_time';
    case FullTime = 'full_time';
    case Postponed = 'postponed';
    case Cancelled = 'cancelled';
 
    public function isInProgress(): bool
    {
        return match ($this) {
            self::Live, self::HalfTime => true,
            default => false,
        };
    }
}

У моделі Game ми використовуємо кастинг типів, щоб працювати з об’єктами, а не рядками:

protected function casts(): array
{
    return [
        'status' => GameStatus::class,
        'current_minute' => 'integer',
        'clock_started_at' => 'datetime',
    ];
}

Розділення current_minute та clock_started_at дозволить нам «запустити» годинник у браузері, не надсилаючи оновлення щосекунди через WebSocket.

# Мовлення подій (Broadcasting)

Ми створимо три події. Кожна реалізує інтерфейс ShouldBroadcastNow замість стандартного ShouldBroadcast. Зазвичай черги — це добре, але рахунок матчу має оновлюватися миттєво. Payload тут крихітний, тому синхронна відправка не перевантажить систему.

Ось як виглядає подія оновлення рахунку:

namespace App\Events;
 
use App\Models\Game;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
 
class ScoreUpdated implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;
 
    public function __construct(public Game $game) {}
 
    public function broadcastOn(): array
    {
        return [
            new Channel("game.{$this->game->id}"),
            new Channel('scoreboard.live'),
        ];
    }
 
    public function broadcastAs(): string
    {
        return 'ScoreUpdated';
    }
 
    public function broadcastWith(): array
    {
        $result = $this->game->result;
 
        return [
            'game_id' => $this->game->id,
            'home_team_score' => $result?->home_team_score,
            'away_team_score' => $result?->away_team_score,
            'current_minute' => $this->game->current_minute,
            'clock_started_at' => $this->game->clock_started_at?->toISOString(),
            'status' => $this->game->status->value,
        ];
    }
}

Кілька важливих моментів:

  • Два канали: ми транслюємо дані одночасно на персональний канал гри game.{id} та на загальний фід scoreboard.live. Це публічні канали, тому авторизація в routes/channels.php не потрібна.
  • broadcastAs: ми даємо події коротке ім'я, щоб не тягнути повний PHP-неймспейс на фронтенд.
  • broadcastWith: тут ми визначаємо мінімальний набір даних. Не варто передавати назви команд — вони вже є на сторінці й не змінюються під час гри. Чим менший payload, тим краще.

# Використання Observers

Найкраще викликати події через Observers. Якщо робити це вручну в контролері, можна легко забути про трансляцію в якомусь фоновому завданні чи консольній команді. ResultObserver гарантує, що будь-яке збереження результату активує пуш-повідомлення:

class ResultObserver
{
    use BroadcastsQuietly;
 
    public function saved(Result $result): void
    {
        $game = $result->game;
        if ($game === null) return;
 
        $game->setRelation('result', $result);
        $this->broadcastQuietly(fn () => ScoreUpdated::dispatch($game));
    }
 
    public function deleted(Result $result): void
    {
        $game = $result->game;
        if ($game === null) return;
 
        $game->setRelation('result', null);
        $this->broadcastQuietly(fn () => ScoreUpdated::dispatch($game));
    }
}

У GameObserver ми додаємо перевірку wasChanged(), щоб не спамити повідомленнями, коли змінюються неважливі дані (наприклад, назва стадіону):

public function updated(Game $game): void
{
    if ($game->wasChanged(['status', 'current_minute', 'clock_started_at'])) {
        $this->broadcastQuietly(fn () => GameStatusChanged::dispatch($game));
    }
}

# Захист від збоїв

Ми використовуємо трейт BroadcastsQuietly. Це важливо: збереження даних у БД є пріоритетом. Якщо Reverb тимчасово «впаде», користувач просто побачить оновлений рахунок після перезавантаження сторінки, але сам запит на збереження не має завершитися помилкою.

Також пам'ятайте про транзакції. Якщо ви робите кілька записів у БД, подія може вилетіти раніше, ніж транзакція зафіксується (commit). У такому разі використовуйте ShouldDispatchAfterCommit.

# Frontend: Слухаємо події

Kickoff використовує Inertia та Vue з пакетом @laravel/echo-vue. Налаштовуємо Echo при запуску додатка:

import { configureEcho } from '@laravel/echo-vue';
 
configureEcho({
    broadcaster: 'reverb',
});

Для підписки ми створили власний composable useEchoPublicClient. Він вирішує дві проблеми: ігнорує запити під час SSR (де немає об'єкта window) і автоматично додає крапку перед назвою події, щоб Echo коректно розпізнавав короткі імена broadcastAs.

На сторінці табло ми слухаємо scoreboard.live і просто оновлюємо дані у відповідній грі в реактивному масиві:

useEchoPublicClient(
    'scoreboard.live',
    'ScoreUpdated',
    (e) => {
        const game = games.value.find((g) => g.id === e.game_id);
        if (game) {
            Object.assign(game, e);
        } else if (['live', 'half_time'].includes(e.status)) {
            // Якщо з'явилася нова гра, якої не було на табло — підтягуємо її через Inertia reload
            router.reload({ only: ['games'] });
        }
    },
);

# Оптимізація таймера

Транслювати кожну секунду таймера через WebSocket — погана ідея. Ми передаємо лише current_minute та clock_started_at. На фронтенді проста функція вираховує поточну хвилину на основі різниці в часі:

const elapsedMs = now - new Date(clockStartedAt).getTime();
return (baseMinute ?? 0) + Math.max(0, Math.floor(elapsedMs / 60000));

Один таймер (setInterval) на весь додаток оновлює значення now раз на секунду, і всі картки матчів синхронно змінюють хвилину гри.

# Робота в Production

Коли ви готові вийти в «лайв», пам'ятайте про три речі:

  • Контроль процесів: Використовуйте Supervisor, щоб Reverb працював постійно і перезапускався у разі збою.
  • SSL/TLS: Браузери блокують ws:// на HTTPS-сторінках. Налаштуйте Nginx або Caddy як реверс-проксі для перетворення wss:// на внутрішній порт Reverb.
  • Vite Config: Переконайтеся, що змінні VITE_REVERB_* вказані правильно для вашого продакшн-домену, інакше фронтенд намагатиметься підключитися до localhost.

Reverb легко масштабується горизонтально через Redis, тому ваш додаток витримає навіть наплив фанатів під час фіналу Ліги чемпіонів. Використовуйте WebSocket з розумом: передавайте лише те, що змінилося, і дозвольте клієнту самому домальовувати деталі.

Популярні

Інше, що варто прочитати

13 Оновлено 26 червня, 2026

Удосконалюйте свої проєкти Laravel за допомогою справжнього штучного інтелекту для кодування з Laravel Boost!

Готові підняти свій робочий процес у Laravel на новий рівень? У цій статті я розгляну Laravel Boost, інноваційний AI-допомічник для програмування, який зробить вашу розробку швидшою та продуктивнішою

15 Оновлено 26 червня, 2026

Claude Agent тепер інтегрований в AI Assistant для PhpStorm

Ви коли-небудь задумувалися, як полегшити свою роботу в Laravel? У нашій статті ми розглядаємо, як інтеграція Claude Code в PhpStorm може підвищити вашу продуктивність, спростивши процес написання коду та навчання нових розробників. Читайте далі, щоб дізнатися більше про переваги та функціональність цього потужного поєднання

16 Оновлено 26 червня, 2026

Простий пакет RabbitMQ для Laravel

Вам цікаво дізнатися, як спростити інтеграцію RabbitMQ у вашому Laravel-додатку? У нашій статті ми розглянемо пакет Simple RabbitMQ, який дозволяє легко налаштувати багатозʼєднання, публікувати повідомлення та обробляти черги за допомогою простого синтаксису. Читайте далі, щоб дізнатися більше!