Чат и Сообщения

Раз у нас есть мэтчи, людям нужно общаться! При мэтче мы будем создавать “Комнату/Диалог” (Conversation), а внутри неё уже писать сообщения (Messages).

1. Создаем миграции:

php artisan make:migration create_conversations_table
php artisan make:migration create_messages_table

2.Открой миграцию ..._create_conversations_table.php:

    public function up(): void
    {
        Schema::create('conversations', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user1_id')->constrained('users')->onDelete('cascade');
            $table->foreignId('user2_id')->constrained('users')->onDelete('cascade');
            $table->timestamp('last_message_at')->nullable(); // Для сортировки диалогов по последнему сообщению
            $table->timestamps();

            // Уникальный индекс: диалог между двумя юзерами может быть только один!
            $table->unique(['user1_id', 'user2_id']);
        });
    }

3. Открой миграцию ..._create_messages_table.php:

    public function up(): void
    {
        Schema::create('messages', function (Blueprint $table) {
            $table->id();
            $table->foreignId('conversation_id')->constrained()->onDelete('cascade');
            $table->foreignId('sender_id')->constrained('users')->onDelete('cascade');
            $table->text('body'); // Текст сообщения
            $table->boolean('is_read')->default(false); // Прочитано или нет
            $table->timestamps();
        });
    }

4. Накатываем миграции:

php artisan migrate

Как только таблицы создадутся, мы обновим SwipeController, чтобы он автоматически создавал диалог при мэтче, и напишем API для отправки сообщений но сначала создадим модели !

Создаем модели для чата

Когда происходит Мэтч (взаимный лайк), нам нужно автоматически создать пустой диалог (Conversation), чтобы они могли сразу начать переписку.

Выполни в терминале:

php artisan make:model Conversation
php artisan make:model Message

1. Открой app/Models/Conversation.php и настроим связи:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Conversation extends Model
{
    protected $fillable = ['user1_id', 'user2_id', 'last_message_at'];

    // Участник 1
    public function user1()
    {
        return $this->belongsTo(User::class, 'user1_id');
    }

    // Участник 2
    public function user2()
    {
        return $this->belongsTo(User::class, 'user2_id');
    }

    // Сообщения в диалоге
    public function messages()
    {
        return $this->hasMany(Message::class);
    }

    // Последнее сообщение (удобно для списка диалогов)
    public function lastMessage()
    {
        return $this->hasOne(Message::class)->latestOfMany();
    }

    // Преобразование дат
    protected function casts(): array
    {
        return [
            'last_message_at' => 'datetime',
        ];
    }
}

Открой app/Models/Message.php:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Message extends Model
{
    protected $fillable = ['conversation_id', 'sender_id', 'body', 'is_read'];

    // Кто отправил
    public function sender()
    {
        return $this->belongsTo(User::class, 'sender_id');
    }

    protected function casts(): array
    {
        return [
            'is_read' => 'boolean',
        ];
    }
}

Обновляем SwipeController (Создаем диалог при Мэтче!)

Теперь идем в app/Http/Controllers/SwipeController.php. Нам нужно добавить логику создания Conversation, если is_match стал true.

Важный нюанс: чтобы не было дублей диалогов (например, Алекс создал диалог с Анной, а Анна потом пытается создать второй), мы будем сохранять ID юзеров по возрастанию: кто первый, кто второй — не важно, диалог между ними один!

Замени блок внутри store() где у нас проверка на мэтч:

        $isMatch = false;

        if ($request->action === 'like' || $request->action === 'superlike') {
            
            $reverseSwipe = Swipe::where('swiper_id', $swipedId)
                ->where('swiped_id', $swiperId)
                ->whereIn('action', ['like', 'superlike'])
                ->exists();

            if ($reverseSwipe) {
                $isMatch = true;
                
                // НОВОЕ: Создаем диалог при мэтче!
                // Чтобы не было дублей, меньший ID всегда на первом месте
                $smallerId = min($swiperId, $swipedId);
                $largerId = max($swiperId, $swipedId);

                Conversation::firstOrCreate(
                    ['user1_id' => $smallerId, 'user2_id' => $largerId],
                    ['last_message_at' => now()]
                );
            }
        }

Создаем ChatController (Отправка и чтение)

Теперь сделаем API для списка диалогов и отправки сообщений.

