Laravel Eloquent: связи между моделями (hasOne, hasMany, belongsTo, ManyToMany) и проблема N+1

Eloquent ORM — это сердце Laravel. С его помощью работа с базой данных выглядит как работа с обычными PHP-объектами. Но без понимания механизма связей и проблемы N+1 легко получить код, который делает сотни лишних запросов к БД и кладёт сервер под нагрузкой. В этой статье разберём все типы связей, жадную загрузку и типичные ошибки.

Как работают связи в Eloquent

Связи в Eloquent определяются методами внутри классов-моделей. Каждый метод возвращает объект типа Relation, который описывает, как модели соединены между собой. Laravel сам формирует SQL-запросы, вам не нужно писать JOIN вручную.

Базовая структура модели с несколькими связями:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    protected $fillable = ['name', 'email'];

    // Один пользователь — один профиль
    public function profile()
    {
        return $this->hasOne(Profile::class);
    }

    // Один пользователь — много статей
    public function posts()
    {
        return $this->hasMany(Post::class);
    }

    // Пользователь принадлежит одной компании
    public function company()
    {
        return $this->belongsTo(Company::class);
    }

    // Пользователь — много ролей (через сводную таблицу)
    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }
}

hasOne — один-к-одному

hasOne используется, когда у одной записи есть ровно одна связанная запись в другой таблице. Классический пример — пользователь и его профиль.

// Миграция для таблицы profiles
// В ней должен быть user_id (foreign key)
Schema::create('profiles', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->onDelete('cascade');
    $table->string('bio')->nullable();
    $table->string('avatar')->nullable();
    $table->timestamps();
});

// Модель Profile — обратная связь
class Profile extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

// Использование
$user = User::find(1);
echo $user->profile->bio;       // Обращаемся как к свойству

// Создание связанной записи
$user->profile()->create([
    'bio' => 'PHP-разработчик',
    'avatar' => 'avatar.jpg',
]);

Если имена ключей нестандартные — можно указать их явно: hasOne(Profile::class, 'foreign_key', 'local_key').

hasMany — один-ко-многим

Самая распространённая связь. Один пользователь пишет много статей, одна категория содержит много товаров.

// Модель Post
class Post extends Model
{
    protected $fillable = ['title', 'content', 'user_id', 'published'];

    // Обратная связь: статья принадлежит пользователю
    public function author()
    {
        return $this->belongsTo(User::class, 'user_id');
    }

    // Статья имеет много комментариев
    public function comments()
    {
        return $this->hasMany(Comment::class);
    }
}

// Примеры работы с hasMany
$user = User::find(1);

// Получить все статьи
$posts = $user->posts;

// Получить только опубликованные статьи (с условием на связь)
$published = $user->posts()->where('published', true)->get();

// Получить последние 5 статей
$recent = $user->posts()->orderByDesc('created_at')->limit(5)->get();

// Добавить статью через связь (user_id заполняется автоматически)
$user->posts()->create([
    'title'   => 'Новая статья',
    'content' => 'Текст...',
]);

belongsToMany — многие-ко-многим

Когда одна сущность может принадлежать многим другим и наоборот. Пример: статья — теги, пользователь — роли. Требует промежуточную (сводную) таблицу.

// Миграция сводной таблицы (по алфавиту: post_tag, а не tag_post)
Schema::create('post_tag', function (Blueprint $table) {
    $table->foreignId('post_id')->constrained()->onDelete('cascade');
    $table->foreignId('tag_id')->constrained()->onDelete('cascade');
    $table->primary(['post_id', 'tag_id']);
});

// Модели
class Post extends Model
{
    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }
}

class Tag extends Model
{
    public function posts()
    {
        return $this->belongsToMany(Post::class);
    }
}

// Работа с ManyToMany
$post = Post::find(1);

// Прикрепить теги
$post->tags()->attach([1, 2, 3]);

// Открепить теги
$post->tags()->detach([2]);

// Синхронизировать (удаляет лишние, добавляет нужные)
$post->tags()->sync([1, 3, 5]);

// Получить статьи с тегом "PHP"
$phpTag = Tag::where('slug', 'php')->first();
$posts = $phpTag->posts;

// Доп. данные в сводной таблице (если есть поле sort_order)
// В миграции добавьте: $table->integer('sort_order')->default(0);
// В модели: return $this->belongsToMany(Tag::class)->withPivot('sort_order');
// Доступ: $post->tags->first()->pivot->sort_order;

Проблема N+1 и жадная загрузка (Eager Loading)

