เป้าหมาย: สร้างแชตแบบห้องสาธารณะด้วย Laravel, Reverb, Broadcasting, Laravel Echo และ Blade โดยไม่ต้องรีเฟรชหน้าเว็บ

#1. Laravel Reverb คืออะไร

Laravel Reverb เป็น WebSocket server ของ Laravel สำหรับส่งเหตุการณ์จากฝั่งเซิร์ฟเวอร์ไปยังเบราว์เซอร์แบบ real-time ผ่านระบบ Broadcasting เหมาะกับแชต การแจ้งเตือน Dashboard และการติดตามสถานะงาน

สถาปัตยกรรม

flowchart LR
    A[Browser A] -->|POST /messages| B[Laravel API]
    B --> C[(Database)]
    B -->|Broadcast event| D[Laravel Reverb]
    D -->|WebSocket| A
    D -->|WebSocket| E[Browser B]

#2. สิ่งที่ต้องมี

  • PHP และ Composer ที่รองรับ Laravel เวอร์ชันที่ติดตั้ง
  • Node.js และ npm
  • SQLite หรือฐานข้อมูลอื่น
  • Terminal อย่างน้อย 3 หน้าต่าง

#3. สร้างโปรเจกต์และติดตั้ง Reverb

composer create-project laravel/laravel reverb-chat
cd reverb-chat
php artisan install:broadcasting --reverb
npm install

ตัวติดตั้ง Broadcasting จะเตรียม Reverb, Echo และค่าคอนฟิกที่จำเป็นตามเวอร์ชัน Laravel ที่ใช้อยู่ ตรวจสอบไฟล์ที่ตัวติดตั้งแก้ไขก่อนดำเนินการ

ตั้งค่า .env ให้ใช้ SQLite และตรวจสอบค่าที่ตัวติดตั้งสร้าง:

DB_CONNECTION=sqlite
BROADCAST_CONNECTION=reverb

REVERB_APP_ID=local-app
REVERB_APP_KEY=local-key
REVERB_APP_SECRET=local-secret
REVERB_HOST=127.0.0.1
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}"

ค่าตัวอย่างใช้สำหรับเครื่องพัฒนาเท่านั้น ในระบบจริงต้องใช้ secret ที่สุ่มอย่างปลอดภัย และ WSS/HTTPS

สร้างไฟล์ฐานข้อมูลหากยังไม่มี:

touch database/database.sqlite
php artisan migrate

บน Windows ให้สร้าง database/database.sqlite ด้วย File Explorer หรือ PowerShell แทน touch ได้

#4. สร้าง Model และ Migration

php artisan make:model Message -m

