laravel + vue.js + pusher でリアルタイムチャットがやっと完成しました。
ララジャパンの素晴らしいサンプルのおかげで漸くできました。 このサンプルがなければ自力ではとても無理だったと思います。
reverb によるリアルタイムチャットがどうもあまり良くなくて、pusher でのリアルタイムチャットを目指して 2 週間あまり。 おそらく 100 時間ほどかかりました。
chatGPT を信頼して依存しすぎて自分で考えることをしなかったため、こんなにも多くの時間がかかってしまいました。
環境は
linux mint 22.1php 8.3laraval 11
.├── app│ ├── Events│ │ └── MessageSent.php│ ├── Http│ │ └── Controllers│ │ └── ChatsController.php│ └── Models│ ├── Message.php│ └── User.php├── config│ └── broadcasting.php├── resources│ ├── js│ │ ├── app.js│ │ ├── bootstrap.js│ │ ├── chat.js│ │ └── components│ │ ├── ChatForm.vue│ │ └── ChatMessages.vue│ └── views│ ├── chat.blade.php│ ├── dashboard.blade.php│ └── layouts│ └── app.blade.php├── routes│ ├── channels.php│ └── web.php├── tailwind.config.js└── vite.config.js
composer create-project laravel/laravel laravuecd laravuenpm installnpm install vuenpm install @vitejs/plugin-vuenpm install -D tailwindcss postcss autoprefixernpx tailwindcss init -p
composer require pusher/pusher-php-servercomposer require laravel/breeze --devphp artisan breeze:installphp artisan install:broadcastingnpm install bootstrap
npm run build
php artisan make:controller ChatsControllerphp artisan make:event MessageSentphp artisan make:model Message -m
ChatsController
<?php
namespace App\Http\Controllers;
use App\Events\MessageSent;use App\Models\Message;use Illuminate\Support\Facades\Auth;use Illuminate\Http\Request;use Illuminate\Routing\Controller;
class ChatsController extends Controller{ public function __construct() { $this->middleware('auth'); } public function index() { return view('chat'); } public function fetchMessages() { return Message::with('user')->get(); } public function sendMessage(Request $request) { $user = Auth::user();
$message = $user->messages()->create([ 'message' => $request->message, ]);
broadcast(new MessageSent($user, $message))->toOthers(); return ['status' => 'Message Sent!']; }}
MessageSent
<?php
namespace App\Events;
use App\Models\User;use App\Models\Message;use Illuminate\Broadcasting\Channel;use Illuminate\Broadcasting\InteractsWithSockets;use Illuminate\Broadcasting\PresenceChannel;use Illuminate\Broadcasting\PrivateChannel;use Illuminate\Contracts\Broadcasting\ShouldBroadcast;use Illuminate\Foundation\Events\Dispatchable;use Illuminate\Queue\SerializesModels;
class MessageSent implements ShouldBroadcast{ use Dispatchable, InteractsWithSockets, SerializesModels;
public $user; public $message;
public function __construct(User $user, Message $message) { $this->user = $user; $this->message = $message; } public function broadcastOn() { return new PrivateChannel('chat'); }}
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 = ['message'];
public function user() { return $this->belongsTo(User::class); }}
User.php を編集。追加します。
... public function messages() { return $this->hasMany(Message::class); }
migrateion テーブル
public function up(): void { Schema::create('messages', function (Blueprint $table) { $table->id(); $table->foreignId('user_id')->constrained()->onDelete('cascade'); $table->text('message'); $table->timestamps(); }); }
.env
...
DB_CONNECTION=mysqlDB_HOST=127.0.0.1DB_PORT=3306DB_DATABASE=laravueDB_USERNAME=rootDB_PASSWORD=pass
BROADCAST_DRIVER=pusher....
#pusher設定などPUSHER_APP_ID=---------------PUSHER_APP_KEY=-----------------PUSHER_APP_SECRET=----------------PUSHER_APP_CLUSTER=ap3PUSHER_HOST=PUSHER_PORT=443PUSHER_SCHEME=https
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"VITE_REVERB_HOST="${REVERB_HOST}"VITE_REVERB_PORT="${REVERB_PORT}"VITE_REVERB_SCHEME="${REVERB_SCHEME}"
マイグレーション
php artisan migrate:freshphp artisan migrate
broadcast.php で reverb の部分を削除して、pusher だけにします。
それと、.env の設定に合わせて BROADCAST_DRIVER にします。
'default' => env('BROADCAST_DRIVER', 'null'),// .env の設定が BROADCAST_DRIVER であればこのようにする
'connections' => [
'pusher' => [ 'driver' => 'pusher', 'key' => env('PUSHER_APP_KEY'), 'secret' => env('PUSHER_APP_SECRET'), 'app_id' => env('PUSHER_APP_ID'), 'options' => [ 'cluster' => env('PUSHER_APP_CLUSTER'), 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', 'port' => env('PUSHER_PORT', 443), 'scheme' => env('PUSHER_SCHEME', 'https'), 'encrypted' => true, 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', ], 'client_options' => [ // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html ], ],
dashboard
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ __('Dashboard') }} </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 text-gray-900"> {{ __("You're logged in!") }} </div> </div>
<!-- チャットページへのリンク --> <div class="mt-6 flex justify-center"> <a href="{{ route('chat') }}" class="px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow-md hover:bg-blue-600"> チャットページへ </a> </div> </div> </div></x-app-layout>
app.blade.
<!doctype html><html lang="{{ str_replace('_', '-', app()->getLocale()) }}"><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token --> <meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Fonts --> <link rel="dns-prefetch" href="//fonts.bunny.net"> <link href="https://fonts.bunny.net/css?family=Nunito" rel="stylesheet">
<!-- Scripts --> @vite(['resources/css/app.css', 'resources/js/app.js']) @stack('scripts')</head><body class="bg-gray-100"> <div id="app"> <nav class="bg-white shadow"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="flex justify-between h-16 items-center"> <div class="flex items-center"> <a class="text-lg font-bold text-gray-900" href="{{ url('/') }}"> {{ config('app.name', 'Laravel') }} </a> </div>
<!-- ナビゲーションメニュー --> <div class="hidden md:flex space-x-4"> @guest @if (Route::has('login')) <a href="{{ route('login') }}" class="text-gray-900 hover:text-indigo-600 px-3 py-2 rounded-md text-sm font-medium"> {{ __('Login') }} </a> @endif
@if (Route::has('register')) <a href="{{ route('register') }}" class="text-gray-900 hover:text-indigo-600 px-3 py-2 rounded-md text-sm font-medium"> {{ __('Register') }} </a> @endif @else <div class="relative"> <button class="text-gray-900 hover:text-indigo-600 px-3 py-2 rounded-md text-sm font-medium focus:outline-none" onclick="toggleDropdown()"> {{ Auth::user()->name }} </button> <div id="dropdown-menu" class="hidden absolute right-0 mt-2 w-48 bg-white border rounded-md shadow-lg"> <a href="{{ route('logout') }}" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100" onclick="event.preventDefault(); document.getElementById('logout-form').submit();"> {{ __('Logout') }} </a> <form id="logout-form" action="{{ route('logout') }}" method="POST" class="hidden"> @csrf </form> </div> </div> @endguest </div> </div> </div> </nav>
@if (isset($header)) <header class="bg-white shadow"> <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8"> {{ $header }} </div> </header> @endif
<main class="py-4"> {{ $slot }} </main> </div>
<script> function toggleDropdown() { document.getElementById("dropdown-menu").classList.toggle("hidden"); } </script></body></html>
chat.blade
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> 会員チャット </h2> </x-slot>
<div class="max-w-7xl mx-auto p-6"> <div class="bg-white shadow-md rounded-lg"> <div class="px-4 py-3 bg-gray-200 border-b border-gray-300 font-semibold"> 会員チャット </div> <div class="px-4 py-6"> <!-- チャットメッセージの表示部分 --> <chat-messages :messages="messages" :user="{{ auth()->user() }}"></chat-messages> </div> <div class="px-4 py-3 bg-gray-200 border-t border-gray-300"> <!-- メッセージ送信部分 --> <chat-form v-on:messagesent="addMessage" :user="{{ auth()->user() }}"></chat-form> </div> </div> </div>
@push('scripts') @vite(['resources/js/chat.js']) @endpush</x-app-layout>
ルーティング
<?php
use App\Http\Controllers\ProfileController;use App\Http\Controllers\ChatsController;use Illuminate\Support\Facades\Route;
// メッセージ関連のルートRoute::get('/messages', [ChatsController::class, 'fetchMessages']);Route::post('/messages', [ChatsController::class, 'sendMessage']);Route::get('/chat', [ChatsController::class, 'index'])->name('chat');
// ホームとトップページRoute::get('/', function () { return view('welcome');});
Route::get('/home', function () { return view('home');})->name('home');
// 認証が必要なルートRoute::middleware(['auth', 'verified'])->group(function () { Route::get('/dashboard', function () { return view('dashboard'); })->name('dashboard'); // プロフィール関連 Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit'); Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update'); Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');});
// 認証用ルートrequire __DIR__.'/auth.php';
channels.php
<?php
use Illuminate\Support\Facades\Auth;use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('chat', function ($user) { return Auth::check();});
ChatForm.vue
<script setup>import { ref } from 'vue';
const props = defineProps({ user: Object});
const newMessage = ref('');const emit = defineEmits(['messagesent']);
function sendMessage(e) { if (newMessage.value === '') return;
emit("messagesent", { user: props.user, message: newMessage.value });
newMessage.value = "";}</script>
<template> <div class="input-group"> <input type="text" class="form-control" placeholder="メッセージをタイプしてください..." v-model="newMessage" @keydown.enter="sendMessage" /> <span class="input-group-btn"> <button class="btn btn-primary" @click="sendMessage"> 送信 </button> </span> </div></template>
ChatMessages.vue
<script setup>
defineProps({ messages: Array, user: Object});</script>
<template> <ul style="list-style:none"> <li v-for="message in messages" :key="message.id" :class="message.user.id === user.id ? 'text-end' : 'text-start'"> <div class="header"> <strong> <span v-if="message.user.id === user.id">私</span> <span v-else>{{ message.user.name }}さん</span> </strong> </div> <p> {{ message.message }} </p> </li> </ul></template>
app.js
import './bootstrap';
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
bootstrap.js
import 'bootstrap';
import axios from 'axios';window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
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 ?? `ws-${import.meta.env.VITE_PUSHER_APP_CLUSTER}.pusher.com`, wsPort: import.meta.env.VITE_PUSHER_PORT ?? 80, wssPort: import.meta.env.VITE_PUSHER_PORT ?? 443, forceTLS: (import.meta.env.VITE_PUSHER_SCHEME ?? 'https') === 'https', enabledTransports: ['ws', 'wss'], cluster:import.meta.env.VITE_PUSHER_APP_CLUSTER,});
chat.js
import { createApp, ref } from 'vue';import ChatMessages from './components/ChatMessages.vue';import ChatForm from './components/ChatForm.vue';
createApp({ setup () { const messages = ref([]);
fetchMessages();
window.Echo.private('chat') .listen('MessageSent', (e) => { messages.value.push({ message: e.message.message, user: e.user }); });
function fetchMessages() { axios.get('/messages').then(response => { messages.value = response.data; }); }
function addMessage(message) { messages.value.push(message);
axios.post('/messages', message).then(response => { // success }); }
return { messages, fetchMessages, addMessage } }}).component('chat-messages', ChatMessages).component('chat-form', ChatForm).mount('#app');
tailwind.config.js の編集
content: [ './vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php', './storage/framework/views/*.php', './resources/views/**/*.blade.php', './resources/js/**/*.vue', // 追加 ],
vite.config.js
import { defineConfig } from 'vite';import laravel from 'laravel-vite-plugin';import vue from '@vitejs/plugin-vue';
export default defineConfig({ plugins: [ laravel({ input: ['resources/css/app.css', 'resources/js/app.js'], refresh: true, }), vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false, }, }, }), ], resolve: { alias: { vue: 'vue/dist/vue.esm-bundler.js', }, },});
cd ~/laravue
php artisan config:clearphp artisan cache:clearphp artisan view:clearphp artisan route:clearnpm run build
php artisan serve