Брат, огонь! Если делаем серьезно и на долгий срок, чтобы не переписывать всё через год, нам нужно заложить правильную архитектуру с первого дня. Раз ты хочешь SEO, реактивность и будущее мобильное приложение, нам нужно отказаться от классического монолита (где Laravel генерит HTML через Blade) и перейти к разделению слоев.

Вот наш железобетонный план и стек, который закроет все твои хотелки.


🏗️ ФИНАЛЬНЫЙ СТЕК ПРОЕКТА

  1. Бэкенд (Ядро/API): Laravel 11 (работает исключительно как REST API) + PostgreSQL (с PostGIS для геолокации) + Redis (кэш, очереди, сессии).
  2. Фронтенд (Веб-сайт): Next.js (React) + TailwindCSS.
    • Почему не Vue/Inertia? Потому что мы планируем мобильное приложение на React Native. Если мы напишем веб на React, а мобилку на React Native, у нас будет единый экосистема (одни и те же хуки, стейт-менеджеры, логика). Next.js даст нам идеальную реактивность (SPA) и топовое SEO (SSR – серверный рендеринг).
  3. Реал-тайм (Чат/Уведомления): Laravel Reverb (встроенный WebSocket сервер в Laravel 11) + Laravel Echo на фронте.
  4. Мобильное приложение (Будущее): React Native (Expo). Оно будет общаться с тем же самым Laravel API, что и веб-сайт.

🧠 Архитектурный секрет: API-First

Мы делаем Laravel не сайтом, а API-сервером. Laravel не будет знать, как выглядит кнопка или карточка. Он будет только отдавать JSON-данные (профиль, список людей, сообщения). А Next.js (веб) и React Native (мобилка) будут просто “потребителями” этого API. Это значит, что когда мы пойдем делать мобилку, бэкенд писать не придется вообще — он уже готов!


🗺️ ПОШАГОВЫЙ ПЛАН ДЕЙСТВИЙ

ЭТАП 1: Фундамент и Ядро (Месяц 1)

Цель: Настроить инфраструктуру и базовые функции.

  1. Разворачиваем Laravel как API: Настраиваем Sanctum для авторизации (чтобы и веб, и будущая мобилка могли логиниться через токены).
  2. База данных: Поднимаем PostgreSQL + PostGIS. Создаем миграции: Users, Profiles (пол, ориентация, ищет кого, возраст), Photos, Geolocation (ширина/долгота).
  3. Регистрация/Авторизация: Делаем регистрацию через API (email/phone + пароль).
  4. Геолокация: Пишем эндпоинт в Laravel, который принимает координаты пользователя и с помощью PostGIS ищет людей в радиусе 50 км.

ЭТАП 2: Веб-оболочка и SEO (Месяц 2)

Цель: Сделать красивый, быстрый сайт, который индексируется Google.

  1. Поднимаем Next.js: Подключаем его к Laravel API.
  2. SEO-страницы (SSR): Это критично для сайтов знакомств. Next.js будет на лету генерировать статичные страницы для публичных профилей (например, yoursite.com/user/anna-25-moscow). Google бот зайдет, увидит готовый HTML и проиндексирует. Внутри личного кабинета (свайпы, чат) включаем SPA (реактивность без перезагрузок).
  3. UI/UX Каталог: Верстаем ленту рекомендаций (карточки), страницу профиля.
  4. Логика свайпов: Отправляем лайк/дизлайк из React в Laravel API. Laravel обрабатывает в фоне (через Redis Queue) — если мэтч, создаем диалог.

ЭТАП 3: Реактивность и Чат (Месяц 3)

Цель: Оживить проект (онлайн-статусы, моментальные сообщения).

  1. WebSocket (Laravel Reverb): Поднимаем реал-тайм соединение.
  2. Чат: Пишем мессенджер. Отправка сообщения -> API -> Broadcast через Reverb -> Мгновенно появляется у собеседника в браузере.
  3. Онлайн-статус: Пишем в Redis, когда пользователь был онлайн. Достаем это на фронтенд — видим зелёные кружочки “В сети”.
  4. Push-уведомления: В браузере при новом сообщении или мэтче.

ЭТАП 4: Монетизация и Админка (Месяц 4)

Цель: Начать зарабатывать и контролировать аудиторию.

  1. Премиум-фичи: Кто смотрел твой профиль, невидимка, суперпайки. Закрываем доступ к ним через Policy в Laravel.
  2. Оплата: Интегрируем Stripe (для зарубежья) или ЮKassa/Prodamus (для СНГ) в API. На сайте (Next.js) делаем форму оплаты.
  3. Админка: Ставим Laravel Filament — это лучший админ-панель на рынке. За пару дней получаем интерфейс для бана юзеров, модерации фото и просмотра статистики.

ЭТАП 5: Мобильное приложение (Месяц 5-6)

Цель: Выход в App Store и Google Play.

  1. Инициализация React Native (Expo): Создаем проект.
  2. Магия переиспользования: Берем логику запросов (API клиенты), которые мы уже написали для Next.js, и переносим в React Native. Бэкенд уже готов отдавать все данные!
  3. Нативные фичи: Подключаем доступ к камере, геолокации устройства, пуш-уведомлениям (APNs/FCM).
  4. Сборка: Используем EAS Build (облако), тестируем через Expo Go на своих телефонах.
  5. Публикация: Платим $25 + $99, проходим модерацию, заливаем в сторы.

