Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
🌐
Клиент-сервер
Опубликовано:
19.10.2026
•
Обновлено:
19.10.2026

Связи в Laravel Eloquent: моделирование отношений, Eager Loading и контроль SQL-запросов

Данил Мануйлов

Работа с реляционными базами данных через Eloquent ORM позволяет быстро описывать бизнес-логику приложения, но без понимания внутренних механизмов генерации SQL легко столкнуться с деградацией производительности. Классический сценарий интернет-магазина — сущности «Заказ» (Order) и «Позиции заказа» (OrderItem) — наглядно демонстрирует, как правильно выстраивать связи, избегать лишних обращений к базе и контролировать количество запросов.


1. Моделирование связей: Order и OrderItem

Связь между заказом и его позициями относится к типу «один ко многим» (One-to-Many). Один заказ содержит несколько позиций, а каждая конкретная позиция принадлежит строго одному заказу.

Настройка hasMany и belongsTo по соглашениям Laravel

По умолчанию Eloquent определяет внешние ключи на основе названий моделей и таблиц. Для родительской модели Order дочерней будет OrderItem, а внешний ключ в таблице order_items ожидается как order_id.

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Order extends Model
{
    protected $fillable = ['status', 'total_amount'];

    public function items(): HasMany
    {
        return $this->hasMany(OrderItem::class);
    }
}

В дочерней модели OrderItem объявляется обратная связь через метод belongsTo:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class OrderItem extends Model
{
    protected $fillable = ['order_id', 'product_name', 'quantity', 'price'];

    public function order(): BelongsTo
    {
        return $this->belongsTo(Order::class);
    }
}

Переопределение внешних и локальных ключей

Если схема базы данных отличается от соглашений Laravel (например, используется составное имя поля custom_order_id или нестандартный первичный ключ), ключи передаются явно:

// В модели Order
public function items(): HasMany
{
    // hasMany(СвязаннаяМодель, внешний_ключ_в_дочерней_таблице, локальный_ключ_в_текущей)
    return $this->hasMany(OrderItem::class, 'custom_order_id', 'id');
}

// В модели OrderItem
public function order(): BelongsTo
{
    // belongsTo(СвязаннаяМодель, внешний_ключ_в_текущей_таблице, первичный_ключ_в_родительской)
    return $this->belongsTo(Order::class, 'custom_order_id', 'id');
}

2. Ленивая загрузка против Eager Loading: решение проблемы N+1

При проектировании списков заказов часто требуется отобразить позиции внутри каждого заказа. Способ обращения к связи напрямую влияет на количество обращений к СУБД.

Как возникает проблема N+1 (Lazy Loading)

При вызове динамического свойства $order->items Eloquent применяет ленивую загрузку (Lazy Loading): SQL-запрос выполняется в момент первого обращения к свойству.

$orders = Order::all(); // 1 запрос: SELECT * FROM orders

foreach ($orders as $order) {
    // На каждой итерации выполняется отдельный запрос:
    // SELECT * FROM order_items WHERE order_id = ?
    foreach ($order->items as $item) {
        echo $item->product_name;
    }
}

Если в выборке 50 заказов, база данных обработает:

  • 1 запрос на получение списка заказов;
  • 50 запросов на получение позиций для каждого заказа.

Итого — 51 запрос (проблема N + 1).

Жадная загрузка через with()

Метод with() активирует жадную загрузку (Eager Loading). Eloquent выполняет ровно два запроса вне зависимости от размера выборки:

$orders = Order::with('items')->get();

foreach ($orders as $order) {
    foreach ($order->items as $item) {
        echo $item->product_name;
    }
}

Фактически выполняются два SQL-запроса:

SELECT * FROM `orders`;
SELECT * FROM `order_items` WHERE `order_items`.`order_id` IN (1, 2, 3, ...);

После выполнения второго запроса Eloquent автоматически сопоставляет полученные записи OrderItem с соответствующими моделями Order в оперативной памяти.


3. Фильтрация связанных данных: whereHas и withWhereHas

Нередко требуется отобрать заказы по критериям, относящимся к их дочерним элементам (например, найти заказы, содержащие оптовые позиции с количеством от 5 штук).

Фильтрация родительских моделей через whereHas

Метод whereHas накладывает условие на выборку основных моделей, формируя подзапрос EXISTS в SQL:

use Illuminate\Database\Eloquent\Builder;

$orders = Order::whereHas('items', function (Builder $query) {
    $query->where('quantity', '>=', 5);
})->get();

Сгенерированный SQL выглядит следующим образом:

SELECT * FROM `orders` 
WHERE EXISTS (
    SELECT 1 FROM `order_items` 
    WHERE `orders`.`id` = `order_items`.`order_id` 
      AND `quantity` >= 5
);

Этот запрос возвращает только заказы с подходящими позициями, но не подгружает сами позиции в память. При обращении к $order->items снова сработает ленивая загрузка.

Совмещение фильтрации и жадной загрузки: withWhereHas

Если нужно одновременно отфильтровать заказы по условию позиций и сразу загрузить в коллекцию только подходящие позиции, используется метод withWhereHas:

$orders = Order::withWhereHas('items', function (Builder $query) {
    $query->where('quantity', '>=', 5);
})->get();

Этот вызов эквивалентен комбинации whereHas и with с идентичным замыканием: заказы без нужных позиций будут отсечены, а в свойство $order->items попадут только строки с quantity >= 5.


