Pendahuluan
“Kenapa notifikasi baru muncul setelah saya refresh halaman?”
Ini pertanyaan yang sering diajukan user aplikasi Indonesia yang terbiasa dengan WhatsApp, Instagram, dan aplikasi real-time lainnya. Mereka mengharapkan notifikasi langsung muncul — tanpa harus refresh.
Solusi tradisional adalah polling — browser mengirim request ke server setiap N detik untuk mengecek apakah ada data baru:
Polling (tidak efisien):
Browser → "Ada notif baru?" → Server: "Tidak"
Browser → "Ada notif baru?" → Server: "Tidak"
Browser → "Ada notif baru?" → Server: "Ya, ada 1!"
Masalah polling: ribuan request tidak berguna ke server. WebSocket jauh lebih efisien:
WebSocket (efisien):
Browser ←→ Server (koneksi persistent)
Server → "Notif baru: task #42 selesai!" → Browser (langsung)
Laravel Reverb adalah WebSocket server open-source yang bisa kamu host sendiri — tanpa biaya Pusher!
HTTP vs WebSocket
| HTTP | WebSocket | |
|---|---|---|
| Koneksi | Request-Response (satu arah) | Bidirectional (dua arah) |
| Persistensi | Sementara | Terus-menerus |
| Overhead | Tinggi (header baru tiap request) | Rendah (handshake sekali) |
| Cocok untuk | REST API, form submission | Chat, notifikasi, game, live dashboard |
Analogi: HTTP seperti SMS (kirim satu pesan, selesai). WebSocket seperti telepon (koneksi terbuka, bisa ngobrol kapan saja).
Langkah 1: Instalasi Laravel Reverb
# Install Reverb (Laravel 11+)
php artisan install:broadcasting
Perintah ini secara otomatis:
- Install
laravel/reverbvia Composer - Publish konfigurasi
- Setup
resources/js/echo.js - Update
bootstrap.js
# .env — Konfigurasi Reverb
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=my-app
REVERB_APP_KEY=my-app-key
REVERB_APP_SECRET=my-app-secret
REVERB_HOST=localhost
REVERB_PORT=8080
REVERB_SCHEME=http
# Untuk frontend (Vite)
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
# Install JavaScript dependencies
npm install --save-dev laravel-echo pusher-js
npm run build
Langkah 2: Buat Event yang Bisa Di-broadcast
php artisan make:event TaskCompleted
<?php
// app/Events/TaskCompleted.php
namespace App\Events;
use App\Models\Task;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class TaskCompleted implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Task $task) {}
// Channel mana yang akan menerima event ini
public function broadcastOn(): array
{
return [
// Private channel — hanya user terautentikasi yang diizinkan
new PrivateChannel('user.' . $this->task->user_id),
];
}
// Data yang dikirim ke frontend
public function broadcastWith(): array
{
return [
'task_id' => $this->task->id,
'title' => $this->task->title,
'message' => "Task '{$this->task->title}' telah selesai!",
];
}
// Nama event di frontend (default: class name)
public function broadcastAs(): string
{
return 'task.completed';
}
}
Langkah 3: Broadcast Event dari Controller
// app/Http/Controllers/TaskController.php
use App\Events\TaskCompleted;
public function complete(Task $task): JsonResponse
{
$this->authorize('update', $task);
$task->update(['status' => 'completed']);
// Broadcast event ke semua listener di channel ini
TaskCompleted::dispatch($task);
return response()->json(['data' => new TaskResource($task)]);
}
Langkah 4: Authorization untuk Private Channel
Private channel hanya bisa diakses oleh user yang sudah diotorisasi. Definisikan aturannya di routes/channels.php:
<?php
// routes/channels.php
use App\Models\User;
use Illuminate\Support\Facades\Broadcast;
// Private channel: user.{userId}
// Hanya user dengan ID yang sesuai yang bisa subscribe
Broadcast::channel('user.{userId}', function (User $user, int $userId) {
return $user->id === $userId;
});
// Presence channel: room.{roomId}
// User bisa subscribe jika memiliki akses ke room tersebut
Broadcast::channel('room.{roomId}', function (User $user, int $roomId) {
return $user->rooms()->where('rooms.id', $roomId)->exists();
});
Langkah 5: Frontend — Dengarkan Event dengan Laravel Echo
// resources/js/echo.js (sudah dibuat otomatis oleh install:broadcasting)
import Echo from 'laravel-echo';
import Pusher from 'pusher-js';
window.Pusher = Pusher;
window.Echo = new Echo({
broadcaster: 'reverb',
key: import.meta.env.VITE_REVERB_APP_KEY,
wsHost: import.meta.env.VITE_REVERB_HOST,
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
enabledTransports: ['ws', 'wss'],
});
// resources/js/notifications.js — Tambahkan listener
// Subscribe ke private channel user yang sedang login
const userId = window.authUserId; // Harus ada di HTML: <meta name="auth-user-id" content="{{ auth()->id() }}">
window.Echo.private(`user.${userId}`)
.listen('.task.completed', (event) => {
console.log('Task selesai:', event);
// Tampilkan toast notification
showToast(event.message, 'success');
// Update badge notifikasi
updateNotificationBadge();
});
{{-- resources/views/layouts/app.blade.php --}}
<head>
{{-- Sediakan user ID untuk JavaScript --}}
@auth
<meta name="auth-user-id" content="{{ auth()->id() }}">
@endauth
</head>
<body>
@yield('content')
@vite(['resources/js/app.js'])
</body>
Langkah 6: Presence Channel — Tampilkan Siapa yang Online
Presence channel memungkinkan melihat daftar user yang aktif di channel tertentu:
// routes/channels.php
Broadcast::channel('online', function (User $user) {
return ['id' => $user->id, 'name' => $user->name];
// Return data user jika diizinkan
});
// Frontend: presence channel
window.Echo.join('online')
.here((users) => {
// Dipanggil saat pertama join — daftar semua user online
console.log('Sedang online:', users);
renderOnlineUsers(users);
})
.joining((user) => {
// Dipanggil saat user baru join
console.log(user.name, 'baru online');
addOnlineUser(user);
})
.leaving((user) => {
// Dipanggil saat user keluar
console.log(user.name, 'offline');
removeOnlineUser(user);
});
Langkah 7: Proyek — Chat Sederhana
Backend
php artisan make:model Message -m
php artisan make:event MessageSent
// database/migrations/xxxx_create_messages_table.php
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('room_id')->constrained();
$table->text('body');
$table->timestamps();
});
<?php
// app/Events/MessageSent.php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
class MessageSent implements ShouldBroadcast
{
public function __construct(public Message $message) {}
public function broadcastOn(): array
{
return [new Channel('room.' . $this->message->room_id)];
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'body' => $this->message->body,
'user' => $this->message->user->only('id', 'name'),
'created_at' => $this->message->created_at->toISOString(),
];
}
}
// app/Http/Controllers/MessageController.php
public function store(Request $request, Room $room): JsonResponse
{
$message = $room->messages()->create([
'user_id' => auth()->id(),
'body' => $request->validate(['body' => 'required|max:2000'])['body'],
]);
$message->load('user');
// Broadcast ke semua user di room
MessageSent::dispatch($message);
return response()->json($message, 201);
}
Frontend Chat
{{-- resources/views/chat/room.blade.php --}}
<div id="messages" class="flex flex-col gap-2 p-4 h-96 overflow-y-auto">
@foreach($messages as $message)
<div class="flex gap-2">
<strong>{{ $message->user->name }}</strong>
<span>{{ $message->body }}</span>
</div>
@endforeach
</div>
<form id="message-form" class="flex gap-2 p-4">
<input id="message-input" type="text" placeholder="Ketik pesan..."
class="flex-1 border rounded px-3 py-2">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded">
Kirim
</button>
</form>
<script>
const roomId = {{ $room->id }};
const container = document.getElementById('messages');
// Dengarkan pesan baru
window.Echo.channel(`room.${roomId}`)
.listen('.MessageSent', (event) => {
const div = document.createElement('div');
div.className = 'flex gap-2';
div.innerHTML = `<strong>${event.user.name}</strong><span>${event.body}</span>`;
container.appendChild(div);
container.scrollTop = container.scrollHeight; // Scroll ke bawah
});
// Kirim pesan
document.getElementById('message-form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.getElementById('message-input');
await fetch(`/rooms/${roomId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ body: input.value }),
});
input.value = '';
});
</script>
Menjalankan Reverb di Production
# Jalankan Reverb server
php artisan reverb:start
Setup Supervisor:
[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
user=deploy
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/reverb.log
Nginx proxy untuk WebSocket (wss://):
# Di dalam server block HTTPS
location /app/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
Troubleshooting: Error yang Sering Muncul
WebSocket connection failed / ERR_CONNECTION_REFUSED
Penyebab: Reverb server tidak berjalan, atau port 8080 diblokir firewall.
Solusi:
# Pastikan Reverb berjalan
php artisan reverb:start
# Buka port di firewall
sudo ufw allow 8080
# Cek apakah Reverb berjalan di port yang benar
ss -tlnp | grep 8080
Echo tidak menerima event
Penyebab: Event tidak di-dispatch, channel name tidak cocok, atau BROADCAST_CONNECTION salah.
Solusi:
# Verifikasi konfigurasi
grep BROADCAST_CONNECTION .env
# Harus: BROADCAST_CONNECTION=reverb
# Debug di browser console
window.Echo.connector.pusher.connection.bind('state_change', states => {
console.log('Reverb state:', states);
});
# Status harus "connected"
# Cek event di Reverb console
php artisan reverb:start --debug
Private channel 403 Forbidden
Penyebab: Authorization callback di channels.php mengembalikan false, atau Sanctum auth tidak setup.
Solusi:
// Pastikan route broadcasting sudah terdaftar
// Di bootstrap/app.php (Laravel 11):
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
channels: __DIR__.'/../routes/channels.php', // ← Ini wajib ada
// ...
)
// Cek logic authorization di channels.php
Broadcast::channel('user.{userId}', function (User $user, int $userId) {
\Log::info("Auth check: user={$user->id}, requested={$userId}");
return $user->id === $userId; // Pastikan logika ini benar
});
Reverb berhenti setelah beberapa jam di production
Penyebab: Memory leak atau crash yang tidak ditangani Supervisor.
Solusi:
; supervisor config — tambahkan restart periodik
[program:reverb]
command=php /var/www/app/artisan reverb:start
autorestart=true
stopwaitsecs=60
; Restart setiap 24 jam untuk cegah memory leak
; Atau gunakan: --max-connections=10000
Pertanyaan yang Sering Diajukan
Laravel Reverb vs Pusher, mana yang lebih baik?
Reverb adalah pilihan terbaik untuk aplikasi yang ingin self-hosted dan hemat biaya. Tidak ada batasan koneksi berbasis harga. Pusher lebih mudah di-setup (tidak perlu server tambahan) dan punya dashboard monitoring yang bagus. Untuk proyek kecil atau MVP, Pusher free tier cukup. Untuk production serius, Reverb lebih cost-effective.
Berapa banyak koneksi WebSocket yang bisa ditangani Reverb?
Reverb bisa menangani ribuan koneksi concurrent di server dengan resource yang memadai. Untuk 1–2 vCPU dan 2GB RAM, ~5.000–10.000 koneksi simultan adalah estimasi realistis. Untuk traffic lebih besar, scale horizontally dengan load balancer.
Apakah WebSocket bisa digunakan dengan mobile app?
Ya! Aplikasi Flutter atau React Native bisa connect ke Reverb menggunakan library WebSocket standar atau Pusher SDK (yang kompatibel dengan Reverb). Data yang diterima sama persis dengan yang diterima browser.
Bagaimana cara fallback jika WebSocket tidak didukung?
Laravel Echo secara otomatis menggunakan long polling sebagai fallback jika WebSocket tidak tersedia (jaringan korporat yang memblokir WebSocket). Tambahkan enabledTransports: ['ws', 'wss', 'xhr_polling'] di konfigurasi Echo.
Kesimpulan
Real-time bukan lagi fitur mewah — ini ekspektasi standar pengguna modern. Dengan Laravel Reverb, kamu bisa membangun aplikasi real-time tanpa biaya bulanan Pusher.
Kita sudah belajar:
- Perbedaan HTTP polling vs WebSocket
- Setup Reverb server
- Broadcast events dengan ShouldBroadcast
- Public, Private, dan Presence channels
- Membangun chat real-time dari nol
Ingat: setiap request Reverb juga berjalan melalui Laravel — jadi optimasi query di artikel Performance Optimization tetap sangat relevan untuk memastikan WebSocket handler berjalan cepat!
Selamat — aplikasimu sekarang bisa “berbicara” langsung dengan user tanpa mereka harus refresh! ⚡💬