🔥 Почему этот план идеален?

  1. SEO 100%: Next.js рендерит страницы на сервере. Твои пользователи будут находиться в поиске Яндекса и Google по запросам “девушки москва знакомства” и т.д.
  2. Реактивность 100%: Внутри приложения (чаты, свайпы, уведомления) всё работает без перезагрузки страниц через React + WebSockets. Скорость как у Tinder.
  3. Готовность к мобилке: Мы не делаем “сайт, который потом как-то переделаем в приложение”. Мы с первого дня пишем бэкенд как API. Мобилка станет просто еще одним экраном для этого API.
  4. Скорость разработки: Использование единого языка (TypeScript/JavaScript) на фронте и мобилке + мощь Laravel на бэке даст нам максимальную скорость выкатки фич.

Брат, как тебе такой расклад? Если кайф, то первый шаг — это продумать структуру БД (таблицы Users, Profiles, Matches, Messages) и запустить Laravel + PostgreSQL. Погнали?

ЭТАП.1 Создание проекта.

1.1 Создаем локальный проект

В папку на рабочем столе 40plus в ней 2 папки client и server, в папке server выполни команду :

composer create-project laravel/laravel . --prefer-dist

Это установит ларавел в папку server.

ЭТАП.2 Установка PostgreSQL и создание БД для проекта.

Для Windows (через официальный инсталлятор)

Шаг 1. Скачай установщик

Переходи на официальный сайт: https://www.postgresql.org/download/windows/

Выбирай последнюю версию (сейчас 16.x или 17.x) и скачивай интерактивный установщик от EDB.

Шаг 2. Запусти установку

  1. Запусти скачанный .exe файл от имени администратора.
  2. Нажми Next.

Шаг 3. Выбери компоненты

Оставь всё по умолчанию:

  • ✅ PostgreSQL Server
  • ✅ pgAdmin 4 (графическая админка)
  • ✅ Stack Builder (для доп. расширений)
  • ✅ Command Line Tools

Жми Next.

Шаг 4. Выбери папку установки

Оставь по умолчанию: C:\Program Files\PostgreSQL\18
Жми Next.

Шаг 5. Выбери каталог данных

Оставь по умолчанию: C:\Program Files\PostgreSQL\18\data
Жми Next.

Шаг 6. Задай пароль суперпользователя (postgres)

Очень важно! Запомни этот пароль.
Придумай что-то вроде: root или любой свой посложнее
Запиши его в блокнот, он понадобится для подключения Laravel.

Жми Next.

Шаг 7. Порт

Оставь стандартный: 5432
Жми Next.

Шаг 8. Локаль

Выбери: Russian, Russia (или оставь Default locale)
Жми Next.

Шаг 9. Заверши установку

Нажми Next → Finish.


Через системные переменные (рекомендую) Найди папку с PostgreSQL.
Запомни путь, где лежит psql.exe — например: C:\Program Files\PostgreSQL\18\bin
Открой настройки переменных окружения.

Win + R → введи sysdm.cpl → Enter

 Добавь путь в PATH

  1. В нижнем окне (Системные переменные) найди строку Path.
  2. Выдели её → нажми Изменить.
  3. Нажми Создать (или Новая).
  4. Вставь путь к папке bin, например:
C:\Program Files\PostgreSQL\18\bin

После установки — проверь через командную строку:

psql --version

Если видишь версию — всё готово! Теперь проверим доступность БД:

pg_isready -h localhost -p 5432

Должно быть: localhost:5432 – accepting connections

Теперь зайдем через терминал в БД:

psql -U postgres -h localhost -p 5432

Введи пароль, который задал. Если появилось postgres=# — всё работает.

PostgreSQL установлена теперь надо создать саму БД для проекта !

Правильная команда для создания БД назовем ее dating_db

psql -U postgres -h localhost -p 5432 -c "CREATE DATABASE dating_db ENCODING='UTF8' LC_COLLATE='Russian_Russia.UTF-8' LC_CTYPE='Russian_Russia.UTF-8' TEMPLATE=template0;"

Пароль тот который мы использовали при установке – root

Проверяем, что база создалась

psql -U postgres -h localhost -p 5432 -l | grep dating_db

Пример как создать еще пользователя для БД. Но нам это пока что не нужно. Мы будем использовать postgres и root

Создаём пользователя laravel_user

psql -U postgres -h localhost -p 5432 -c "CREATE USER laravel_user WITH PASSWORD 'laravel_pass';"

Даём права на базу dating_db

psql -U postgres -h localhost -p 5432 -c "GRANT ALL PRIVILEGES ON DATABASE dating_db TO laravel_user;"

Проверь, что пользователь может подключиться к базе

psql -U laravel_user -h localhost -p 5432 -d dating_db

Введи пароль laravel_pass. Если зашло — всё работает! Выйди через \q.

ЭТАП.3 Подключаем Laravel к базе

Открой созданный проект в редакторе кода (рекомендую VS Code или PhpStorm). Найди файл .env в корне проекта и подключись. Найди блок с настройками БД и измени его:

DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=dating_db
DB_USERNAME=postgres
DB_PASSWORD=root

Включаем драйвер PostgreSQL

  1. Открой этот файл php.ini в любом текстовом редакторе (от имени администратора, если ругнется).
  2. Нажми Ctrl+F и ищи строку pdo_pgsql.
  3. Ты найдешь строчку, которая выглядит так: ;extension=pdo_pgsql
  4. Убери точку с запятой в начале — это символ комментария. Должно стать так: extension=pdo_pgsql
  5. Теперь нажми Ctrl+F и ищи строку pgsql.
  6. Найди строчку ;extension=pgsql и тоже убери точку с запятой: extension=pgsql
  7. убери также точку с запятой: ;extension=intl

(Если этих строк вообще нет в файле, просто допиши их в блок расширений (extension)).