php artisan make:controller ChatController

Открой app/Http/Controllers/ChatController.php:

<?php

namespace App\Http\Controllers;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Http\Request;

class ChatController extends Controller
{
    // 1. Получить список всех диалогов юзера
    public function index(Request $request)
    {
        $userId = $request->user()->id;

        // Ищем диалоги, где юзер является либо user1, либо user2
        $conversations = Conversation::where('user1_id', $userId)
            ->orWhere('user2_id', $userId)
            ->with(['user1.profile', 'user2.profile', 'lastMessage']) // Подтягиваем профили и последнее сообщение
            ->orderBy('last_message_at', 'desc') // Самые свежие сверху
            ->get()
            ->map(function ($conv) use ($userId) {
                // Определяем, с кем мы общаемся (если я user1, то собеседник user2)
                $companion = $conv->user1_id === $userId ? $conv->user2 : $conv->user1;
                
                return [
                    'conversation_id' => $conv->id,
                    'companion' => [
                        'id' => $companion->id,
                        'name' => $companion->profile->name,
                        // 'avatar' => $companion->profile->avatar, (когда добавим фото)
                    ],
                    'last_message' => $conv->lastMessage ? $conv->lastMessage->body : null,
                    'updated_at' => $conv->last_message_at,
                ];
            });

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

    // 2. Получить сообщения конкретного диалога
    public function messages(Request $request, $conversationId)
    {
        $conversation = Conversation::findOrFail($conversationId);

        // Проверка безопасности: юзер должен быть участником этого диалога
        if ($request->user()->id !== $conversation->user1_id && $request->user()->id !== $conversation->user2_id) {
            return response()->json(['message' => 'Доступ запрещен'], 403);
        }

        return Message::where('conversation_id', $conversationId)
            ->with('sender.profile')
            ->orderBy('created_at', 'asc') // Старые внизу, новые вверху (как в WhatsApp)
            ->get();
    }

    // 3. Отправить сообщение
    public function send(Request $request, $conversationId)
    {
        $conversation = Conversation::findOrFail($conversationId);

        if ($request->user()->id !== $conversation->user1_id && $request->user()->id !== $conversation->user2_id) {
            return response()->json(['message' => 'Доступ запрещен'], 403);
        }

        $request->validate([
            'body' => 'required|string|max:1000',
        ]);

        $message = Message::create([
            'conversation_id' => $conversationId,
            'sender_id' => $request->user()->id,
            'body' => $request->body,
        ]);

        // Обновляем время последнего сообщения в диалоге (чтобы он поднялся в списке)
        $conversation->update(['last_message_at' => now()]);

        return response()->json([
            'message' => 'Сообщение отправлено',
            'data' => $message->load('sender.profile')
        ], 201);
    }
}

Добавляем роуты чата

В routes/api.php внутри защищенной группы добавь:

    // Чат
    Route::get('/chats', [ChatController::class, 'index']);
    Route::get('/chats/{conversationId}/messages', [ChatController::class, 'messages']);
    Route::post('/chats/{conversationId}/send', [ChatController::class, 'send']);

ТЕСТИРОВАНИЕ ЧАТА!

У нас Алекс и Анна уже залайкали друг друга на прошлом шаге, значит в базе уже должен был создаться диалог (если ты обновил SwipeController и засвайпал их заново. Если нет, давай просто получим их токены и они сделают тестовый свайп по новой, либо мы посмотрим диалог).

1. Проверяем список диалогов Алекса:

curl http://127.0.0.1:8000/api/chats -H "Authorization: Bearer ТУТ_ТОКЕН_АЛЕКСА" -H "Accept: application/json"

Там должна отобразиться Анна в качестве собеседника.

2. Алекс пишет Анне сообщение (предположим ID диалога = 1):

curl -X POST http://127.0.0.1:8000/api/chats/1/send -H "Authorization: Bearer ТУТ_ТОКЕН_АЛЕКСА" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"body\":\"Hello Anna\"}"

3. Читаем переписку (сообщения диалога):

curl http://127.0.0.1:8000/api/chats/1/messages -H "Authorization: Bearer ТУТ_ТОКЕН_АЛЕКСА" -H "Accept: application/json"

Брат, мы собрали полноценный бэкенд дейтинга: Регистрация -> Профиль -> Геолокация -> Лента -> Свайпы -> Мэтч -> Чат!

Как работают чаты в современном вебе (Связка REST + WebSocket)

То, что мы написали в ChatController — это REST API. Оно нужно для сохранения истории сообщений в базу данных и их прочтения. Но если мы оставим только его, Анне придется обновлять страницу (или тянуть приложение вниз), чтобы увидеть сообщение от Алекса. Это не чат, это почта!

Для реал-тайма (чтобы сообщения появлялись мгновенно, как в Telegram/WhatsApp) нам нужен WebSocket.

Давай разберем, как это будет работать в нашей архитектуре.

Чат всегда работает по двум каналам:

