【Laravel 13】ReverbとEchoでリアルタイムチャットを作る手順|Lightsail + Ubuntu 24.04 + PHP 8.4

Laravel 13でリアルタイムチャットを作ってみました。

最初は「メッセージを投稿して、他のブラウザにも即時反映されるだけなら簡単では?」と思っていましたが、実際にやってみると、Laravel本体だけでなく、Reverb、Broadcasting、Echo、Vite、WebSocket用ポート、Supervisorなど、意外と多くの部品が関係していました。

完成形

今回作るのは、最小構成のリアルタイムチャットです。

できることは以下です。

  • /messages にアクセスするとメッセージ一覧が表示される
  • フォームからメッセージを投稿できる
  • 投稿内容はMySQLの messages テーブルに保存される
  • 別ブラウザで開いている画面にも、投稿がリアルタイム反映される
  • Laravel ReverbをSupervisorで常駐化する

逆に、まだ作らないものは以下です。

  • ログイン機能
  • ユーザー名表示
  • 複数チャットルーム
  • 既読・未読
  • HTTPS化
  • 投稿削除・編集

まずは「Laravelでリアルタイムチャットの中核部分を理解する」ことを目的にします。

使用環境

今回の環境です。

  • AWS Lightsail:AWSが提供する、VPS感覚で使えるシンプルなクラウドサーバーサービス。
  • Ubuntu 24.04 LTS:長期サポート版のLinux OSで、今回のLaravelアプリを動かす土台。
  • PHP 8.4:Laravel 13を動かすためのサーバーサイド言語実行環境。
  • MySQL:投稿されたチャットメッセージを保存するデータベース。
  • nginx:ブラウザからのHTTPアクセスを受けて、Laravelへ処理を渡すWebサーバー。
  • Composer:Laravel本体やPHPライブラリをインストール・管理するPHP用パッケージ管理ツール。
  • Node.js 22:ViteやLaravel Echoなど、フロントエンド関連のビルドに使うJavaScript実行環境。
  • Laravel 13:今回のチャットアプリ本体を作るPHPフレームワーク。
  • Laravel Reverb:Laravel公式のWebSocketサーバーで、リアルタイム通信を担当する。
  • Laravel Echo:ブラウザ側でReverbからのリアルタイム通知を受け取るJavaScriptライブラリ。
  • Vite:CSSやJavaScriptを本番用にまとめるフロントエンドビルドツール。
  • Supervisor:Reverbなどの常駐プロセスを起動・監視・自動再起動するためのプロセス管理ツール。

Laravel 13はPHP 8.3以上が必要です。今回はPHP 8.4を使いました。

全体の仕組み

リアルタイムチャットの流れはこうです。

ユーザーAがメッセージ投稿
↓
Laravelがバリデーション
↓
messagesテーブルに保存
↓
MessageCreatedイベントをbroadcast
↓
Laravel ReverbがWebSocketで配信
↓
ユーザーBのブラウザでLaravel Echoが受信
↓
JavaScriptで画面にメッセージを追加

普通の投稿フォームなら、DBに保存してリダイレクトすれば終わりです。

リアルタイム化する場合は、DB保存後に「新しいメッセージができた」というイベントをWebSocketで他のブラウザへ通知します。

1. サーバーを用意する

Lightsailでは、LAMPブループリントではなく、OS OnlyのUbuntuを選びました。

Laravel 13を使うなら、最初からUbuntuを選んでPHP 8.4を入れる方がきれいです。

Lightsailの作成画面では、以下のようにしました。

Platform: Linux/Unix
Blueprint: OS Only
OS: Ubuntu 24.04 LTS
Plan: 2GB RAM以上
Static IP: 割り当てる

SSHユーザーはUbuntuの場合、通常は ubuntu です。

ssh -i "C:\Users\ユーザー名\.ssh\lightsail-key.pem" ubuntu@サーバーの固定IP

2. Ubuntuを更新する

まずはOSを更新します。

sudo apt update
sudo apt upgrade -y
sudo reboot

再接続後、タイムゾーンを日本にします。

sudo timedatectl set-timezone Asia/Tokyo
timedatectl

基本ツールも入れます。

sudo apt install -y curl git unzip ca-certificates lsb-release software-properties-common

3. swapを作る

2GB RAMのサーバーだと、Composerやnpmでメモリが苦しくなることがあります。

念のため2GBのswapを作りました。

sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h

