Работа с реляционными базами данных через 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() защищают проект от деградации производительности при масштабировании.



.svg.webp)





