Laravel Reverb と Vue3 を使った Chat App を構築


## はじめに

### このプロジェクトを作成しようと思ったきっかけ
- Laravel Reverb (以下、Reverb) を使ってシンプルなチャットAppを構築したいと思った
- ほかにも幾つか「Reverb + Vue」で構築を説明している記事があったが、一部足りていないことがあったりした
- 「Reverb + Vue3」に加え Laravel Jetstream (以下、Jetstream) または Laravel Breeze ) を使って構築したかったが、その組み合わせでデモを行っている記事は見つからなかった

### 本デモについて

- このデモコードは、下記の記事をほとんどコピーしたものですが、 Jetstream (Inertia) を使っている点が異なります。Harish Kumar さん、貴重な記事をありがとうございます:clap:
  - https://qirolab.com/posts/building-real-time-chat-applications-with-laravel-reverb-and-vue-3 


## 構築ステップ

### 1. 新規 Laravel プロジェクトを作成し、必要なパッケージをインストールする。

Laravel プロジェクトを作成
```bash
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 を作成

まずは、モデルとマイグレーションファイルを下記コマンドで一括生成します。

```bash
php artisan make:model ChatMessage -m
```

- マイグレーションファイルを編集。

```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('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');
    }
};

```


```bash
php artisan migrate
```

ChatMessage.php モデルファイルを編集。

```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
<?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

<?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 を記載するためのファイルです。以下のルートを追加します。


```php
Broadcast::channel('chat.{id}', function ($user, $id) {
    return (int) $user->id === (int) $id;
});
```



### 6. Vue ページとコンポーネントを作成

Dashboard ページを作成するため、`resources/js/Pages/Dashboard.vue` ファイル (デフォルトで存在) を下記の通り編集していきます。
 
```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`ファイルを作成し、下記のように編集します。これは、チャット画面の大元のファイルです。

```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` を作成し、下記のように編集していきます。

```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

<?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();
    }
}
```

シードを行います。

```bash
php artisan db:seed
```


### 8. Chat App が動くかテスト

下記のコマンドを、それぞれ異なるターミナルで実行します。

```bash
php artisan serve
```
```bash
php artisan reverb:start --debug
```
```bash
npm run dev
```

下記のように、タブを2つ開き Chat が受信送信でき、タイピング中は、その旨のメッセージが Input エレメントの下に表示されることを確認します。

![スクリーンショット 2024-07-25 133701.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789456/3603d68a-d854-7731-d429-120b806609f1.png)



## 終わりに

本デモを通して、Reverb のパワフルさと Jetstream を使うことでフロントを Vue でスムーズに構築することが体験できたかと思います。私もまさしくその1人です。
Reverb は、Laravel 11より導入された新機能ですが、私が拝見した複数の記事でも紹介されている通り、ほぼ裏技のようなものなので、いつかパブリックの App に組み込めたらと考えています。

本デモコードのソースコードは[こちら](https://github.com/memorandumtk/chat-demo-app-laravel-reverb-vue3)からご覧ください!

最後まで読んでいただきありがとうございました。
質問・メッセージ等ございましたら、メッセージ等お待ちしております!