4. nginxとMySQLを入れる

sudo apt install -y nginx mysql-server
sudo systemctl enable nginx
sudo systemctl enable mysql
sudo systemctl status nginx --no-pager
sudo systemctl status mysql --no-pager

active (running) になっていればOKです。

5. PHP 8.4を入れる

Ubuntu標準のPHPではなく、PHP 8.4を使うためにPPAを追加します。

sudo add-apt-repository ppa:ondrej/php -y
sudo apt update

Laravelでよく使う拡張もまとめて入れます。

sudo apt install -y php8.4 php8.4-fpm php8.4-cli php8.4-mysql php8.4-mbstring php8.4-xml php8.4-curl php8.4-zip php8.4-bcmath php8.4-intl php8.4-gd php8.4-sqlite3

確認します。

php -v
php-fpm8.4 -v
php -m | grep -i sqlite

PHP 8.4.xpdo_sqlitesqlite3 が表示されればOKです。

6. Composerを入れる

cd ~
curl -sS https://getcomposer.org/installer -o composer-setup.php
php composer-setup.php
sudo mv composer.phar /usr/local/bin/composer
composer -V

7. Node.js 22を入れる

Ubuntu標準のNode.jsだと古く、Viteでエラーになることがあります。

今回もNode.js 18では以下のようなエラーになりました。

Vite requires Node.js version 20.19+ or 22.12+

Node.js 22を入れます。

curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
node -v
npm -v

8. Laravel用のDBを作る

MySQLに入ります。

sudo mysql

DBとユーザーを作ります。

CREATE DATABASE laravel13_chat CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

CREATE USER 'laravel_user'@'localhost' IDENTIFIED BY 'ここに強いパスワードを入れる';

GRANT ALL PRIVILEGES ON laravel13_chat.* TO 'laravel_user'@'localhost';

FLUSH PRIVILEGES;

EXIT;

今回は日本語検索での意図しない一致を避けるため、照合順序は utf8mb4_general_ci にしました。

utf8mb4_unicode_ci は便利な面もありますが、日本語ではひらがな・カタカナ・濁音・半濁音などをかなり広く同一視することがあり、通常のチャット検索では違和感が出る可能性があります。

9. Laravel 13プロジェクトを作る

cd /var/www
sudo chown -R ubuntu:ubuntu /var/www
composer create-project laravel/laravel:^13.0 laravel13-chat
cd /var/www/laravel13-chat
php artisan --version

Laravel Framework 13.x と出ればOKです。

10. .envを設定する

.env を編集します。

nano .env

DB設定を変更します。

APP_URL=http://サーバーの固定IP
APP_LOCALE=ja
APP_FALLBACK_LOCALE=ja
APP_FAKER_LOCALE=ja_JP

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel13_chat
DB_USERNAME=laravel_user
DB_PASSWORD=ここにDBパスワードを入れる

マイグレーションを実行します。

php artisan migrate

11. Viteをビルドする

npm install
npm run build

LaravelではBladeを使う場合でも、CSSやJavaScriptはViteでビルドされます。

resources/js/app.jsresources/css/app.css を変更した場合、本番表示では npm run build が必要です。

12. nginxでLaravelを公開する

nginx設定を作ります。

sudo nano /etc/nginx/sites-available/laravel13-chat

以下を貼ります。

server {
    listen 80;
    listen [::]:80;

    server_name サーバーの固定IP;

    root /var/www/laravel13-chat/public;
    index index.php index.html;

    charset utf-8;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location = /favicon.ico {
        access_log off;
        log_not_found off;
    }

    location = /robots.txt {
        access_log off;
        log_not_found off;
    }

    error_page 404 /index.php;

    location ~ ^/index\.php(/|$) {
        fastcgi_pass unix:/run/php/php8.4-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        include fastcgi_params;
        fastcgi_hide_header X-Powered-By;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

有効化します。

sudo ln -s /etc/nginx/sites-available/laravel13-chat /etc/nginx/sites-enabled/laravel13-chat
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx

権限も整えます。

cd /var/www/laravel13-chat
sudo chown -R ubuntu:www-data /var/www/laravel13-chat
sudo chmod -R 775 storage bootstrap/cache

ブラウザで確認します。

http://サーバーの固定IP

Laravelの初期画面が出れば成功です。

13. まず普通のメッセージ投稿機能を作る

ここからアプリ側の実装です。

まず、メッセージ用のModel、Migration、Controller、Request、Factory、Testを作ります。

php artisan make:model Message -mf
php artisan make:controller MessageController
php artisan make:request StoreMessageRequest
php artisan make:test MessagesTest

14. messagesテーブルを作る

database/migrations/xxxx_xx_xx_create_messages_table.php を編集します。

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * マイグレーションを実行します。
     */
    public function up(): void
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->id();
            $table->text('body');
            $table->timestamps();

            $table->index('created_at');
        });
    }

    /**
     * マイグレーションを元に戻します。
     */
    public function down(): void
    {
        Schema::dropIfExists('messages');
    }
};

