Một bảng tỷ số trực tiếp không chỉ là hai con số đổi màu trên màn hình. Hệ thống phải lưu kết quả chính xác vào database, phát thay đổi tới hàng trăm hoặc hàng nghìn trình duyệt, giữ đồng hồ trận đấu chạy mượt và vẫn hoạt động hợp lý khi WebSocket tạm thời gián đoạn. Laravel Reverb giúp giải quyết lớp truyền dữ liệu real-time, nhưng chất lượng của sản phẩm phụ thuộc nhiều hơn vào cách chúng ta thiết kế model, event, payload và chiến lược đồng bộ lại dữ liệu.
Trong bài này, chúng ta xây một ứng dụng mẫu tên Kickoff gồm hai màn hình. Trang bảng tỷ số liệt kê mọi trận đang diễn ra; trang chi tiết trận hiển thị tỷ số, trạng thái, đồng hồ và timeline bàn thắng hoặc thẻ phạt. Backend dùng Laravel Reverb và Broadcasting, frontend dùng Vue cùng Laravel Echo. Mục tiêu là hiểu đầy đủ đường đi của một lần cập nhật, không chỉ dừng ở việc kết nối socket thành công.
Mục lục [Hiển thị]
- 1. Chọn WebSocket thay vì polling có chủ đích
- 2. Kiến trúc tổng thể của bảng tỷ số
- 3. Cài Laravel Reverb và Echo
- 4. Thiết kế model cho trạng thái và đồng hồ
- 5. Tạo event với payload nhỏ và rõ nghĩa
- 6. Dispatch từ observer, không rải trong controller
- 7. Public channel, private channel và quyền truy cập
- 8. Kết nối Echo trong Vue và xử lý SSR
- 9. Cho đồng hồ chạy ở client
- 10. Timeline của một trận và partial reload
- 11. Xử lý mất kết nối và đồng bộ lại
- 12. Kiểm thử từ event đến trình duyệt
- 13. Đưa Reverb lên production
- 14. Các lỗi thường gặp và cách khoanh vùng
- 15. Checklist hoàn thiện
- Kết luận
1. Chọn WebSocket thay vì polling có chủ đích
Polling là cách trình duyệt gọi một JSON endpoint theo chu kỳ. Ưu điểm của nó là dễ làm, tận dụng hạ tầng HTTP sẵn có và tự hồi phục sau lỗi mạng. Nhược điểm nằm ở số request rỗng: trong mười phút không có bàn thắng, mọi client vẫn hỏi máy chủ liên tục. Nếu chu kỳ là mười giây, sự kiện có thể đến muộn gần mười giây; nếu giảm xuống một giây, lưu lượng tăng gấp mười.
WebSocket đảo chiều cách giao tiếp. Client kết nối một lần, sau đó server chủ động đẩy thông báo khi dữ liệu thay đổi. Một trận yên ắng gần như không tạo message; khi có bàn thắng, tất cả tab đang mở nhận cùng một sự kiện. Đổi lại, chúng ta phải vận hành một tiến trình giữ kết nối lâu dài, cấu hình reverse proxy và xử lý reconnect ở frontend.
Với bảng tỷ số, phần lớn thời gian không có thay đổi nhưng mỗi thay đổi cần đến nhanh, nên Reverb hợp lý hơn polling. Tuy nhiên, database vẫn là nguồn sự thật. WebSocket chỉ là lớp phân phối thay đổi. Nếu Reverb dừng, thao tác ghi tỷ số vẫn phải thành công và lần tải trang tiếp theo vẫn phải hiển thị dữ liệu đúng.

