laravel11 + livewire + reverb によるリアルタイムチャット

(2025-02-01)

livewire を使ったリアルタイムのチャットをやりたいと思いました。

livewire の内部で何がどのように設定されているのかがわからないので chatGPT に訊いたのですが chatGPT もあまり詳しくないようで、 その割に適当に回答してくるのでどんどんハマってしまい、数日かかりましたがなんとか実現できました。

chatGPT よりも Gemini の方がいいのかと思ってそちらも試したのですが、Gemini はほとんど役に立ちませんでした。

プログラミングに関しては chatGPT の方が遥かに優れていると思います。

プロジェクト作成と設定

composer create-project --prefer-dist laravel/laravel laralivechat
cd laralivechat
composer require livewire/livewire
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
php artisan install:broadcasting
php artisan make:model Message
php artisan make:livewire Chat
php artisan make:event ChatEvent

messages テーブル作成

php artisan make:migration create_message_table

編集。

database/migrations/2025_01_24_040322_create_message_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->id();
$table->string('content',255);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('messages');
}
};

マイグレーション。

php artisan migrate

model

app/Model/Message.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
use HasFactory;
protected $fillable = [
'content',
];
}

livewire コンポーネント

app/Livewire/Chat.php
<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Message;
class Chat extends Component
{
public function render()
{
$messages = Message::orderBy('created_at', 'desc')->take(20)->get();
return view('livewire.chat', ['messages' => $messages]);
}
}

resources/views/livewire/chat.blade.php
<div>
<div>
<input type="text" id="message" name="message" placeholder="メッセージを書く">
<button id="send-button">送信</button>
</div>
<ul id="message-list">
@foreach ($messages as $message)
<li class="message">{{ $message->content }}</li>
@endforeach
</ul>
</div>

resources/views/components/layouts/app.blade.php の新規作成。

resources/views/components/layouts/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite('resources/js/app.js')
<title>components.layouts.app.blade</title>
</head>
<body>
{{ $slot }}
</body>
</html>

イベント

app/Events/ChatEvent.php
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Queue\SerializesModels;
use App\Models\Message;
use Illuminate\Foundation\Events\Dispatchable;
class ChatEvent implements ShouldBroadcast
{
use Dispatchable, SerializesModels;
public $message;
public function __construct($message)
{
// メッセージをモデルに保存する
$this->message = $message;
$newMessage = new Message();
$newMessage->content = $message;
$newMessage->save();
}
public function broadcastOn()
{
return new Channel('channel-chat'); // チャネルを指定
}
public function broadcastWith()
{
// ブロードキャストするデータ
return [
'message' => $this->message,
];
}
}

js 設定

app/js/app.js
// app.js
import './bootstrap';
import './chat';
console.log('app.js is loaded');

app/js/chat.js の新規作成。

app/js/chat.js
/**
* Send a message to the server
*/
document.getElementById('send-button').addEventListener('click', () => {
const message = document.getElementById('message').value;
//console.log(message) ;
if (message) {
axios.post('/', { message: message }).then(() => {
document.getElementById('message').value = '';
});
}
});
/**
* Listen for events on the channel-chat channel
*/
Echo.channel('channel-chat')
.listen('ChatEvent', (e) => {
const newMessage = document.createElement('li');
newMessage.classList.add('message');
newMessage.textContent = `${e.message} (リアルタイム)`;
const ul = document.getElementById('message-list');
ul.prepend(newMessage); // 新しいメッセージをリストの最初に追加
});

routing

<?php
use Illuminate\Support\Facades\Route;
use App\Livewire\Chat;
use App\Events\ChatEvent;
use Illuminate\Http\Request;
Route::get('/', Chat::class);
# ポスト用ルーティング
Route::post('/', function (Request $request) {
ChatEvent::dispatch($request->message);
});

起動

以下のようなシェルスクリプトを作成して実行。

#!/bin/bash
# プロセス終了時に一括で停止する設定
trap 'kill 0' EXIT
cd ~/laralivechat
# キャッシュなどをクリア
php artisan config:clear
php artisan cache:clear
php artisan view:clear
php artisan route:clear
npm run build
# NPMの開発サーバーをバックグラウンドで実行
npm run dev &
echo "Started npm run dev with PID $!"
# PHP Artisanのreverb:startをバックグラウンドで実行
php artisan reverb:start &
echo "Started php artisan reverb:start with PID $!"
# PHP Artisanのサーバーをバックグラウンドで実行
php artisan serve &
echo "Started php artisan serve with PID $!"
# PHP Artisanのキューをバックグラウンドで実行
php artisan queue:work &
echo "Started php artisan queue:work with PID $!"
# 全プロセスが終了するまで待機
wait