マイグレーションを実行します。

php artisan migrate

15. Messageモデルを作る

app/Models/Message.php です。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    /** @use HasFactory<\Database\Factories\MessageFactory> */
    use HasFactory;

    /**
     * 一括代入できる属性です。
     *
     * @var list<string>
     */
    protected $fillable = [
        'body',
    ];
}

16. バリデーションを作る

app/Http/Requests/StoreMessageRequest.php です。

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreMessageRequest extends FormRequest
{
    /**
     * このリクエストを許可するか判定します。
     */
    public function authorize(): bool
    {
        return true;
    }

    /**
     * バリデーションルールを返します。
     *
     * @return array<string, list<string>|string>
     */
    public function rules(): array
    {
        return [
            'body' => ['required', 'string', 'max:1000'],
        ];
    }
}

17. Controllerを作る

app/Http/Controllers/MessageController.php です。

<?php

namespace App\Http\Controllers;

use App\Http\Requests\StoreMessageRequest;
use App\Models\Message;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;

class MessageController extends Controller
{
    /**
     * メッセージ一覧を表示します。
     */
    public function index(): View
    {
        return view('messages.index', [
            'messages' => Message::query()
                ->latest()
                ->get(),
        ]);
    }

    /**
     * メッセージを保存します。
     */
    public function store(StoreMessageRequest $request): RedirectResponse
    {
        Message::create($request->validated());

        return redirect()
            ->route('messages.index')
            ->with('status', 'メッセージを投稿しました。');
    }
}

18. ルートを追加する

routes/web.php に追加します。

<?php

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

Route::get('/', fn () => redirect()->route('messages.index'));

Route::get('/messages', [MessageController::class, 'index'])
    ->name('messages.index');

Route::post('/messages', [MessageController::class, 'store'])
    ->name('messages.store');

19. Blade画面を作る

resources/views/messages/index.blade.php を作ります。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>Messages</title>

    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
    <main class="mx-auto max-w-3xl p-6">
        <header class="mb-6 flex items-center justify-between">
            <h1 class="text-2xl font-bold">Messages</h1>
            <p class="text-sm text-gray-500">{{ $messages->count() }} total</p>
        </header>

        @if (session('status'))
            <div class="mb-4 rounded border border-green-300 p-3 text-green-700">
                {{ session('status') }}
            </div>
        @endif

        <form method="POST" action="{{ route('messages.store') }}" class="mb-6 rounded border p-4">
            @csrf

            <label for="body" class="mb-2 block font-semibold">Body</label>
            <textarea
                id="body"
                name="body"
                rows="4"
                class="w-full rounded border p-2"
                required
            >{{ old('body') }}</textarea>

            @error('body')
                <p class="mt-2 text-sm text-red-600">{{ $message }}</p>
            @enderror

            <div class="mt-3 text-right">
                <button type="submit" class="rounded bg-green-600 px-4 py-2 text-white">
                    Post
                </button>
            </div>
        </form>

        <section id="messages" class="space-y-3">
            @foreach ($messages as $message)
                <article class="rounded border p-4" data-message-id="{{ $message->id }}">
                    <p>{{ $message->body }}</p>
                    <time class="mt-2 block text-xs text-gray-500">
                        {{ $message->created_at->format('Y-m-d H:i') }}
                    </time>
                </article>
            @endforeach
        </section>
    </main>
</body>
</html>

この段階ではまだリアルタイムではありません。

ただし、ここまでで「普通の投稿型チャット」は動きます。

20. テストを書く

tests/Feature/MessagesTest.php で以下のような内容を確認します。

  • /messages が表示できる
  • メッセージを投稿できる
  • body は必須
  • body は1000文字まで
  • 1000文字を超えるとエラー

テストを実行します。

php artisan test

ここまで通れば、投稿型チャットとしては完成です。