  1. HTTP (REST) — для надежности. Когда Алекс нажимает “Отправить”, его приложение делает POST /api/chats/1/send. Это гарантирует, что сообщение сохранится в БД, даже если интернет плохой.
  2. WebSocket — для скорости. Как только Laravel сохранил сообщение в БД, он тут же “кричит” по WebSocket-соединению: “Эй, все, кто в этом чате, новое сообщение!”. Приложение Анны слушает этот канал и мгновенно отрисовывает пузырек с текстом.

⚙️ Наш стек для WebSocket: Laravel Reverb

В Laravel 11 появился родной, мощный и бесплатный WebSocket-сервер — Laravel Reverb. Раньше приходилось использовать сторонние сервисы типа Pusher (которые дерут деньги), а теперь всё свое!

Как мы это внедрим (План действий):

1. Установка Reverb: Мы выполним команду php artisan install:reverb. Она настроит WebSocket-сервер и установит фронтенд-библиотеку Laravel Echo, которая будет слушать эти события.

2. Создание Event (События): Мы создадим событие NewMessageSent, которое будет реализовывать интерфейс ShouldBroadcast. Это магический интерфейс Laravel.

3. Изменение ChatController: В методе send мы не просто вернем JSON, а вызовем наше событие:

// Сохраняем в БД
 $message = Message::create([...]);

// ТРАНСЛИРУЕМ ПО WEBSOCKET!
broadcast(new NewMessageSent($message))->toOthers();

4. Авторизация каналов (Безопасность): Мы не хотим, чтобы кто попало читал чужие переписки. Мы настроим систему, чтобы Анна могла подключиться только к WebSocket-каналу chat.1, если она является участницей диалога №1.

5. Слушатель на Фронтенде (Next.js / React Native): Когда мы перейдем к написанию фронтенда, мы напишем там примерно такой код:

Echo.private(`chat.${conversationId}`)
    .listen('NewMessageSent', (e) => {
        // e.message - это новое сообщение от Алекса
        // Мгновенно добавляем его в UI чата без перезагрузки!
        addMessageToChat(e.message); 
    });

Установка Laravel Reverb

Настроить бэкенд так, чтобы он сразу стрелял событиями в WebSocket — это значит, что когда мы дойдем до фронтенда, нам останется просто “подключить уши” и всё будет летать в реал-тайме.

Laravel Reverb — это встроенный сервер WebSocket в Laravel 11+. Он поднимается локально и позволяет серверу “разговаривать” с клиентами мгновенно.

Погнали настраивать!

В терминале выполни команду:

php artisan install:broadcasting --reverb

Установщик задаст несколько вопросов. Отвечай так:

  • Do you want to install Reverb’s first-party Node dependencies?Yes (он установит Laravel Echo для будущего фронтенда).
  • Другие вопросы (про хост/порт) — просто жми Enter (оставим дефолтные значения).

Что он сделал:

  1. Скачал пакет laravel/reverb.
  2. Обновил файл .env (добавил настройки Reverb).
  3. Опубликовал конфиг config/reverb.php.

если выкинет ошибку из за того что не успел установить зависимости по таймауту просто сделай вручную:

npm install --save-dev laravel-echo pusher-js
npm run build

Создаем Событие (Event)

Когда отправляется сообщение, мы должны “выкрикнуть” об этом в WebSocket.

Создаем событие:

php artisan make:event NewMessageSent

Открой файл app/Events/NewMessageSent.php и замени его содержимое на это:

<?php

namespace App\Events;

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

// ВАЖНО: Используем ShouldBroadcastNow, чтобы событие отправлялось мгновенно
// без настройки очередей (Redis Queue), что удобно для разработки
class NewMessageSent implements ShouldBroadcastNow
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    public $message;