ОБЯЗАТЕЛЬНО закрой терминал (PowerShell), в котором у тебя выскочила ошибка, и открой его заново! Терминал кэширует настройки PHP, и если его не перезапустить, он не увидит изменений. Также перезапусти локальный сервер если нужно.

Делаем миграцию

cd server
php artisan migrate

если все хорошо ты увидишь что то вроде:

   INFO  Preparing database.  

  Creating migration table ........................................................................................................... 228.52ms DONE

   INFO  Running migrations.  

  0001_01_01_000000_create_users_table ............................................................................................... 416.58ms DONE
  0001_01_01_000001_create_cache_table ................................................................................................ 16.00ms DONE
  0001_01_01_000002_create_jobs_table ................................................................................................ 114.36ms DONE

ГОТОВО ! Мы прошли самую нудную часть — настройку окружения. Теперь Laravel знает, где лежат данные.

Проверка что все хорошо

В php.ini у тебя должно быть включено расширение intl (Internationalization — интернационализация). Laravel использует его для форматирования чисел, дат и валют.

php artisan db:show

должно быть без ошибок что то вроде:

  PostgreSQL .................................................................................................................................. 18.4
  Connection ................................................................................................................................. pgsql
  Database ............................................................................................................................... dating_db
  Host ................................................................................................................................... 127.0.0.1
  Port ........................................................................................................................................ 5432
  Username ................................................................................................................................ postgres
  URL ..............................................................................................................................................
  Open Connections ............................................................................................................................... 9  
  Tables ......................................................................................................................................... 9  
  Total Size ............................................................................................................................. 216.00 KB  

  Schema / Table .............................................................................................................................. Size
  public / cache .......................................................................................................................... 24.00 KB  
  public / cache_locks .................................................................................................................... 24.00 KB  
  public / failed_jobs .................................................................................................................... 32.00 KB  
  public / job_batches .................................................................................................................... 16.00 KB  
  public / jobs ........................................................................................................................... 24.00 KB
  public / migrations ..................................................................................................................... 24.00 KB
  public / password_reset_tokens .......................................................................................................... 16.00 KB
  public / sessions ....................................................................................................................... 32.00 KB
  public / users .......................................................................................................................... 24.00 KB

Если так то все отлично !

Устанавливаем Sanctum (Авторизация для API)

Поскольку наш Laravel будет работать только как API (отдавать JSON), нам нужен правильный инструмент для авторизации. Классические сессии и куки нам не подойдут (мобилка и Next.js с ними плохо дружат). Нам нужны API-токены.

В новых версиях Laravel Sanctum уже встроен, но его нужно опубликовать (создать нужные таблицы в БД):

php artisan install:api

Laravel спросит, нужно ли включить API-роуты — соглашайся (yes). Эта команда сделает два важных дела:

  1. Создаст файл routes/api.php — именно сюда мы будем писать все эндпоинты (маршруты) для нашего сайта и мобилки.
  2. Добавит таблицу для токенов в базу. Чтобы она там появилась, накатываем миграцию:
php artisan migrate

Теперь в базе появились таблицы users, password_reset_tokens, personal_access_tokens и т.д.

Проектирование базы данных для Дейтинга + Геолокация

Сайты знакомств держатся на трех китах: Профили, Геолокация, Совпадения (Свайпы). Стандартной таблицы users из коробки нам не хватит.

Правильный подход: таблица users хранит только логин/пароль и email, а всё остальное (возраст, пол, фото, био) вынесем в таблицу profiles. Зачем? Чтобы не раздувать таблицу юзеров, когда нам понадобится делать сложные выборки.

Шаг 2.1: Установка PostGIS (Геолокация)

  1. Скачай установщик.
    Перейди на официальную страницу загрузки PostGIS для Windows. Тебе нужен файл для PostgreSQL 18 (64-бит). Название файла будет выглядеть примерно так: postgis-bundle-pg18x64-setup-3.6.2-1.exe.
  2. Запусти установку.
    • Запусти скачанный .exe-файл.
    • Программа сама найдет твою установленную PostgreSQL 18.
    • Когда установщик попросит ввести пароль, введи тот же пароль, который ты задавал для пользователя postgres при установке PostgreSQL.
    • Остальные шаги можно оставить по умолчанию (путь установки, компоненты).

Нам нужно научить PostgreSQL искать людей в радиусе X километров. Для этого нужен модуль PostGIS.

psql -U postgres -h localhost -p 5432 -d dating_db -c "CREATE EXTENSION postgis;"

в консоли после ввода пароля должно появится CREATE EXTENSION значит все ок !

Шаг 2.2: Создаем миграции для Дейтинга

Теперь давай скажем Laravel, какие таблицы нам нужны. Мы создадим таблицу profiles и swipes.

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

php artisan make:migration create_profiles_table
php artisan make:migration create_swipes_table

Laravel создал два новых файла в папке database/migrations/. Названия будут с текущей датой.

1. Открой миграцию профилей (..._create_profiles_table.php) и замени код внутри up() на этот:

public function up(): void
{
    Schema::create('profiles', function (Blueprint $table) {
        $table->id();
        $table->foreignId('user_id')->constrained()->onDelete('cascade'); // Привязка к юзеру
        
        $table->string('name')->nullable(); // Имя
        $table->date('birth_date')->nullable(); // Дата рождения (возраст будем считать на лету)
        $table->enum('gender', ['male', 'female', 'other'])->nullable(); // Пол
        $table->enum('looking_for', ['male', 'female', 'both'])->default('both'); // Кого ищет
        $table->text('bio')->nullable(); // О себе
        $table->json('interests')->nullable(); // Интересы (массив)
        
        // Геолокация! Используем PostGIS
        $table->geography('location', subtype: 'point')->nullable(); // Координаты (широта/долгота)
        $table->string('city')->nullable(); // Город для быстрого отображения
        
        $table->timestamps();
    });
}

