Laravel WebSocketsでリアルタイムチャットを作る

はじめに

現代のWebアプリケーションでは、リアルタイム通信機能が不可欠になってきています。特にチャットアプリケーションでは、メッセージのリアルタイム配信がユーザー体験を大きく向上させます。この記事では、Laravel WebSocketsパッケージを使用して、シンプルながらも機能的なリアルタイムチャットアプリケーションを構築する方法を解説します。

前提条件

  • PHP 8.1以上
  • Composer
  • Laravel 10.x
  • Node.js と npm

プロジェクトのセットアップ

まず、新しいLaravelプロジェクトを作成しましょう:

composer create-project laravel/laravel laravel-websockets-chat
cd laravel-websockets-chat

必要なパッケージのインストール

Laravel WebSocketsと関連パッケージをインストールします:

composer require beyondcode/laravel-websockets pusher/pusher-php-server
npm install laravel-echo pusher-js

WebSocketsの設定

1. 環境設定

.envファイルを編集して、Pusher関連の設定を行います:

BROADCAST_DRIVER=pusher

PUSHER_APP_ID=laravel-websockets
PUSHER_APP_KEY=local-key
PUSHER_APP_SECRET=local-secret
PUSHER_HOST=127.0.0.1
PUSHER_PORT=6001
PUSHER_SCHEME=http
PUSHER_APP_CLUSTER=mt1

2. WebSocketsの設定公開

Laravel WebSocketsの設定ファイルを公開します:

php artisan vendor:publish --provider="BeyondCode\LaravelWebSockets\WebSocketsServiceProvider" --tag="config"

3. 認証設定

config/broadcasting.phpファイルのPusher設定を確認し、必要に応じて編集します:

'pusher' => [
    'driver' => 'pusher',
    'key' => env('PUSHER_APP_KEY'),
    'secret' => env('PUSHER_APP_SECRET'),
    'app_id' => env('PUSHER_APP_ID'),
    'options' => [
        'host' => env('PUSHER_HOST', '127.0.0.1'),
        'port' => env('PUSHER_PORT', 6001),
        'scheme' => env('PUSHER_SCHEME', 'http'),
        'encrypted' => false,
        'useTLS' => false,
    ],
],

データベースの設定

1. マイグレーションの実行

WebSocketsのマイグレーションを実行します:

php artisan migrate

2. メッセージモデルの作成

チャットメッセージを保存するためのモデルとマイグレーションを作成します:

php artisan make:model Message -m

database/migrations/{timestamp}_create_messages_table.phpを編集します:

public function up()
{
    Schema::create('messages', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained();
        $table->string('message');
        $table->timestamps();
    });
}

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;

    protected $fillable = ['user_id', 'message'];

    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

イベントの作成

新しいメッセージを送信するためのイベントを作成します:

php artisan make:event NewMessage

app/Events/NewMessage.phpを編集します:

<?php

namespace App\Events;

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

class NewMessage implements ShouldBroadcast
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $message;

    public function __construct(Message $message)
    {
        $this->message = $message;
    }

    public function broadcastOn()
    {
        return new Channel('chat');
    }

    public function broadcastWith()
    {
        return [
            'id' => $this->message->id,
            'user_id' => $this->message->user_id,
            'username' => $this->message->user->name,
            'message' => $this->message->message,
            'created_at' => $this->message->created_at->format('Y-m-d H:i:s')
        ];
    }
}

コントローラーの作成

メッセージを処理するコントローラーを作成します:

php artisan make:controller ChatController

app/Http/Controllers/ChatController.phpを編集します:

<?php

namespace App\Http\Controllers;

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

class ChatController extends Controller
{
    public function index()
    {
        $messages = Message::with('user')->orderBy('created_at', 'asc')->get();
        return view('chat', compact('messages'));
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'message' => 'required|string|max:255',
        ]);

        $message = Message::create([
            'user_id' => auth()->id(),
            'message' => $validated['message'],
        ]);

        $message->load('user');
        
        broadcast(new NewMessage($message))->toOthers();

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

ルーティングの設定

routes/web.phpを編集してルートを追加します:

<?php

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

Route::get('/', function () {
    return view('welcome');
});

Route::middleware(['auth'])->group(function () {
    Route::get('/chat', [ChatController::class, 'index'])->name('chat');
    Route::post('/chat', [ChatController::class, 'store'])->name('chat.store');
});

require __DIR__.'/auth.php';

認証の設定

Laravel Breezeを使って簡単な認証システムをセットアップします:

composer require laravel/breeze --dev
php artisan breeze:install blade
php artisan migrate
npm install
npm run build

フロントエンドの設定

1. Laravel Echoの設定

resources/js/bootstrap.jsを編集して、Laravel Echoの設定を追加します:

import Echo from 'laravel-echo';
import Pusher from 'pusher-js';

window.Pusher = Pusher;

window.Echo = new Echo({
    broadcaster: 'pusher',
    key: import.meta.env.VITE_PUSHER_APP_KEY,
    wsHost: import.meta.env.VITE_PUSHER_HOST || window.location.hostname,
    wsPort: import.meta.env.VITE_PUSHER_PORT || 6001,
    forceTLS: false,
    disableStats: true,
});

2. ビューの作成

resources/views/chat.blade.phpを作成します:

<x-app-layout>
    <x-slot name="header">
        <h2 class="font-semibold text-xl text-gray-800 leading-tight">
            {{ __('チャット') }}
        </h2>
    </x-slot>

    <div class="py-12">
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
                <div class="p-6 bg-white border-b border-gray-200">
                    <div id="chat-messages" class="mb-4 h-96 overflow-y-auto p-4 bg-gray-100 rounded">
                        @foreach($messages as $message)
                            <div class="message mb-2 p-2 rounded @if($message->user_id === auth()->id()) bg-blue-100 ml-auto w-3/4 @else bg-gray-200 mr-auto w-3/4 @endif">
                                <div class="font-bold">{{ $message->user->name }}</div>
                                <div>{{ $message->message }}</div>
                                <div class="text-xs text-gray-500">{{ $message->created_at->format('Y-m-d H:i:s') }}</div>
                            </div>
                        @endforeach
                    </div>
                    <form id="chat-form">
                        @csrf
                        <div class="flex">
                            <input type="text" id="message" name="message" class="flex-1 rounded-l border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" placeholder="メッセージを入力...">
                            <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-r hover:bg-blue-600">送信</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>

    @push('scripts')
    <script>
        document.addEventListener('DOMContentLoaded', function() {
            const chatMessages = document.getElementById('chat-messages');
            const chatForm = document.getElementById('chat-form');
            const messageInput = document.getElementById('message');

            // スクロールを一番下に移動
            function scrollToBottom() {
                chatMessages.scrollTop = chatMessages.scrollHeight;
            }

            // ページ読み込み時にスクロールを一番下に移動
            scrollToBottom();

            // メッセージの追加
            function addMessage(message, isMine = false) {
                const messageDiv = document.createElement('div');
                messageDiv.className = `message mb-2 p-2 rounded ${isMine ? 'bg-blue-100 ml-auto w-3/4' : 'bg-gray-200 mr-auto w-3/4'}`;
                
                const nameDiv = document.createElement('div');
                nameDiv.className = 'font-bold';
                nameDiv.textContent = message.username;
                
                const contentDiv = document.createElement('div');
                contentDiv.textContent = message.message;
                
                const timeDiv = document.createElement('div');
                timeDiv.className = 'text-xs text-gray-500';
                timeDiv.textContent = message.created_at;
                
                messageDiv.appendChild(nameDiv);
                messageDiv.appendChild(contentDiv);
                messageDiv.appendChild(timeDiv);
                
                chatMessages.appendChild(messageDiv);
                scrollToBottom();
            }

            // フォーム送信
            chatForm.addEventListener('submit', function(e) {
                e.preventDefault();
                
                const message = messageInput.value;
                if (!message.trim()) return;
                
                // メッセージをサーバーに送信
                fetch('/chat', {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'X-CSRF-TOKEN': document.querySelector('input[name="_token"]').value
                    },
                    body: JSON.stringify({ message })
                })
                .then(response => response.json())
                .then(data => {
                    // 自分のメッセージを表示
                    addMessage({
                        username: '{{ auth()->user()->name }}',
                        message: message,
                        created_at: new Date().toISOString().replace('T', ' ').substring(0, 19)
                    }, true);
                    
                    // 入力フィールドをクリア
                    messageInput.value = '';
                })
                .catch(error => console.error('Error:', error));
            });

            // WebSocketsでメッセージを受信
            window.Echo.channel('chat')
                .listen('NewMessage', (e) => {
                    // 他のユーザーからのメッセージを表示
                    addMessage(e);
                });
        });
    </script>
    @endpush
</x-app-layout>

WebSocketサーバーの起動

WebSocketサーバーを起動します:

php artisan websockets:serve

別のターミナルでLaravelの開発サーバーも起動します:

php artisan serve

アプリケーションの使用

  1. ブラウザでhttp://localhost:8000/registerにアクセスして、アカウントを作成します
  2. ログイン後、http://localhost:8000/chatにアクセスしてチャットを開始します
  3. 複数のブラウザやシークレットウィンドウで異なるアカウントでログインし、リアルタイムでメッセージのやり取りを確認できます

WebSocketsダッシュボード

Laravel WebSocketsにはダッシュボードが付属しています。config/websockets.php'dashboard' => ['enable' => true]を確認し、http://localhost:8000/laravel-websocketsにアクセスすると、接続状況などを確認できます。

セキュリティ対策

実際の環境では、以下のセキュリティ対策を検討しましょう:

  1. WebSocketsサーバーへのアクセス制限
  2. SSL/TLS暗号化の設定
  3. メッセージのバリデーションとサニタイズ
  4. 認証済みユーザーのみがメッセージを送信できるように制限

機能拡張のアイデア

このベースアプリケーションから、以下のような機能を追加することができます:

  1. プライベートチャットルーム
  2. 既読確認機能
  3. ファイル添付機能
  4. メンション機能
  5. 絵文字サポート

まとめ

Laravel WebSocketsを使用すると、比較的簡単にリアルタイムチャットアプリケーションを構築できます。このパッケージは開発環境だけでなく本番環境でも利用可能で、Pusherサービスの代わりとして使用できるため、コスト効率も良いでしょう。

リアルタイム機能は現代のWebアプリケーションにおいて重要な要素となっており、Laravel WebSocketsは手軽にその機能を実装できるツールとして非常に便利です。ぜひ自分のアプリケーションに取り入れてみてください。

コメント

タイトルとURLをコピーしました