    public function __construct(Message $message)
    {
        // Передаем само сообщение, уже с подтянутым профилем отправителя
        $this->message = $message->load('sender.profile');
    }

    // Указываем, в какой канал WebSocket мы будем стрелять
    public function broadcastOn(): array
    {
        // Используем ПРИВАТНЫЙ канал для конкретного диалога (для безопасности!)
        return [
            new PrivateChannel('chat.' . $this->message->conversation_id),
        ];
    }

    // Указываем, какие данные полетят в WebSocket (чтобы не слать всю базу)
    public function broadcastWith(): array
    {
        return [
            'message' => $this->message
        ];
    }
}

Безопасность каналов (Authorization)

Мы создали PrivateChannel. Это значит, что Анна не сможет просто так подключиться к чужому чату. Нам нужно объяснить Laravel, кто имеет право слушать этот канал.

Открой файл routes/channels.php. Добавь туда следующий код:

<?php

use Illuminate\Support\Facades\Broadcast;
use App\Models\Conversation;

// Авторизация приватного канала чата
Broadcast::channel('chat.{conversationId}', function ($user, $conversationId) {
    // Находим диалог
    $conversation = Conversation::find($conversationId);
    
    // Если диалога нет - доступ закрыт
    if (!$conversation) {
        return false;
    }

    // Разрешаем слушать канал ТОЛЬКО если юзер является участником этого диалога
    return $conversation->user1_id === $user->id || $conversation->user2_id === $user->id;
});

Обновляем ChatController (Стреляем событием!)

Теперь идем в app/Http/Controllers/ChatController.php. В методе send мы вызовем наше событие.

Найди метод send и обнови его:

    public function send(Request $request, $conversationId)
    {
        $conversation = Conversation::findOrFail($conversationId);

        if ($request->user()->id !== $conversation->user1_id && $request->user()->id !== $conversation->user2_id) {
            return response()->json(['message' => 'Доступ запрещен'], 403);
        }

        $request->validate([
            'body' => 'required|string|max:1000',
        ]);

        $message = Message::create([
            'conversation_id' => $conversationId,
            'sender_id' => $request->user()->id,
            'body' => $request->body,
        ]);

        $conversation->update(['last_message_at' => now()]);

        // МАГИЯ REVERB: Транслируем событие по WebSocket!
        broadcast(new \App\Events\NewMessageSent($message))->toOthers();

        return response()->json([
            'message' => 'Сообщение отправлено',
            'data' => $message->load('sender.profile')
        ], 201);
    }

Метод ->toOthers() означает: отправить всем в канале, КРОМЕ того, кто отправил запрос (его же сообщение мы уже вернули в JSON ответе, незачем дублировать).

Как тестировать? (Новая жизнь сервера)

Теперь твой локальный сервер должен работать в двух режимах: обычный HTTP для API и WebSocket для реал-тайма.

Запускай в терминале:

php artisan reverb:start

Ты увидишь красивый логотип Reverb, который говорит, что WebSocket-сервер крутится на порту 8080. Не закрывай это окно!

Открой второе окно терминала и запусти обычный API:

php artisan serve

Теперь отправь сообщение от Алекса в чат (как мы делали в прошлом шаге):

curl -X POST http://127.0.0.1:8000/api/chats/1/send -H "Authorization: Bearer ТУТ_ТОКЕН_АЛЕКСА" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"body\":\"Hello from WebSocket\"}"

В окне, где крутится php artisan reverb:start, ты увидишь, как проскочила строчка: кто-то подключился к приватному каналу, и наше событие NewMessageSent было транслировано!

Но это будет работать при условии если есть подключенный клиент на фронте, если клиента нет то ничего вы не увидите. Можно проверить службу заменив в .env BROADCAST_CONNECTION=reverb на BROADCAST_CONNECTION=log , но тут нужно учесть что этих строки в файле 2 и менять нужноо ту что последняя. Тогда для теста бродкасты будуть лететь в storage/logs/laravel.log

Бэкенд полностью готов к реал-тайму. Когда мы будем писать Next.js, мы просто подключимся туда, и сообщения будут прилетать сами!