2. Kiến trúc tổng thể của bảng tỷ số
Luồng chính bắt đầu khi người vận hành nhập một bàn thắng. Controller hoặc service cập nhật Result trong database. Observer nhận biết bản ghi vừa thay đổi và phát event ScoreUpdated. Laravel Broadcasting gửi payload sang Reverb, Reverb phân phối payload đó cho các kết nối đã đăng ký channel, còn Echo chuyển event vào state Vue.
Ta dùng hai cấp channel:
scoreboard.livelà luồng chung cho trang tổng hợp. Nó nhận tỷ số và trạng thái của mọi trận.game.{id}là luồng riêng cho một trận. Nó nhận tỷ số, trạng thái và từng sự kiện timeline.
Một lần cập nhật tỷ số được phát lên cả hai channel nhưng chỉ cần dispatch một event. Timeline chi tiết không đi vào channel chung vì trang bảng tỷ số không dùng dữ liệu cầu thủ, thẻ hay thay người. Việc chia channel theo nhu cầu hiển thị giúp giảm cả băng thông lẫn công việc cập nhật state ở client.
3. Cài Laravel Reverb và Echo
Trong một dự án Laravel chưa bật broadcasting, chạy:
php artisan install:broadcasting --reverb
php artisan migrate
Quá trình cài đặt tạo config/reverb.php, cấu hình broadcasting, cài laravel-echo cùng pusher-js và bổ sung thông tin kết nối vào .env. Trong môi trường phát triển, chạy Reverb song song với web server, queue và Vite:
php artisan reverb:start
php artisan queue:work
npm run dev
Cần phân biệt hai nhóm biến môi trường. REVERB_SERVER_HOST và REVERB_SERVER_PORT cho biết tiến trình Reverb lắng nghe ở đâu. REVERB_HOST, REVERB_PORT và REVERB_SCHEME là địa chỉ mà Laravel hoặc trình duyệt dùng để kết nối. Production thường để tiến trình nghe nội bộ ở cổng 8080 nhưng công khai qua wss://socket.example.com cổng 443.
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=kickoff
REVERB_APP_KEY=your-public-key
REVERB_APP_SECRET=your-private-secret
REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080
REVERB_HOST=socket.example.com
REVERB_PORT=443
REVERB_SCHEME=https
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
Các biến VITE_* được đóng gói lúc build frontend. Chỉ sửa .env mà không build lại JavaScript sẽ khiến trình duyệt tiếp tục kết nối địa chỉ cũ. Sau khi thay code backend, chạy php artisan reverb:restart để tiến trình dài hạn nạp phiên bản mới.
4. Thiết kế model cho trạng thái và đồng hồ
Một thiết kế dễ bảo trì tách ba trách nhiệm. Game giữ trạng thái vòng đời và đồng hồ; Result giữ tỷ số; GameEvent giữ từng diễn biến. Trạng thái nên là enum để controller, event và frontend không phải truyền những chuỗi tùy ý.
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 in_array($this, [self::Live, self::HalfTime], true);
}
}
Model Game
protected function casts(): array
{
return [
'status' => GameStatus::class,
'current_minute' => 'integer',
'clock_started_at' => 'immutable_datetime',
];
}
Bảng resultsgame_id để một trận chỉ có một kết quả hiện hành. Bảng game_eventstype, minute, team_id, player_id và metadata cần thiết. Timeline có thể sắp xếp bằng phút kết hợp ID để hai sự kiện cùng phút vẫn ổn định.
5. Tạo event với payload nhỏ và rõ nghĩa
ScoreUpdated chỉ mang dữ liệu giao diện cần thay ngay. Không gửi toàn bộ model Game vì Eloquent có thể serialize thêm các cột nội bộ hoặc relation đã load. Phương thức broadcastWith() biến payload thành một contract có chủ đích.
namespace App\Events;
use App\Models\Game;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Broadcasting\InteractsWithSockets;
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('scoreboard.live'),
new Channel('game.'.$this->game->id),
];
}
public function broadcastAs(): string
{
return 'ScoreUpdated';
}
public function broadcastWith(): array
{
return [
'game_id' => $this->game->id,
'home_score' => $this->game->result?->home_score,
'away_score' => $this->game->result?->away_score,
'status' => $this->game->status->value,
'base_minute' => $this->game->current_minute,
'clock_started_at' => $this->game->clock_started_at?->toISOString(),
];
}
}
ShouldBroadcastNow bỏ qua queue nên giảm một chặng trễ, phù hợp payload nhỏ cần xuất hiện tức thì. Đổi lại, request ghi tỷ số chờ Reverb. Với notification nặng hoặc event không yêu cầu độ trễ thấp, ShouldBroadcast qua queue vẫn là lựa chọn an toàn hơn.
GameStatusChanged có thể dùng cùng hai channel và chỉ mang trạng thái cùng thông tin đồng hồ. GameEventRecorded đi riêng vào game.{id}, mang ID của bản ghi timeline hoặc một tập scalar tối thiểu. Tên đội và cầu thủ đã có ở server có thể được lấy bằng partial reload thay vì lặp lại trong mọi message.