Это самая частая ошибка при работе с Eloquent. Без жадной загрузки каждый проход цикла делает отдельный SQL-запрос.

// ПЛОХО: проблема N+1
// 1 запрос — получить 100 статей
// + 100 запросов — получить автора каждой статьи
$posts = Post::all();
foreach ($posts as $post) {
    echo $post->author->name; // ← каждый раз новый SELECT
}

// ХОРОШО: жадная загрузка через with()
// 2 запроса — статьи + все авторы сразу
$posts = Post::with('author')->get();
foreach ($posts as $post) {
    echo $post->author->name; // ← из памяти, без запроса
}

// Загрузка нескольких связей
$posts = Post::with(['author', 'tags', 'comments'])->get();

// Вложенная жадная загрузка
// Статьи + комментарии + авторы комментариев
$posts = Post::with('comments.author')->get();

// Жадная загрузка с условием
$posts = Post::with(['comments' => function ($query) {
    $query->where('approved', true)
          ->orderByDesc('created_at');
}])->get();

// Если модель уже загружена — используем load()
$post = Post::find(1);
$post->load(['author', 'tags']);

Чтобы обнаружить N+1 в development-режиме, включите логирование запросов:

// В AppServiceProvider::boot() или прямо в маршруте/контроллере
use Illuminate\Support\Facades\DB;

DB::listen(function ($query) {
    logger($query->sql, ['bindings' => $query->bindings]);
});

// Или проще — пакет barryvdh/laravel-debugbar
// composer require barryvdh/laravel-debugbar --dev

hasOneThrough и hasManyThrough — связи через посредника

Когда нужно получить данные через промежуточную модель. Например: у страны много пользователей, у пользователей много статей — нужно получить все статьи страны.

// Структура таблиц:
// countries -> users (country_id) -> posts (user_id)

class Country extends Model
{
    // Все статьи пользователей страны
    public function posts()
    {
        return $this->hasManyThrough(
            Post::class,    // конечная модель
            User::class,    // промежуточная модель
            'country_id',   // FK в таблице users
            'user_id',      // FK в таблице posts
            'id',           // PK в таблице countries
            'id'            // PK в таблице users
        );
    }
}

// Использование
$russia = Country::where('code', 'RU')->first();
$allPosts = $russia->posts; // все статьи российских пользователей

Полиморфные связи

Когда одна модель может принадлежать нескольким разным моделям. Пример: комментарии к статьям и комментарии к видео — одна таблица comments.

// Миграция таблицы comments
Schema::create('comments', function (Blueprint $table) {
    $table->id();
    $table->text('body');
    $table->morphs('commentable'); // создаст commentable_id и commentable_type
    $table->timestamps();
});

// Модель Comment
class Comment extends Model
{
    public function commentable()
    {
        return $this->morphTo();
    }
}

// Модели Post и Video
class Post extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

class Video extends Model
{
    public function comments()
    {
        return $this->morphMany(Comment::class, 'commentable');
    }
}

// Использование
$post = Post::find(1);
$post->comments()->create(['body' => 'Отличная статья!']);

$video = Video::find(5);
$video->comments()->create(['body' => 'Спасибо за видео!']);

// В БД: commentable_type = "App\Models\Post", commentable_id = 1
// В БД: commentable_type = "App\Models\Video", commentable_id = 5

Подсчёт связанных записей без загрузки

Не нужно загружать все комментарии, чтобы посчитать их количество — используйте withCount.

// Получить статьи с количеством комментариев
$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count; // добавляется автоматически
}

// Несколько счётчиков
$posts = Post::withCount(['comments', 'tags', 'likes'])->get();

// Счётчик с условием (только одобренные комментарии)
$posts = Post::withCount(['comments as approved_comments_count' => function ($q) {
    $q->where('approved', true);
}])->get();

// Суммировать числовое поле связанной таблицы
$posts = Post::withSum('votes', 'value')->get();
// $post->votes_sum_value

Итог и чеклист

  • hasOne — у модели есть одна связанная запись (пользователь → профиль)
  • hasMany — у модели много связанных записей (пользователь → статьи)
  • belongsTo — обратная сторона hasOne / hasMany (статья → пользователь)
  • belongsToMany — много-ко-многим через сводную таблицу (статья → теги)
  • hasManyThrough — связь через промежуточную модель (страна → статьи через пользователей)
  • morphMany — полиморфная связь (комментарии к разным типам контента)

Всегда используйте with() при выводе списков, где обращаетесь к связанным данным — это исключит проблему N+1. Если не уверены, сколько запросов делает страница — включите debug bar или логируйте SQL в режиме разработки.