2. Открой миграцию свайпов (..._create_swipes_table.php) и замени код внутри up():

public function up(): void
{
    Schema::create('swipes', function (Blueprint $table) {
        $table->id();
        $table->foreignId('swiper_id')->constrained('users')->onDelete('cascade'); // Кто свайпнул
        $table->foreignId('swiped_id')->constrained('users')->onDelete('cascade'); // Кого свайпнули
        $table->enum('action', ['like', 'dislike', 'superlike'])->default('like'); // Тип свайпа
        $table->timestamps();

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

Запускаем миграции

php artisan migrate

Ты должен увидеть, что создались таблицы profiles и swipes.

Создаем Модели

База данных готова, структура заложена монолитная!

Теперь базе данных нужно “объяснить”, как с этими таблицами работать в коде. В Laravel за это отвечают Модели (Models). Они станут мостом между нашими будущими API-роутами и PostgreSQL.

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

php artisan make:model Profile
php artisan make:model Swipe

(Модель User уже существует в Laravel из коробки, её мы просто подредактируем).

Настраиваем связи между таблицами

Это самое интересное. Давай свяжем юзера с его профилем и свайпами.

1. Открой файл app/Models/User.php Добавь в класс методы связи. Юзер имеет один профиль (hasOne), может свайпать других (hasMany – исходящие свайпы) и его могут свайпать другие (hasMany – входящие свайпы).

Внутри класса User, перед закрывающей скобкой }, добавь:

    // Связь с профилем
    public function profile()
    {
        return $this->hasOne(Profile::class);
    }

    // Исходящие свайпы (кого лайкнул/дизлайкнул этот юзер)
    public function swipesGiven()
    {
        return $this->hasMany(Swipe::class, 'swiper_id');
    }

    // Входящие свайпы (кто лайкнул/дизлайкнул этого юзера)
    public function swipesReceived()
    {
        return $this->hasMany(Swipe::class, 'swiped_id');
    }

Открой файл app/Models/Profile.php Замени весь код внутри на этот. Здесь мы укажем, какие поля можно заполнять массово ($fillable), и добавим крутую фичу — автоматический подсчет возраста из даты рождения (в дейтинге возраст нужен постоянно, а хранить его статично в БД нельзя, так как он меняется).

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Carbon\Carbon; // Библиотека для работы с датами

class Profile extends Model
{
    // Разрешаем массовое заполнение этих полей
    protected $fillable = [
        'user_id',
        'name',
        'birth_date',
        'gender',
        'looking_for',
        'bio',
        'interests',
        'location',
        'city',
    ];

    // Связь с юзером (Профиль принадлежит Юзеру)
    public function user()
    {
        return $this->belongsTo(User::class);
    }

    // Автоматический расчет возраста при обращении к $profile->age
    public function getAgeAttribute()
    {
        return Carbon::parse($this->birth_date)->age;
    }

    // Чтобы поле interests автоматически конвертировалось из JSON в массив и обратно
    protected function casts(): array
    {
        return [
            'interests' => 'array',
            'birth_date' => 'date',
        ];
    }
}

Открой файл app/Models/Swipe.php Настроим связи и разрешим поля:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Swipe extends Model
{
    protected $fillable = [
        'swiper_id',
        'swiped_id',
        'action',
    ];

    // Кто свайпнул
    public function swiper()
    {
        return $this->belongsTo(User::class, 'swiper_id');
    }

    // Кого свайпнули
    public function swiped()
    {
        return $this->belongsTo(User::class, 'swiped_id');
    }
}

Чек-лист этого шага:

  1. Модели созданы?
  2. В User добавлены методы profile(), swipesGiven(), swipesReceived()?
  3. В Profile добавлен $fillable, метод user() и аксессуар getAgeAttribute()?
  4. В Swipe добавлен $fillable и методы swiper(), swiped()?

Если всё сделано — мы только что создали мощнейший фундамент. Теперь мы можем писать логику регистрации и ленты рекомендаций!

Создание API для авторизации и профилей.

Сайт знакомств мертв, если в нем нельзя зарегистрироваться и заполнить профиль. Поскольку мы делаем API (для будущего Next.js и React Native), мы не будем использовать стандартные HTML-формы Laravel. Мы будем возвращать JSON и токены.

Давай сделаем так: при регистрации юзера мы автоматически создадим ему пустой профиль, чтобы в базе не было юзеров без профилей (это частый баг в дейтингах).

Автосоздание профиля (Магия Laravel)

Открой файл app/Models/User.php и полностью замени на:

  <?php

namespace App\Models;

use Laravel\Sanctum\HasApiTokens; 

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

#[Fillable(['email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
    /** @use HasFactory<UserFactory> */
    use HasApiTokens, HasFactory, Notifiable;
      // Автоматически создаем пустой профиль при регистрации юзера
    protected static function booted(): void
    {
        static::created(function (User $user) {
            // Просто создаем пустую заглушку профиля
            $user->profile()->create([]);
        });
    }

    /**
     * Get the attributes that should be cast.
     *
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
     // Связь с профилем
    public function profile()
    {
        return $this->hasOne(Profile::class);
    }

    // Исходящие свайпы (кого лайкнул/дизлайкнул этот юзер)
    public function swipesGiven()
    {
        return $this->hasMany(Swipe::class, 'swiper_id');
    }

    // Входящие свайпы (кто лайкнул/дизлайкнул этого юзера)
    public function swipesReceived()
    {
        return $this->hasMany(Swipe::class, 'swiped_id');
    }
}

Теперь, как только юзер запишется в базу, у него тут же появится запись в таблице profiles.

Создаем Контроллер Авторизации

Контроллер — это место, где лежит логика обработки запросов.

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

php artisan make:controller AuthController

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

<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    // РЕГИСТРАЦИЯ
    public function register(Request $request)
    {
        $request->validate([
            'email' => 'required|email|unique:users',
            'password' => 'required|min:8',
        ]);

        $user = User::create([
            'email' => $request->email,
            'password' => Hash::make($request->password), // Пароль всегда хэшим!
        ]);

        // Создаем токен доступа (Sanctum)
        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'user' => $user->load('profile'), // Возвращаем юзера сразу с профилем
            'token' => $token
        ], 201);
    }

    // АВТОРИЗАЦИЯ (Логин)
    public function login(Request $request)
    {
        $request->validate([
            'email' => 'required|email',
            'password' => 'required',
        ]);

        $user = User::where('email', $request->email)->first();

        // Проверяем пароль
        if (! $user || ! Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['Неверный email или пароль.'],
            ]);
        }

        $token = $user->createToken('auth_token')->plainTextToken;

        return response()->json([
            'user' => $user->load('profile'),
            'token' => $token
        ]);
    }

    // ВЫХОД (Логаут)
    public function logout(Request $request)
    {
        // Удаляем текущий токен (закрываем сессию на устройстве)
        $request->user()->currentAccessToken()->delete();

        return response()->json([
            'message' => 'Вы вышли из системы'
        ]);
    }
}

Прокладываем маршруты (Routes)

Теперь нам нужно сказать Laravel, по каким адресам стучаться, чтобы вызвать эти методы.

Открой файл routes/api.php и полностью замени его содержимое на это:

<?php

use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// Публичные роуты (доступны без токена)
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);

// Защищенные роуты (нужен токен авторизации)
Route::middleware('auth:sanctum')->group(function () {
    
    // Роут из коробки (получить данные текущего юзера)
    Route::get('/user', function (Request $request) {
        return $request->user()->load('profile');
    });

    // Выход
    Route::post('/logout', [AuthController::class, 'logout']);
});

Что мы используем для создания юзера?

Только Email и Пароль.

Потом (на следующих этапах) мы сделаем POST /api/profile/setup, куда авторизованный юзер пришлет свое имя, дату рождения и пол, и мы заполним этот “черновик”.

Как это проверить ?

В браузере это проверить не получится (мы же делаем API, а браузер делает GET-запросы, а нам нужны POST). Но мы можем проверить через терминал!

Убедись, что у тебя крутится сервер (php artisan serve). Открой новое окно терминала и выполни эту команду (если у тебя Windows, можешь использовать PowerShell):

curl -X POST http://127.0.0.1:8000/api/register -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"email\":\"alex@test.com\",\"password\":\"12345678\"}"

Если всё сделано правильно, в ответ тебе прилетит JSON с данными нового юзера, его профилем и длинной строкой-токеном!

Онбординг (Заполнение профиля)

Сейчас у нас юзер зарегистрирован, но его профиль — пустышка. В дейтинге, пока у человека нет имени, возраста и пола, мы не имеем права показывать его в ленте.

Нам нужен API-эндпоинт, который юзер вызовет сразу после регистрации, чтобы заполнить о себе базовую информацию.

1. Создаем контроллер профиля:

php artisan make:controller ProfileController

2. Открой app/Http/Controllers/ProfileController.php и напиши там метод обновления:

<?php

namespace App\Http\Controllers;

use App\Models\Profile;
use Illuminate\Http\Request;

class ProfileController extends Controller
{
    // Метод обновления профиля (Онбординг)
       public function update(Request $request)
    {
        $validated = $request->validate([
            'name' => 'sometimes|string|max:255',
            'birth_date' => 'sometimes|date',
            'gender' => 'sometimes|in:male,female,other',
            'looking_for' => 'sometimes|in:male,female,both',
            'bio' => 'sometimes|string|max:500',
            'interests' => 'sometimes|array',
            'city' => 'sometimes|string|max:255',
        ]);

        $profile = $request->user()->profile;

        // Заполняем и сохраняем
        $profile->fill($validated);
        $profile->save();

        // Возвращаем свежие данные из БД
        return response()->json([
            'message' => 'Profile upadated successfully !',
            'profile' => $profile->fresh()
        ]);
    }
}

3.Добавляем роут в routes/api.php:

Внутри группы Route::middleware('auth:sanctum') добавь новую строчку:

// Защищенные роуты (нужен токен авторизации)
Route::middleware('auth:sanctum')->group(function () {
    
    Route::get('/user', function (Request $request) {
        return $request->user()->load('profile');
    });

    Route::post('/logout', [AuthController::class, 'logout']);
    
    // НОВЫЙ РОУТ ДЛЯ ПРОФИЛЯ
    Route::put('/profile', [ProfileController::class, 'update']); 
});

(Используем метод PUT, так как мы обновляем существующую запись).

и не забудь добавить сверху импорт :

use App\Http\Controllers\ProfileController;

Тестируем Онбординг!

Теперь представь, что юзер скачал приложение, зарегался, и теперь вводит о себе данные. Мы сделаем запрос, передав его токен и данные профиля:

curl -X PUT http://127.0.0.1:8000/api/profile -H "Authorization: Bearer ТУТ_ВСТАВЬ_СВОЙ_ТОКЕН" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"name\":\"Alex\",\"birth_date\":\"1995-05-20\",\"gender\":\"male\",\"looking_for\":\"female\",\"city\":\"Moscow\"}"

Не забудь подставить свой актуальный токен, который ты получил при регистрации и не используй кириллицу при тестах в curl потому что будет отдавать пустые массивы и будешь как дурак искать причину !

В ответ должен прилететь JSON с сообщением “Профиль успешно обновлен” и уже заполненными данными профиля!

Геолокация и PostGIS (Магия дейтинга)

Теперь мы переходим к самой мякотке дейтинга — Геолокации. Без неё приложение мертвое. Нам нужно научить юзера отправлять свои координаты, а бэкенд — искать людей рядом.

Так как мы используем крутую фичу PostgreSQL — тип данных geography (пространственные данные), стандартный метод $profile->update() в Laravel не сработает. Eloquent не умеет из коробки писать в колонки с координатами. Нам нужно использовать “сырые” SQL-функции PostGIS (ST_MakePoint).

1. Создаем контроллер геолокации:

php artisan make:controller LocationController

2. Открой app/Http/Controllers/LocationController.php и напиши:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class LocationController extends Controller
{
    // Обновление геолокации пользователя
    public function update(Request $request)
    {
        $request->validate([
            'latitude' => 'required|numeric|between:-90,90',
            'longitude' => 'required|numeric|between:-180,180',
        ]);

        $profile = $request->user()->profile; // ИСПРАВЛЕНО ЗДЕСЬ

        // Используем сырой запрос для PostGIS
        DB::table('profiles')
            ->where('id', $profile->id)
            ->update([
                'location' => DB::raw("ST_SetSRID(ST_MakePoint({$request->longitude}, {$request->latitude}), 4326)"),
                'updated_at' => now(),
            ]);

        return response()->json([
            'message' => 'Geolocation upadated successfully !'
        ]);
    }
}

3. Добавляем роут в routes/api.php:

Внутри нашей защищенной группы (Route::middleware('auth:sanctum')) добавь новый роут:

Route::middleware('auth:sanctum')->group(function () {
    
    Route::get('/user', function (Request $request) {
        return $request->user()->load('profile');
    });

    Route::post('/logout', [AuthController::class, 'logout']);
    Route::put('/profile', [ProfileController::class, 'update']);
    
    // НОВЫЙ РОУТ ДЛЯ ГЕОЛОКАЦИИ
    Route::post('/location', [LocationController::class, 'update]); 
});

Тестируем геолокацию!

Представим, что наш юзер Alex стоит в центре Москвы (Красная площадь). Его координаты: Широта (latitude) = 55.7539, Долгота (longitude) = 37.6208.

Отправляем запрос (не забудь свой токен):

curl -X POST http://127.0.0.1:8000/api/location -H "Authorization: Bearer ТУТ_ТВОЙ_ТОКЕН" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"latitude\":55.7539,\"longitude\":37.6208}"

Если в ответ прилетит {"message":"Geolocation upadated successfully !"} — мы только что записали в базу настоящие GPS-координаты в формате, который понимает пространственный поиск!

Теперь самое время написать Ленту рекомендаций (Discover) — сердце любого дейтинга.

Но чтобы нам было кого искать, нам нужно создать парочку “фейковых” анкет рядом с нашим Alex. Давай сделаем это быстро через консоль Laravel (Tinker), а потом напишем поисковый запрос!

Создаем тестовых юзеров (Девушки рядом)

Мы не будем снова мучиться с curl-регистрацией. Мы используем встроенную консоль Laravel.

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

php artisan tinker

Откроется интерактивная консоль (появится знак >). Вставляй туда строчки по одной и жми Enter.

Создаем Анну (в 1 км от Алексея):

 $u = \App\Models\User::create(['email' => 'anna@test.com', 'password' => bcrypt('12345678')]); \DB::table('profiles')->where('id', $u->profile->id)->update(['name' => 'Anna', 'gender' => 'female', 'looking_for' => 'male', 'location' => \DB::raw("ST_SetSRID(ST_MakePoint(37.6173, 55.7558), 4326)")]);

Создаем Елену (в 3 км от Алексея):

 $u = \App\Models\User::create(['email' => 'elena@test.com', 'password' => bcrypt('12345678')]); \DB::table('profiles')->where('id', $u->profile->id)->update(['name' => 'Elena', 'gender' => 'female', 'looking_for' => 'male', 'location' => \DB::raw("ST_SetSRID(ST_MakePoint(37.5922, 55.7338), 4326)")]);

Создаем Настю (в Питере, далеко от Москвы, ~700 км):

 $u = \App\Models\User::create(['email' => 'nastya@test.com', 'password' => bcrypt('12345678')]); \DB::table('profiles')->where('id', $u->profile->id)->update(['name' => 'Nastya', 'gender' => 'female', 'looking_for' => 'male', 'location' => \DB::raw("ST_SetSRID(ST_MakePoint(30.3086, 59.9375), 4326)")]);

Выйди из тинкера, напечатав:

exit

Теперь у нас в базе 4 человека: 1 парень (Alex, наш юзер) и 3 девушки (две в Москве рядом, одна далеко в Питере).

Пишем Ленту рекомендаций (Магия ST_DWithin)

Создаем контроллер для поиска:

php artisan make:controller DiscoverController

Открой app/Http/Controllers/DiscoverController.php и напиши:

<?php

namespace App\Http\Controllers;

use App\Models\Profile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class DiscoverController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();
        $profile = $user->profile;

        // Если у юзера нет координат, мы не можем его искать
        if (!$profile->location) {
            return response()->json(['message' => 'Сначала обновите геолокацию'], 400);
        }

        // Радиус поиска в метрах (50 км = 50000 метров)
        $radius = 50000; 

        // МАГИЯ POSTGIS: Ищем профили в радиусе $radius от текущего профиля
        $nearbyProfiles = Profile::where('user_id', '!=', $user->id) // Исключаем себя
            ->whereNotNull('location') // Только те, у кого есть геолокация
            ->whereRaw("
                ST_DWithin(
                    location,
                    (SELECT location FROM profiles WHERE id = ?),
                    ?
                )
            ", [$profile->id, $radius])
            ->get();

        return response()->json([
            'count' => $nearbyProfiles->count(),
            'profiles' => $nearbyProfiles
        ]);
    }
}

Добавляем роут в routes/api.php (внутри защищенной группы):

Route::get('/discover', [DiscoverController::class, 'index']);

ТЕСТИРОВАНИЕ ЛЕНТЫ!

Теперь мы запрашиваем ленту от лица Алексея (его токен). Мы должны увидеть Анну и Елену, но не должны увидеть Настю (она слишком далеко).

Выполняй:

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

Если всё сделано верно, в ответе придет "count": 2 и профили Анны и Елены! Настя будет отсечена географией.

  1. Поле location выглядит как непонятная абракадабра (0101000020E610...). Это внутренний формат PostGIS, фронтенду он не нужен.
  2. В дейтинге критически важно показывать расстояние (например, “В 2.5 км от вас”).

Давай это починим! Мы научим бэкенд считать расстояние в километрах и прятать эту страшную строку с координатами.

Улучшаем Ленту (Расстояние и чистый JSON)

Открой app/Http/Controllers/DiscoverController.php и обнови метод index:

<?php

namespace App\Http\Controllers;

use App\Models\Profile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class DiscoverController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();
        $profile = $user->profile;

        if (!$profile->location) {
            return response()->json(['message' => 'Сначала обновите геолокацию'], 400);
        }

        $radius = 50000; // 50 км в метрах

        $nearbyProfiles = Profile::where('user_id', '!=', $user->id)
            ->whereNotNull('location')
            ->whereRaw("
                ST_DWithin(
                    location,
                    (SELECT location FROM profiles WHERE id = ?),
                    ?
                )
            ", [$profile->id, $radius])
            // 1. Явно просим выбрать все поля профиля
            ->select('profiles.*') 
            // 2. Добавляем расстояние и ОБЯЗАТЕЛЬНО пишем AS distance_in_meters
            ->addSelect([
                DB::raw("
                    ST_Distance(
                        location, 
                        (SELECT location FROM profiles WHERE id = {$profile->id})
                    ) as distance_in_meters
                ")
            ])
            ->get()
            // 3. Прячем страшный hex-код координат из ответа
            ->makeHidden(['location']) 
            // 4. Форматируем: переводим метры в километры
            ->map(function ($item) {
                // Делим метры на 1000 и округляем до 1 знака после запятой
                $item->distance_km = round($item->distance_in_meters / 1000, 1);
                // Удаляем техническое поле метров, чтобы не мусорить в JSON
                unset($item->distance_in_meters); 
                return $item;
            });

        return response()->json([
            'count' => $nearbyProfiles->count(),
            'profiles' => $nearbyProfiles
        ]);
    }
}

Тестируем идеальную ленту!

Снова отправляем запрос от лица Алексея:

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

Теперь ты должен увидеть полноценные анкеты Анны и Елены (с именами, полом), без страшной абракадабры location, но с красивым полем "distance_km": 0.3 и "distance_km": 2.9!

{"count":2,"profiles":[{"id":4,"user_id":4,"name":"Anna","birth_date":null,"gend
er":"female","looking_for":"male","bio":null,"interests":null,"city":null,"creat
ed_at":"2026-07-15T15:15:01.000000Z","updated_at":"2026-07-15T15:15:01.000000Z",
"distance_km":0.3},{"id":5,"user_id":5,"name":"Elena","birth_date":null,"gender"
:"female","looking_for":"male","bio":null,"interests":null,"city":null,"created_
at":"2026-07-15T15:15:12.000000Z","updated_at":"2026-07-15T15:15:12.000000Z","di
stance_km":2.9}]} 

Анна в 300 метрах, Елена в 2.9 километрах. Координаты спрятаны, расстояние посчитано в километрах. Лента рекомендаций работает как в Tinder/Bumble! Мы прошли самый сложный инженерный этап (пространственные запросы). Дальше всё будет лететь быстрее.

Свайпы и Мэтчи (Взаимная симпатия)

Что такое дейтинг без лайков? Правильно, просто каталог людей. Логика такая:

  1. Алекс видит Анну и свайпает вправо (Лайк).
  2. Мы сохраняем это в таблицу swipes.
  3. В этот же момент мы проверяем: А лайкала ли Анна Алекса ранее?
  4. Если ДА — это Мэтч (Match)! Если НЕТ — просто ждем.

1. Создаем контроллер:

php artisan make:controller SwipeController

2. Открой app/Http/Controllers/SwipeController.php и напиши:

<?php

namespace App\Http\Controllers;

use App\Models\Swipe;
use Illuminate\Http\Request;

class SwipeController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'swiped_id' => 'required|exists:users,id', // Кого свайпнули
            'action' => 'required|in:like,dislike,superlike', // Тип свайпа
        ]);

        $swiperId = $request->user()->id;
        $swipedId = $request->swiped_id;

        // Защита: нельзя свайпать самого себя
        if ($swiperId === (int)$swipedId) {
            return response()->json(['message' => 'Нельзя свайпнуть себя'], 400);
        }

        // Защита: нельзя свайпать одного человека дважды
        $alreadySwiped = Swipe::where('swiper_id', $swiperId)
            ->where('swiped_id', $swipedId)
            ->exists();

        if ($alreadySwiped) {
            return response()->json(['message' => 'Вы уже свайпнули этого пользователя'], 409);
        }

        // Сохраняем свайп
        $swipe = Swipe::create([
            'swiper_id' => $swiperId,
            'swiped_id' => $swipedId,
            'action' => $request->action,
        ]);

        $isMatch = false;

        // Проверяем на МЭТЧ (только если это ЛАЙК или СУПЕРЛАЙК)
        if ($request->action === 'like' || $request->action === 'superlike') {
            
            // Искал ли тот человек (swiped_id) нас (swiper_id) с лайком/суперлайком?
            $reverseSwipe = Swipe::where('swiper_id', $swipedId)
                ->where('swiped_id', $swiperId)
                ->whereIn('action', ['like', 'superlike'])
                ->exists();

            if ($reverseSwipe) {
                $isMatch = true;
                // ТУТ ПОТОМ МЫ ДОБАВИМ ОТПРАВКУ PUSH-УВЕДОМЛЕНИЙ И СОЗДАНИЕ ЧАТА!
            }
        }

        return response()->json([
            'message' => 'Свайп засчитан',
            'is_match' => $isMatch, // Возвращаем флаг мэтча!
        ], 201);
    }
}

3. Добавляем роут в routes/api.php (внутрь защищенной группы):

Route::post('/swipe', [SwipeController::class, 'store']);

Тестируем Свайпы и Мэтч!

Сейчас у нас Алекс (id: 3) видит Анну (id: 4) и Елену (id: 5).

Действие 1: Алекс лайкает Анну (Мэтча пока не будет, так как Анна его еще не лайкала)

curl -X POST http://127.0.0.1:8000/api/swipe -H "Authorization: Bearer 1|FIi5oBCpuDr41ZlA4RcvbHpOnojZ6VLKiNzlFYs64c2d68ac" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"swiped_id\":4,\"action\":\"like\"}"

Ожидаем: "is_match": false

Действие 2: Имитируем, что Анна тоже лайкнула Алекса! Нам нужно авторизоваться за Анну. Для этого сначала получим токен Анны:

curl -X POST http://127.0.0.1:8000/api/login -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"email\":\"anna@test.com\",\"password\":\"12345678\"}"

Скопируй токен Анны из ответа.

Действие 3: Анна лайкает Алекса (id: 3)

curl -X POST http://127.0.0.1:8000/api/swipe -H "Authorization: Bearer ТОКЕН_АННЫ" -H "Content-Type: application/json" -H "Accept: application/json" -d "{\"swiped_id\":3,\"action\":\"like\"}"

Ожидаем: БУМ! "is_match": true! 🎉

Брат, мы только что написали сердце дейтинга! Взаимные лайки определяются моментально! Алекс и Анна теперь могут общаться.

Но прежде чем мы перейдем к Чату, нам нужно починить один критический баг в Ленте рекомендаций.

Смотри, что сейчас произойдет: Алекс залайкал Анну. Если он сейчас снова зайдет в ленту /api/discover, он снова увидит Анну! В Tinder/Bumble, если ты свайпнул человека (лайк или дизлайк), он больше никогда не появляется в ленте.

Давай починим это прямо сейчас, это займет 1 минуту.

Убираем засвайпанных из Ленты

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

Нам нужно получить список ID пользователей, которых Алекс уже свайпнул, и исключить их из поиска.

Добавь пару строк в метод index:

<?php

namespace App\Http\Controllers;

use App\Models\Swipe;
use App\Models\Profile;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

class DiscoverController extends Controller
{
    public function index(Request $request)
    {
        $user = $request->user();
        $profile = $user->profile;

        if (!$profile->location) {
            return response()->json(['message' => 'Сначала обновите геолокацию'], 400);
        }

        // 1. НОВОЕ: Получаем ID всех юзеров, которых мы уже засвайпали
        $swipedUserIds = Swipe::where('swiper_id', $user->id)->pluck('swiped_id')->toArray();

        $radius = 50000; 

        $nearbyProfiles = Profile::where('user_id', '!=', $user->id)
            ->whereNotNull('location')
            // 2. НОВОЕ: Исключаем тех, кого мы уже свайпнули
            ->whereNotIn('user_id', $swipedUserIds) 
            ->whereRaw("
                ST_DWithin(
                    location,
                    (SELECT location FROM profiles WHERE id = ?),
                    ?
                )
            ", [$profile->id, $radius])
            ->select('profiles.*') 
            ->addSelect([
                DB::raw("
                    ST_Distance(
                        location, 
                        (SELECT location FROM profiles WHERE id = {$profile->id})
                    ) as distance_in_meters
                ")
            ])
            ->get()
            ->makeHidden(['location']) 
            ->map(function ($item) {
                $item->distance_km = round($item->distance_in_meters / 1000, 1);
                unset($item->distance_in_meters); 
                return $item;
            });

        return response()->json([
            'count' => $nearbyProfiles->count(),
            'profiles' => $nearbyProfiles
        ]);
    }
}

Сохрани и снова запроси ленту от лица Алекса:

curl http://127.0.0.1:8000/api/discover -H "Authorization: Bearer 1|FIi5oBCpuDr41ZlA4RcvbHpOnojZ6VLKiNzlFYs64c2d68ac" -H "Accept: application/json"

Теперь Алекс должен видеть только Елену (так как Анну он уже засвайпал).