6. Dispatch từ observer, không rải trong controller
Nếu event chỉ được dispatch trong controller nhập tỷ số, một job đồng bộ dữ liệu hoặc Artisan command sửa kết quả sẽ quên phát thông báo. Observer đặt phản ứng ngay cạnh thay đổi model, vì vậy mọi đường ghi đều có hành vi nhất quán.
namespace App\Observers;
use App\Events\ScoreUpdated;
use App\Models\Result;
class ResultObserver
{
public function saved(Result $result): void
{
$game = $result->game;
if (! $game) {
return;
}
$game->setRelation('result', $result);
$this->broadcastSafely(
fn () => ScoreUpdated::dispatch($game)
);
}
public function deleted(Result $result): void
{
if (! $result->game) {
return;
}
$result->game->setRelation('result', null);
$this->broadcastSafely(
fn () => ScoreUpdated::dispatch($result->game)
);
}
}
setRelation() tránh gửi relation cũ đã cache trước khi Result được lưu. Với GameObserver, không phát event sau mọi lần update. Chỉ phát khi những trường ảnh hưởng giao diện thay đổi:
public function updated(Game $game): void
{
if ($game->wasChanged([
'status',
'current_minute',
'clock_started_at',
])) {
$this->broadcastSafely(
fn () => GameStatusChanged::dispatch($game)
);
}
}
Broadcast là side effect, không phải thao tác ghi chính. Nếu Reverb mất kết nối, kết quả trong database vẫn phải lưu. Có thể bọc dispatch trong try/catch, ghi log warning và để request tiếp tục. Nếu thay đổi nằm trong transaction, event nên triển khai ShouldDispatchAfterCommit hoặc dùng DB::afterCommit(). Nếu phát trước commit, client có thể thấy một tỷ số sau đó bị rollback.
7. Public channel, private channel và quyền truy cập
Tỷ số bóng đá là dữ liệu công khai nên Channel thường đủ. Client không cần gọi endpoint xác thực channel, giảm một request khi subscribe. Dữ liệu quản trị, phòng bình luận riêng hoặc thông tin người dùng phải dùng PrivateChannel hay PresenceChannel và khai báo quyền trong routes/channels.php.
Broadcast::channel('game-admin.{game}', function (User $user, Game $game) {
return $user->can('update', $game);
});
allowed_origins trong cấu hình Reverb cũng cần giới hạn theo domain thật. Đây không thay thế authorization của private channel, nhưng giúp từ chối kết nối từ origin không mong muốn.
8. Kết nối Echo trong Vue và xử lý SSR
Echo được cấu hình một lần ở bootstrap frontend. Với package tích hợp Vue, phần cốt lõi chỉ cần chọn broadcaster Reverb. Vấn đề xuất hiện khi ứng dụng bật server-side rendering: phía server không có window và không thể mở WebSocket. Vì vậy composable đăng ký channel phải dừng sớm ngoài trình duyệt.
import { useEchoPublic } from '@laravel/echo-vue';
export function useEchoPublicClient(
channel,
event,
callback,
) {
if (typeof window === 'undefined') {
return;
}
const exactEvent = event.startsWith('.')
? event
: '.'.concat(event);
useEchoPublic(channel, exactEvent, callback);
}
Dấu chấm trước .ScoreUpdated rất quan trọng khi backend dùng broadcastAs(). Nếu thiếu, Echo có thể tự thêm namespace event mặc định và listener im lặng dù message vẫn đến socket.
Trang bảng tỷ số tạo một bản state cục bộ từ props SSR, sau đó patch đúng trận khi event tới:
const games = ref([...props.games]);
useEchoPublicClient(
'scoreboard.live',
'ScoreUpdated',
(event) => {
const game = games.value.find(
item => item.id === event.game_id
);
if (game) {
Object.assign(game, {
home_score: event.home_score,
away_score: event.away_score,
status: event.status,
current_minute: event.base_minute,
clock_started_at: event.clock_started_at,
});
return;
}
if (['live', 'half_time'].includes(event.status)) {
router.reload({ only: ['games'] });
}
},
);
Nhánh reload chỉ dùng khi một trận chưa có trong state ban đầu vừa chuyển sang live. Payload không mang tên đội, nên partial reload lấy riêng prop games từ server. Trường hợp phổ biến vẫn chỉ patch vài scalar và không phát sinh HTTP request.
9. Cho đồng hồ chạy ở client
Không nên broadcast phút thi đấu mỗi giây. Backend phát hai mốc: base_minute là phút tại lần đổi trạng thái gần nhất, clock_started_at là lúc đồng hồ bắt đầu chạy. Client lấy thời gian hiện tại trừ mốc bắt đầu rồi cộng vào phút cơ sở.
export function matchClockMinute(
now,
status,
baseMinute,
clockStartedAt,
) {
if (status !== 'live' || !clockStartedAt) {
return baseMinute;
}
const elapsed = now - new Date(clockStartedAt).getTime();
const minutes = Math.max(0, Math.floor(elapsed / 60000));
return (baseMinute ?? 0) + minutes;
}
Một now ref dùng chung, cập nhật mỗi giây trong onMounted(), có thể điều khiển tất cả card. Khi chuyển sang hiệp nghỉ, event trạng thái mang phút cơ sở mới và đồng hồ đứng lại. Cách này giữ giao diện chuyển động mà socket không phải phát hàng nghìn message thời gian.
Đồng hồ client chỉ phục vụ hiển thị. Những quyết định nghiệp vụ như khóa nhập bàn thắng sau khi hết trận phải dựa vào thời gian và trạng thái ở server. Khi tab ngủ lâu hoặc máy người dùng sai giờ, event tiếp theo và lần đồng bộ lại sẽ đưa giao diện về nguồn dữ liệu chuẩn.
10. Timeline của một trận và partial reload
Trang chi tiết subscribe game.{id}. Hai listener tỷ số và trạng thái patch phần header. Khi nhận GameEventRecorded, frontend có hai lựa chọn: dựng dòng timeline trực tiếp từ payload hoặc reload riêng prop events.
Nếu payload chỉ chứa team_id, player_id và assist_player_id, partial reload hợp lý hơn. Server vốn biết cách resolve quan hệ, format phút và dịch loại sự kiện. Frontend không cần giữ thêm bảng tra tên cầu thủ chỉ để dựng một dòng mới.
const channel = 'game.'.concat(props.game.id);
useEchoPublicClient(channel, 'GameEventRecorded', () => {
router.reload({
only: ['events'],
preserveScroll: true,
});
});
Tỷ số thay đổi thường xuyên và payload đã đủ nên không reload. Timeline xuất hiện ít hơn và cần dữ liệu quan hệ nên reload một phần. Phân biệt hai trường hợp này giúp code frontend đơn giản mà vẫn tránh tải lại toàn trang.
11. Xử lý mất kết nối và đồng bộ lại
Ứng dụng real-time phải giả định client sẽ mất một số event: laptop ngủ, điện thoại đổi mạng, proxy đóng kết nối hoặc Reverb restart trong lúc deploy. Khi Echo kết nối lại, nên gọi một endpoint snapshot hoặc partial reload các prop quan trọng. Không cố phát lại toàn bộ lịch sử qua socket nếu database đã lưu trạng thái mới nhất.
Giao diện nên có trạng thái kết nối kín đáo: connected, reconnecting và unavailable. Không nên xóa tỷ số cũ khi socket mất; hãy giữ snapshot gần nhất, đánh dấu dữ liệu có thể chậm và đồng bộ khi kết nối trở lại. Với thao tác quản trị, chống ghi trùng bằng idempotency key hoặc unique constraint ở lớp dữ liệu.
12. Kiểm thử từ event đến trình duyệt
Ở backend, dùng Broadcast::fake() để xác nhận observer chỉ phát khi trường cần thiết thay đổi. Test payload nên khóa các key công khai để một refactor model không vô tình làm contract đổi. Các trường hợp đáng có gồm tạo kết quả lần đầu, cập nhật tỷ số, xóa kết quả, đổi trạng thái, cập nhật trường không liên quan và lỗi broadcast.
Broadcast::fake();
$result->update(['home_score' => 2]);
Broadcast::assertBroadcasted(
ScoreUpdated::class,
fn (ScoreUpdated $event) =>
$event->game->is($result->game)
);
Ở trình duyệt, mở hai tab bảng tỷ số và một tab quản trị. Ghi một bàn thắng rồi kiểm tra cả hai tab cập nhật mà không reload. Sau đó tắt Reverb, ghi thêm kết quả, xác nhận database vẫn lưu; khởi động lại Reverb và tải lại trang để thấy snapshot đúng. Cuối cùng kiểm tra reconnect, hiệp nghỉ, full time và một trận mới chuyển sang live.

