はじめに
このプロジェクトを作成しようと思ったきっかけ
- Laravel Reverb (以下、Reverb) を使ってシンプルなチャットAppを構築したいと思った
- ほかにも幾つか「Reverb + Vue」で構築を説明している記事があったが、一部足りていないことがあったりした
- 「Reverb + Vue3」に加え Laravel Jetstream (以下、Jetstream) または Laravel Breeze ) を使って構築したかったが、その組み合わせでデモを行っている記事は見つからなかった
本デモについて
- このデモコードは、下記の記事をほとんどコピーしたものですが、 Jetstream (Inertia) を使っている点が異なります。Harish Kumar さん、貴重な記事をありがとうございます!
構築ステップ
1. 新規 Laravel プロジェクトを作成し、必要なパッケージをインストールする。
Laravel プロジェクトを作成
composer create-project laravel/laravel vue-chat
cd vue-chat
composer require laravel/jetstream
php artisan jetstream:install inertia
php artisan install:broadcasting
npm run build
php artisan serve
と php artisan reverb:start --debug
コマンドを使って、一度 Jetstream のデフォルト画面が確認できるか、ログインができるかを確認することをお勧めします。
2. ChatMessage Model & Migration を作成
まずは、モデルとマイグレーションファイルを下記コマンドで一括生成します。
php artisan make:model ChatMessage -m
- マイグレーションファイルを編集。
<?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('chat_messages', function (Blueprint $table) {
$table->id();
$table->foreignId('receiver_id');
$table->foreignId('sender_id');
$table->text('text');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('chat_messages');
}
};
php artisan migrate
ChatMessage.php モデルファイルを編集。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ChatMessage extends Model
{
use HasFactory;
protected $fillable = [
'sender_id',
'receiver_id',
'text'
];
public function sender()
{
return $this->belongsTo(User::class, 'sender_id');
}
public function receiver()
{
return $this->belongsTo(User::class, 'receiver_id');
}
}
3. Routes を作成
-
routes/web.php
を下記のように編集。
<?php
use App\Events\MessageSent;
use App\Models\ChatMessage;
use App\Models\User;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Inertia\Inertia;
Route::get('/', function () {
return Inertia::render('Welcome', [
'canLogin' => Route::has('login'),
'canRegister' => Route::has('register'),
'laravelVersion' => Application::VERSION,
'phpVersion' => PHP_VERSION,
]);
});
Route::middleware([
'auth:sanctum',
config('jetstream.auth_session'),
'verified',
])->group(function () {
// ここから追加したコード
Route::get('/dashboard', function () {
return Inertia::render('Dashboard', [
'users' => User::query()->where('id', '!=', auth()->id())->get()
]);
})->middleware(['auth'])->name('dashboard');
Route::get('/chat/{friend}', function (User $friend) {
return Inertia::render('Chat', [
'friend' => $friend,
'user' => auth()->user()
]);
})->middleware(['auth'])->name('chat');
Route::get('/messages/{friend}', function (User $friend) {
return ChatMessage::query()
->where(function ($query) use ($friend) {
$query->where('sender_id', auth()->id())
->where('receiver_id', $friend->id);
})
->orWhere(function ($query) use ($friend) {
$query->where('sender_id', $friend->id)
->where('receiver_id', auth()->id());
})
->with(['sender', 'receiver'])
->orderBy('id', 'asc')
->get();
})->middleware(['auth']);
Route::post('/messages/{friend}', function (User $friend) {
$message = ChatMessage::create([
'sender_id' => auth()->id(),
'receiver_id' => $friend->id,
'text' => request()->input('message')
]);
broadcast(new MessageSent($message));
return $message;
});
});
4. MessageSent イベントを作成
-
app/Events/MessageSent.php
を下記のように編集。
<?php
namespace App\Events;
use App\Models\ChatMessage;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Queue\SerializesModels;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
class MessageSent implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
/**
* Create a new event instance.
*/
public function __construct(public ChatMessage $message)
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel("chat.{$this->message->receiver_id}"),
];
}
}
5. Private Channels を channels.php に定義
routes/channels.php
は、Reverb をインストールした時に自動作成される WebSocket 通信用の Routes を記載するためのファイルです。以下のルートを追加します。
Broadcast::channel('chat.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
6. Vue ページとコンポーネントを作成
Dashboard ページを作成するため、resources/js/Pages/Dashboard.vue
ファイル (デフォルトで存在) を下記の通り編集していきます。
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
defineProps(
{
users: Array,
}
);
</script>
<template>
<AppLayout title="Dashboard">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Dashboard
</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div v-for="user in users" class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6">
<div class="flex items-center">
// この部分が自分以外のユーザへのチャット画面へのURLとなります。
<a :href="`chat/${user.id}`">
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">{{ user.name }}</div>
<div class="text-sm text-gray-500">{{ user.email }}</div>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
resources/js/Pages/Chat.vue
ファイルを作成し、下記のように編集します。これは、チャット画面の大元のファイルです。
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Chat from '@/Components/Chat.vue';
defineProps(
{
friend: Object,
user: Object,
}
);
</script>
<template>
<AppLayout title="Chat Page">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Chat Page With {{ friend.name }}
</h2>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="py-12">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div class="overflow-hidden bg-white shadow-sm sm:rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
<Chat
:friend="friend"
:currentUser="user"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
-
resources/js/Components/Chat.vue
を作成し、下記のように編集していきます。
<script setup>
import axios from "axios";
import { nextTick, onMounted, ref, watch } from "vue";
const props = defineProps({
friend: {
type: Object,
required: true,
},
currentUser: {
type: Object,
required: true,
},
});
const messages = ref([]);
const newMessage = ref("");
const messagesContainer = ref(null);
const isFriendTyping = ref(false);
const isFriendTypingTimer = ref(null);
watch(
messages,
() => {
nextTick(() => {
messagesContainer.value.scrollTo({
top: messagesContainer.value.scrollHeight,
behavior: "smooth",
});
});
},
{ deep: true }
);
const sendMessage = () => {
if (newMessage.value.trim() !== "") {
axios
.post(`/messages/${props.friend.id}`, {
message: newMessage.value,
})
.then((response) => {
messages.value.push(response.data);
newMessage.value = "";
});
}
};
const sendTypingEvent = () => {
Echo.private(`chat.${props.friend.id}`).whisper("typing", {
userID: props.currentUser.id,
});
};
onMounted(() => {
axios.get(`/messages/${props.friend.id}`).then((response) => {
console.log(response.data);
messages.value = response.data;
});
Echo.private(`chat.${props.currentUser.id}`)
.listen("MessageSent", (response) => {
messages.value.push(response.message);
})
.listenForWhisper("typing", (response) => {
isFriendTyping.value = response.userID === props.friend.id;
if (isFriendTypingTimer.value) {
clearTimeout(isFriendTypingTimer.value);
}
isFriendTypingTimer.value = setTimeout(() => {
isFriendTyping.value = false;
}, 1000);
});
});
</script>
<template>
<div>
<div class="flex flex-col justify-end h-80">
<div ref="messagesContainer" class="p-4 overflow-y-auto max-h-fit">
<div
v-for="message in messages"
:key="message.id"
class="flex items-center mb-2"
>
<div
v-if="message.sender_id === currentUser.id"
class="p-2 ml-auto text-white bg-blue-500 rounded-lg"
>
{{ message.text }}
</div>
<div v-else class="p-2 mr-auto bg-gray-200 rounded-lg">
{{ message.text }}
</div>
</div>
</div>
</div>
<div class="flex items-center">
<input
type="text"
v-model="newMessage"
@keydown="sendTypingEvent"
@keyup.enter="sendMessage"
placeholder="Type a message..."
class="flex-1 px-2 py-1 border rounded-lg"
/>
<button
@click="sendMessage"
class="px-4 py-1 ml-2 text-white bg-blue-500 rounded-lg"
>
Send
</button>
</div>
<small v-if="isFriendTyping" class="text-gray-700">
{{ friend.name }} is typing...
</small>
</div>
</template>
<style scoped>
</style>
7. デモ用のユーザデータを作成
下記のようにdatabase/seeders/DatabaseSeeder.php
ファイルを書き換えていきます。ユーザは、database/factories/UserFactory.php
の情報に沿って作成されるようになります。そのため、パスワードを変更していなければ、「username: ダミーメールアドレス + password: password」でログインが可能となります。
<?php
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
User::factory(10)->create();
}
}
シードを行います。
php artisan db:seed
8. Chat App が動くかテスト
下記のコマンドを、それぞれ異なるターミナルで実行します。
php artisan serve
php artisan reverb:start --debug
npm run dev
下記のように、タブを2つ開き Chat が受信送信でき、タイピング中は、その旨のメッセージが Input エレメントの下に表示されることを確認します。
終わりに
本デモを通して、Reverb のパワフルさと Jetstream を使うことでフロントを Vue でスムーズに構築することが体験できたかと思います。私もまさしくその1人です。
Reverb は、Laravel 11より導入された新機能ですが、私が拝見した複数の記事でも紹介されている通り、ほぼ裏技のようなものなので、いつかパブリックの App に組み込めたらと考えています。
本デモコードのソースコードはこちらからご覧ください!
最後まで読んでいただきありがとうございました。
質問・メッセージ等ございましたら、遠慮なくコンタクトください。