4. Корректная пагинация выборок со связями

При выводе больших объемов данных Eager Loading и условия на связи комбинируются с методами пагинации Eloquent Builder.

use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;

$orders = Order::with('items')
    ->whereHas('items', fn (Builder $query) => $query->where('price', '>', 1000))
    ->orderByDesc('created_at')
    ->paginate(15);

paginate() против simplePaginate()

  • paginate(15): Выполняет дополнительный запрос SELECT COUNT(*) FROM orders WHERE ... для вычисления общего числа страниц. Подходит для интерфейсов с классической нумерацией страниц (1, 2, 3... 10).
  • simplePaginate(15): Не выполняет подсчет COUNT(*). Запрос запрашивает 15 + 1 запись (16), чтобы определить, есть ли следующая страница. Это экономит ресурсы СУБД на высоконагруженных таблицах с миллионами строк, когда достаточно кнопок «Назад» / «Вперед» или бесконечного скролла.

5. Контроль и тестирование числа SQL-запросов

Для предотвращения скрытых проблем с производительностью количество SQL-запросов необходимо отслеживать локально и проверять в автоматических тестах.

Отладка в коде через Query Log

Для ручной проверки количества и текста запросов используется фасад DB:

use Illuminate\Support\Facades\DB;

DB::enableQueryLog();

$orders = Order::with('items')->take(10)->get();

$queries = DB::getQueryLog();
$queryCount = count($queries); // Ожидаемое значение: 2

Автоматический тест на количество SQL-запросов

Написание Feature-теста гарантирует, что будущие изменения в контроллерах или ресурсах не вернут проблему N+1.

namespace Tests\Feature;

use App\Models\Order;
use App\Models\OrderItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;

class OrderPerformanceTest extends TestCase
{
    use RefreshDatabase;

    public function test_orders_listing_does_not_produce_n_plus_one_queries(): void
    {
        // Подготовка: создаем 10 заказов, у каждого по 3 позиции
        Order::factory()
            ->count(10)
            ->has(OrderItem::factory()->count(3), 'items')
            ->create();

        DB::flushQueryLog();
        DB::enableQueryLog();

        // Выполнение тестируемого запроса
        $orders = Order::with('items')->get();

        // Имитация чтения связанных данных
        foreach ($orders as $order) {
            $order->items->each(fn ($item) => $item->product_name);
        }

        $queries = DB::getQueryLog();

        // Проверяем, что выполнено строго 2 запроса (заказы + позиции)
        $this->assertCount(2, $queries);
    }
}

6. Запрет ленивой загрузки на уровне приложения

Laravel предоставляет встроенный механизм предотвращения ленивой загрузки во время разработки и тестирования. В методе boot провайдера AppServiceProvider можно активировать строгий режим:

namespace App\Providers;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        // Выбрасывает исключение LazyLoadingViolationException при попытке ленивой загрузки
        Model::preventLazyLoading(! $this->app->isProduction());
    }
}

Если разработчик забудет указать with() и обратится к связи в цикле, приложение немедленно выбросит исключение в локальном окружении, не допуская неоптимальный код в production.


Часто задаваемые вопросы (FAQ)

В чём разница между order->items иorder->items()?

Вызов свойства $order->items возвращает коллекцию моделей Collection<OrderItem>, выполняя запрос или обращаясь к уже загруженным данным. Вызов метода $order->items() возвращает экземпляр построителя запросов HasMany, позволяя добавлять дополнительные условия (например, $order->items()->where('price', '>', 100)->get()).

Чем whereHas отличается от has()?

Метод has('items') проверяет только факт наличия хотя бы одной связанной записи (или заданного количества через оператор >=). Метод whereHas('items', $callback) позволяет задать детальные условия фильтрации для полей связанной таблицы внутри замыкания.

Зачем использовать withWhereHas, если есть with и whereHas?

Метод withWhereHas устраняет дублирование кода. Без него приходится передавать одно и то же замыкание с фильтрацией дважды: сначала в whereHas для отбора родительских моделей, затем в with для фильтрации подгружаемых дочерних записей.

Можно ли передавать в with вложенные связи?

Да. Для загрузки глубоких связей используется точечная нотация: Order::with('items.product')->get(). В этом случае Eloquent загрузит заказы, их позиции и связанные с каждой позицией товары за 3 SQL-запроса.

Как ограничить список выбираемых колонок при Eager Loading?

Колонки указываются через двоеточие: Order::with('items:id,order_id,product_name')->get(). При ограничении колонок в связанной таблице обязательно нужно указывать внешний ключ (order_id), иначе Eloquent не сможет сопоставить дочерние записи с родительскими моделями.

Что произойдет, если вызвать load() вместо with()?

Метод load() выполняет «ленивую жадную загрузку» (Lazy Eager Loading). Он применяется, когда экземпляр родительской модели уже получен из базы, и требуется подгрузить связи позже: $order->load('items').


Заключение

Грамотное проектирование отношений в Laravel Eloquent строится на соблюдении соглашений об именовании ключей, осознанном выборе между ленивой и жадной загрузкой и контроле формируемых SQL-запросов. Использование with(), whereHas() и withWhereHas() в сочетании с пагинацией минимизирует нагрузку на СУБД, а автоматические тесты на количество запросов и директива Model::preventLazyLoading() защищают проект от деградации производительности при масштабировании.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также