13. Đưa Reverb lên production
Reverb là tiến trình dài hạn, không nên chạy trong một phiên SSH. Supervisor hoặc systemd phải giữ tiến trình sống và khởi động lại khi máy chủ reboot. Khi deploy code, dùng php artisan reverb:restart để đóng kết nối nhẹ nhàng; process manager sẽ chạy tiến trình mới.
Website HTTPS buộc WebSocket dùng wss://. Nginx hoặc Caddy thường terminate TLS rồi proxy vào Reverb nội bộ. Với Nginx, hai header nâng cấp kết nối là phần không thể thiếu:
location / {
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_pass http://127.0.0.1:8080;
}
Mỗi kết nối mở tiêu thụ một file descriptor. Hãy kiểm tra ulimit -n, giới hạn worker của web server và giới hạn Supervisor trước khi tăng số client. Reverb mặc định có thể dùng event loop dựa trên stream_select; hệ thống nhiều kết nối cần cân nhắc event loop hiệu năng cao hơn và theo dõi memory thực tế.
Khi một Reverb server không đủ, bật horizontal scaling và dùng Redis pub/sub làm lớp đồng bộ giữa nhiều node. Load balancer phân phối kết nối, còn Redis bảo đảm message nhận ở một node được chuyển tới client đang giữ bởi node khác. Laravel Pulse có thể theo dõi số connection và message để phát hiện tăng trưởng bất thường.
14. Các lỗi thường gặp và cách khoanh vùng
- Socket kết nối nhưng listener không chạy: kiểm tra dấu chấm trước tên event khi có
broadcastAs(), tên channel và namespace. - Local chạy, production không chạy: kiểm tra
VITE_REVERB_*tại thời điểm build, chứng chỉ TLS, allowed origins và reverse proxy headers. - Request lưu tỷ số bị lỗi khi Reverb dừng: broadcast đồng bộ đang ném exception; cần tách side effect và ghi log thay vì làm hỏng thao tác chính.
- Payload có dữ liệu cũ: relation Eloquent đã cache; dùng
setRelation(),refresh()có chủ đích hoặc truyền DTO scalar vào event. - Client nhận event trước khi transaction commit: triển khai
ShouldDispatchAfterCommithoặc dispatch trong callback sau commit. - Đồng hồ nhảy hoặc chạy khi nghỉ: chỉ tính elapsed khi trạng thái là live và cập nhật lại mốc khi nhận event trạng thái.
- Deploy xong vẫn chạy code cũ: Reverb là tiến trình dài hạn; chạy
php artisan reverb:restart.
15. Checklist hoàn thiện
- Database giữ trạng thái chuẩn, WebSocket chỉ phân phối thay đổi.
- Payload chỉ chứa ID và scalar giao diện cần dùng ngay.
- Observer kiểm tra
wasChanged()trước khi phát. - Broadcast failure không làm thất bại thao tác ghi dữ liệu.
- Event trong transaction chỉ phát sau commit.
- Channel công khai và riêng tư được chọn theo tính nhạy cảm dữ liệu.
- SSR không khởi tạo Echo ở phía server.
- Client tính đồng hồ cục bộ và đồng bộ lại sau reconnect.
- Production có TLS, reverse proxy, process manager và monitoring.
- Kịch bản kiểm thử bao gồm nhiều tab, mất mạng và restart Reverb.
Kết luận
Phần kết nối Reverb và Echo chỉ là lớp dây dẫn. Phần quyết định hệ thống có bền hay không nằm ở contract payload, vị trí dispatch, chiến lược sau transaction, cách frontend khôi phục snapshot và quy trình vận hành tiến trình dài hạn. Khi giữ database làm nguồn sự thật, phát đúng thay đổi và để client tự tính phần có thể suy ra, bảng tỷ số có thể cập nhật nhanh mà không tạo lưu lượng thừa.
Bài này được viết lại theo hướng triển khai độc lập từ ý tưởng của bài hướng dẫn trên Laravel News và đối chiếu với tài liệu Laravel Reverb chính thức. Bạn có thể đọc thêm các bài trong chuyên mục Laravel Nè và phần ghi chú viết code thực tế để nối tiếp chủ đề triển khai và vận hành.