21. Reverbをインストールする

次にリアルタイム化します。

php artisan install:broadcasting --reverb

途中でNode関連がうまく入らない場合は、手動で入れます。

npm install --save-dev laravel-echo pusher-js

22. .envにReverb設定を追加する

.env のReverb周辺を設定します。

BROADCAST_CONNECTION=reverb

REVERB_APP_ID=任意のID
REVERB_APP_KEY=任意のキー
REVERB_APP_SECRET=任意のシークレット

REVERB_SERVER_HOST=0.0.0.0
REVERB_SERVER_PORT=8080

REVERB_HOST=サーバーの固定IP
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_HOSTlocalhost にしないことです。

ブラウザ上の localhost は、Lightsailサーバーではなく、自分のPCを指します。

そのため、外部ブラウザから接続するなら、REVERB_HOST はサーバーの固定IPやドメインにします。

23. イベントを作る

新しいメッセージを配信するイベントを作ります。

php artisan make:event MessageCreated

app/Events/MessageCreated.php を編集します。

<?php

namespace App\Events;

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

class MessageCreated implements ShouldBroadcastNow
{
    use Dispatchable;
    use SerializesModels;

    public function __construct(
        public Message $message,
    ) {
    }

    /**
     * イベントを配信するチャンネルを返します。
     */
    public function broadcastOn(): Channel
    {
        return new Channel('messages');
    }

    /**
     * ブラウザ側で受け取るイベント名を返します。
     */
    public function broadcastAs(): string
    {
        return 'message.created';
    }

    /**
     * ブラウザへ送るデータを返します。
     *
     * @return array<string, mixed>
     */
    public function broadcastWith(): array
    {
        return [
            'id' => $this->message->id,
            'body' => $this->message->body,
            'created_at' => $this->message->created_at?->format('Y-m-d H:i'),
        ];
    }
}

今回は ShouldBroadcastNow を使っています。

そのため、キューを使わず即時配信されます。

ShouldBroadcast にする場合は、別途 queue:work が必要です。

24. 投稿時にイベントをbroadcastする

MessageControllerstore を変更します。

use App\Events\MessageCreated;

public function store(StoreMessageRequest $request): RedirectResponse
{
    $message = Message::create($request->validated());

    broadcast(new MessageCreated($message))->toOthers();

    return redirect()
        ->route('messages.index')
        ->with('status', 'メッセージを投稿しました。');
}

toOthers() を使うことで、投稿者自身にはWebSocket通知を返さないようにしています。

これにより、投稿者側で同じメッセージが二重表示されるのを防ぎます。

25. Echoを初期化する

resources/js/echo.js を作ります。

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'],
});

26. app.jsでメッセージを受信する

resources/js/app.js でEchoを読み込みます。

import './bootstrap';
import './echo';

const messages = document.querySelector('#messages');
const template = document.querySelector('#message-template');

if (messages && template && window.Echo) {
    window.Echo.channel('messages')
        .listen('.message.created', (event) => {
            if (document.querySelector([data-message-id="${event.id}"])) {
                return;
            }

            const clone = template.content.cloneNode(true);
            const article = clone.querySelector('[data-message-id]');
            const body = clone.querySelector('[data-message-body]');
            const createdAt = clone.querySelector('[data-message-created-at]');

            article.dataset.messageId = event.id;
            body.textContent = event.body;
            createdAt.textContent = event.created_at;

            messages.prepend(clone);
        });
}

27. Bladeにtemplateを追加する

resources/views/messages/index.blade.php にリアルタイム追加用のtemplateを追加します。

<template id="message-template">
    <article class="rounded border p-4" data-message-id="">
        <p data-message-body></p>
        <time class="mt-2 block text-xs text-gray-500" data-message-created-at></time>
    </article>
</template>

既存のメッセージ一覧には data-message-id を付けておきます。

<article class="rounded border p-4" data-message-id="{{ $message->id }}">
    <p>{{ $message->body }}</p>
    <time class="mt-2 block text-xs text-gray-500">
        {{ $message->created_at->format('Y-m-d H:i') }}
    </time>
</article>

28. Viteを再ビルドする