ポイント

これだけのためにおそらく数十時間を費やしたと思います。
主な理由は chatGPT が reverb を使ったリアルタイムチャットをよく知らないことでした。でも自分一人では絶対に解決できなかったと思います。

livewire は render を必要とする

これを全く知りませんでした。

chatGPT が最後までこだわったのは sendMessage メソッドで、chatGPT が示したコードは、

<?php
namespace App\Livewire;
use App\Models\Message;
use Livewire\Component;
use Illuminate\Support\Facades\Log;
class Chat extends Component
{
public $message; // メッセージの入力値を格納するプロパティ
public function sendMessage()
{
if (!empty($this->message)) {
Message::create([
'content' => $this->message
]);
$this->message = '';
}
}
public function render()
{
$messages = Message::orderBy('created_at', 'desc')->take(20)->get();
return view('livewire.chat', [
'messages' => $messages
]);
}
}

こうすると、書き込んだメッセージが2つ表示されます。

実際のコードは、

<?php
namespace App\Livewire;
use Livewire\Component;
use App\Models\Message;
class Chat extends Component
{
public function render()
{
$messages = Message::orderBy('created_at', 'desc')->take(20)->get();
return view('livewire.chat', ['messages' => $messages])
->layout('layouts.app');
}
}

本来は公式ドキュメントを読むべきなのでしょうが、膨大かつ難解なので内容を理解するには相当な時間が必要でしょう。

js の構造が複雑

resources/views/components/layouts/app.blade.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@vite('resources/js/app.js')
<title>components.layouts.app.blade</title>
</head>
<body>
{{ $slot }}
</body>
</html>

となっているので、最初に app.js が呼ばれます。

app/js/app.js
// app.js
import './bootstrap';
import './chat';

そしてこの記述に従って、bootstrap.js と chat.js をインポートします。

app/js/bootstrap.js
import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
/**
* Echo exposes an expressive API for subscribing to channels and listening
* for events that are broadcast by Laravel. Echo and event broadcasting
* allow your team to quickly build robust real-time web applications.
*/
import './echo';

ここで echo.js がインポートされます。

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

そして、app.js に記述に従って chat.js がインポートされます。

app/js/chat.js
/**
* Send a message to the server
*/
document.getElementById('send-button').addEventListener('click', () => {
const message = document.getElementById('message').value;
//console.log(message) ;
if (message) {
axios.post('/', { message: message }).then(() => {
document.getElementById('message').value = '';
});
}
});
/**
* Listen for events on the channel-chat channel
*/
Echo.channel('channel-chat')
.listen('ChatEvent', (e) => {
const newMessage = document.createElement('li');
newMessage.classList.add('message');
newMessage.textContent = `${e.message} (リアルタイム)`;
const ul = document.getElementById('message-list');
ul.prepend(newMessage); // 新しいメッセージをリストの最初に追加
});

この中で、設定するのは app.js と chat.js だけです。bootstrap.js と echo.js はデフォルトのままでいいと思います。
chatGPT は他のファイルを編集したがりました。この点に関してはよくわかっていないと思います。

reverb の key などの情報を変更する必要はない

これも chatGPT が頻繁に要求してきたことです。

reverb は pusher と異なりインターネットでの認証などは必要ありません。 reverb をインストールすると .env に情報がデフォルトで設定され、その情報を echo.js が自動的に読み取って動作するようになっています。

それを変更すると動かなくなる可能性があります。

messages テーブルの message カラムは混乱する

前回のプロジェクトで messages テーブルの message カラムを作るようになっていたのですが、これがハマる一つの理由になったと思います。

やたら message という言葉が氾濫して何だかわけがわからなくなります。

document.getElementById('send-button').addEventListener('click', () => {
const message = document.getElementById('message').value;
//console.log(message) ;
if (message) {
axios.post('/', { message: message }).then(() => {
document.getElementById('message').value = '';
});
}
});

<div>
<div>
<input type="text" id="message" name="message" placeholder="メッセージを書く">
<button id="send-button">送信</button>
</div>
<ul id="message-list">
@foreach ($messages as $message)
<li class="message">{{ $message->content }}</li>
@endforeach
</ul>
</div>

これは混乱します。

なので、message -> content に変更しました。