1
0

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

Posted at

はじめに

このプロジェクトを作成しようと思ったきっかけ

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

本デモについて

構築ステップ

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 servephp 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 エレメントの下に表示されることを確認します。

スクリーンショット 2024-07-25 133701.png

終わりに

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

本デモコードのソースコードはこちらからご覧ください!

最後まで読んでいただきありがとうございました。
質問・メッセージ等ございましたら、遠慮なくコンタクトください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0