.envVITE_REVERB_*resources/js/* を変更したら、必ずビルドします。

php artisan config:clear
npm run build

これを忘れると、ブラウザ側JavaScriptが古い設定のままになり、ws://localhost:8080 に接続しようとして失敗します。

29. Lightsailで8080番ポートを開ける

Reverbは8080番ポートで動かします。

LightsailのNetworkingで、IPv4 Firewallに以下を追加します。

Application: Custom
Protocol: TCP
Port: 8080
Source: Any IPv4 address

検証段階では Any IPv4 address で構いません。

本番では、ドメインやHTTPS構成、リバースプロキシを含めて考えた方が良いです。

30. Reverbを起動する

まず手動で起動します。

cd /var/www/laravel13-chat
php artisan reverb:start --host=0.0.0.0 --port=8080 --hostname=サーバーの固定IP

この状態でブラウザを2つ開きます。

http://サーバーの固定IP/messages

片方で投稿して、もう片方に即時反映されれば成功です。

31. SupervisorでReverbを常駐化する

手動起動のままだと、SSHを切るとReverbが止まります。

Supervisorで常駐化します。

sudo apt install -y supervisor
sudo systemctl enable supervisor
sudo systemctl start supervisor

設定ファイルを作ります。

sudo nano /etc/supervisor/conf.d/laravel-reverb.conf

中身は以下です。

[program:laravel-reverb]
process_name=%(program_name)s
command=/usr/bin/php /var/www/laravel13-chat/artisan reverb:start --host=0.0.0.0 --port=8080 --hostname=サーバーの固定IP
directory=/var/www/laravel13-chat
autostart=true
autorestart=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/laravel-reverb.log
stopwaitsecs=10

Supervisorに読み込ませます。

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl status

laravel-reverb RUNNING と出ればOKです。

ログ確認は以下です。

sudo tail -f /var/log/supervisor/laravel-reverb.log

32. Reverbを再起動したい場合

コードや設定を変えた場合は、Reverbを再起動します。

cd /var/www/laravel13-chat
php artisan reverb:restart

またはSupervisorから再起動します。

sudo supervisorctl restart laravel-reverb

33. よくハマったポイント

PHP 8.2ではLaravel 13が厳しい

Laravel 13を使うならPHP 8.3以上が必要です。

今回はPHP 8.4を入れました。

Node.js 18ではViteが動かなかった

Ubuntu標準の nodejs は古いことがあります。

今回はViteがNode.js 20.19以上または22.12以上を要求したため、Node.js 22に入れ替えました。

localhostはサーバーではなくブラウザ側のPC

今回一番分かりづらかったのがここです。

.env に以下のように書くと、ブラウザ側では自分のPCの localhost を見に行ってしまいます。

REVERB_HOST=localhost

外部ブラウザからLightsailのReverbへ接続するなら、固定IPまたはドメインを指定します。

REVERB_HOST=サーバーの固定IP

.envを変えたらnpm run buildが必要

VITE_ で始まる環境変数は、フロントエンドのビルドに埋め込まれます。

そのため、.env を変えたら以下が必要です。

php artisan config:clear
npm run build

8080番ポートを開けないとWebSocket接続できない

HTTPの80番だけでなく、Reverb用の8080番もLightsailのFirewallで許可する必要があります。

34. いまの構成で足りないもの

今回作ったのは、最小構成のリアルタイムチャットです。

実用化するなら、次は以下が必要です。

  • ログイン機能
  • ユーザー名表示
  • 複数ルーム
  • HTTPS化
  • APP_DEBUG=false
  • 投稿削除・編集
  • スパム対策
  • CSRFや認可の確認
  • Reverbを443番経由で使う構成
  • .env 秘密情報のローテーション

特に公開するなら、最低でも以下は必要です。

APP_ENV=production
APP_DEBUG=false

また、学習中に .env の中身をどこかに貼った場合は、以下を作り直した方が安全です。

  • APP_KEY
  • DBパスワード
  • REVERB_APP_KEY
  • REVERB_APP_SECRET

まとめ

Laravel 13でリアルタイムチャットを作るには、単にフォームを作るだけではなく、以下の部品が関係します。

  • LaravelのController / Request / Model / Migration
  • Blade
  • MySQL
  • Broadcasting
  • Laravel Reverb
  • Laravel Echo
  • Vite
  • WebSocket用ポート
  • Supervisor

最初は部品が多くて大変ですが、一度動くところまで作ると、流れはかなりシンプルです。

DBに保存
↓
イベントをbroadcast
↓
Reverbで配信
↓
Echoで受信
↓
画面に追加

この中核が理解できれば、ログイン付きチャットや複数ルーム、既読機能などにも発展させやすくなります。