แก้ migration ใน database/migrations/*_create_messages_table.php:

public function up(): void
{
    Schema::create('messages', function (Blueprint $table) {
        $table->id();
        $table->string('name', 60);
        $table->string('body', 500);
        $table->timestamps();
    });
}

ไฟล์ app/Models/Message.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    protected $fillable = ['name', 'body'];
}
php artisan migrate

#5. สร้าง Broadcasting Event

php artisan make:event MessageSent

ไฟล์ app/Events/MessageSent.php:

<?php

namespace App\Events;

use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class MessageSent implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public function __construct(public Message $message) {}

    public function broadcastOn(): array
    {
        return [new Channel('chat-room')];
    }

    public function broadcastAs(): string
    {
        return 'message.sent';
    }
}

ShouldBroadcastNow เหมาะกับ Workshop เพราะส่งทันทีโดยไม่ต้องเปิด queue worker; งาน production อาจใช้ ShouldBroadcast ร่วมกับ queue เพื่อควบคุมโหลด

#6. สร้าง Controller

php artisan make:controller ChatController

ไฟล์ app/Http/Controllers/ChatController.php:

<?php

namespace App\Http\Controllers;

use App\Events\MessageSent;
use App\Models\Message;
use Illuminate\Http\Request;

class ChatController extends Controller
{
    public function index()
    {
        return view('chat', [
            'messages' => Message::latest()->take(30)->get()->reverse()->values(),
        ]);
    }

    public function store(Request $request)
    {
        $data = $request->validate([
            'name' => ['required', 'string', 'max:60'],
            'body' => ['required', 'string', 'max:500'],
        ]);

        $message = Message::create($data);
        broadcast(new MessageSent($message));

        return response()->json($message, 201);
    }
}

#7. กำหนด Routes

ไฟล์ routes/web.php:

<?php

use App\Http\Controllers\ChatController;
use Illuminate\Support\Facades\Route;

Route::get('/', [ChatController::class, 'index']);
Route::post('/messages', [ChatController::class, 'store'])
    ->middleware('throttle:30,1');

#8. เชื่อมต่อ Laravel Echo

ตรวจสอบไฟล์ resources/js/bootstrap.js ที่ install:broadcasting สร้าง โดยต้องมีการตั้งค่า Echo คล้ายตัวอย่างนี้:

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: Number(import.meta.env.VITE_REVERB_PORT ?? 80),
    wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443),
    forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
    enabledTransports: ['ws', 'wss'],
});

ตรวจสอบว่า resources/js/app.js import bootstrap:

import './bootstrap';

#9. สร้างหน้าแชต Blade

ไฟล์ resources/views/chat.blade.php:

<!DOCTYPE html>
<html lang="th">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">
    <title>Laravel Reverb Chat</title>
    @vite(['resources/css/app.css', 'resources/js/app.js'])
    <style>
        body { font-family: sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
        #messages { height: 360px; overflow-y: auto; border: 1px solid #ddd; padding: 16px; }
        form { display: flex; gap: 8px; margin-top: 16px; }
        input { padding: 10px; min-width: 0; }
        input[name="body"] { flex: 1; }
    </style>
</head>
<body>
    <h1>💬 Laravel Reverb Chat</h1>
    <div id="messages">
        @foreach ($messages as $message)
            <p><strong>{{ $message->name }}:</strong> {{ $message->body }}</p>
        @endforeach
    </div>
    <form id="chat-form">
        <input name="name" placeholder="ชื่อ" maxlength="60" required>
        <input name="body" placeholder="ข้อความ" maxlength="500" required>
        <button type="submit">ส่ง</button>
    </form>

    <script type="module">
        const box = document.querySelector('#messages');
        const form = document.querySelector('#chat-form');
        const addMessage = (message) => {
            const p = document.createElement('p');
            const strong = document.createElement('strong');
            strong.textContent = `${message.name}: `;
            p.append(strong, document.createTextNode(message.body));
            box.appendChild(p);
            box.scrollTop = box.scrollHeight;
        };

        window.Echo.channel('chat-room')
            .listen('.message.sent', (event) => addMessage(event.message));

        form.addEventListener('submit', async (event) => {
            event.preventDefault();
            const button = form.querySelector('button');
            button.disabled = true;
            try {
                const response = await fetch('/messages', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Accept': 'application/json',
                        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
                    },
                    body: JSON.stringify(Object.fromEntries(new FormData(form))),
                });
                if (!response.ok) throw new Error(`HTTP ${response.status}`);
                form.elements.body.value = '';
                // ไม่ append ที่นี่ เพราะข้อความจะเข้ามาทาง Reverb อยู่แล้ว
            } catch (error) {
                alert(`ส่งข้อความไม่สำเร็จ: ${error.message}`);
            } finally {
                button.disabled = false;
            }
        });
    </script>
</body>
</html>

ในบางการตั้งค่า Vite/Blade การเข้าถึง window.Echo ใน inline module อาจเกิดลำดับโหลดไม่ตรงกัน หากพบปัญหาให้ย้าย JavaScript ส่วนหน้าแชตไปไว้ใน resources/js/app.js หลัง import './bootstrap' หรือแยกเป็นไฟล์ module ที่ import จาก app.js

#10. รันระบบ

เปิด Terminal แยกกัน:

# Terminal 1: Laravel HTTP server
php artisan serve

# Terminal 2: Reverb WebSocket server
php artisan reverb:start --debug

# Terminal 3: Vite
npm run dev

เปิด http://127.0.0.1:8000 สองแท็บ ตั้งชื่อคนละชื่อ แล้วทดลองส่งข้อความ ข้อความควรแสดงทั้งสองแท็บทันที

#11. การทดสอบ

กรณีทดสอบ ผลที่คาดหวัง
ส่งข้อความจากแท็บ A แท็บ A และ B ได้รับข้อความ
ส่งข้อความว่าง ได้รับ HTTP 422
ข้อความเกิน 500 ตัวอักษร ได้รับ HTTP 422
รีเฟรชหน้าเว็บ แสดง 30 ข้อความล่าสุด
ปิด Reverb server ส่ง HTTP ได้ แต่ไม่อัปเดตแบบ real-time
ส่งข้อความเป็น HTML แสดงเป็นข้อความ ไม่ถูกประมวลผลเป็น HTML

#12. แนวทางต่อยอด Production

  1. เปลี่ยนจาก Public Channel เป็น Private/Presence Channel พร้อม Authentication และ Authorization หากมีข้อมูลส่วนตัว
  2. ใช้ HTTPS/WSS และ reverse proxy ที่รองรับ WebSocket Upgrade
  3. ตั้งค่า REVERB_ALLOWED_ORIGINS ตามโดเมนที่อนุญาต
  4. แยก Reverb process ออกจาก HTTP process และใช้ process supervisor
  5. พิจารณา queue worker, Redis และการ scale ตามปริมาณผู้ใช้
  6. เพิ่มการตรวจสอบสิทธิ์ผู้ส่ง, rate limiting, monitoring และการเก็บ log โดยไม่เปิดเผยข้อมูลลับ
  7. ออกแบบการ reconnect, missed messages และการดึงประวัติย้อนหลัง เพราะ WebSocket ไม่รับประกันการส่งย้อนหลังเมื่อ client หลุด

#13. ปัญหาที่พบบ่อย

  • WebSocket connection failed: ตรวจสอบ REVERB_HOST, port 8080, firewall และ Vite variables แล้ว restart Vite
  • Event ไม่มาถึง: ตรวจสอบ BROADCAST_CONNECTION=reverb, ชื่อ Channel และ .listen('.message.sent', ...)
  • 419 Page Expired: ตรวจสอบ CSRF token ใน request
  • Duplicate messages: อย่า append ข้อความทั้งจาก HTTP response และ Broadcast พร้อมกันโดยไม่ทำ deduplication
  • Deploy แล้วเชื่อมต่อไม่ได้: ตรวจสอบ reverse proxy, TLS, WebSocket Upgrade และ origin restrictions

#สรุป

Laravel Reverb ช่วยให้แอป Laravel สื่อสารกับเบราว์เซอร์แบบ real-time ได้ผ่าน WebSocket Workshop นี้ใช้ Public Channel เพื่อสาธิตหลักการ จึงไม่ควรนำไปใช้กับข้อความส่วนตัวโดยไม่มีระบบยืนยันตัวตนและการกำหนดสิทธิ์

#เอกสารอ้างอิง