Связи моделей в Laravel: частые ошибки и как их чинить
Проблема N+1, забытые внешние ключи в hasMany, дубли в belongsToMany и синхронный delete вместо каскада — разбираем ошибки в связях Eloquent, которые дороже всего обходятся в проде.

Одна страница списка заказов вместо трёх запросов делает 1 200 — и это не выдуманный кошмар, а типичный результат забытого with() при выводе заказов с клиентами. Связи Eloquent выглядят обманчиво просто: описал метод, вызвал как свойство, получил объект. Проблемы начинаются, когда таблица разрастается, а связь ленивая. Разберём конкретные ошибки в связях моделей Laravel (актуально для 10.x и 11.x), их цену и то, чем чинить.
Ошибка 1. Ленивая загрузка и N+1
Самая дорогая ошибка. Она не ломает код — код работает, тесты зелёные, а под нагрузкой сервис начинает упираться в базу.
// Модель Order с методом связи
public function customer()
{
return $this->belongsTo(Customer::class);
}
// В контроллере — грабли
$orders = Order::all();
foreach ($orders as $order) {
// Каждое обращение к $order->customer — отдельный SELECT
echo $order->customer->name;
}
На 500 заказах это 1 запрос на список плюс 500 на клиентов. Решение — жадная загрузка через with:
$orders = Order::with('customer')->get();
// Теперь два запроса: заказы и один SELECT ... WHERE id IN (...)
Чтобы N+1 не проползал незамеченным, включите строгий режим в AppServiceProvider. С Laravel 9.x доступен Model::preventLazyLoading() — в разработке он бросает исключение при попытке ленивой подгрузки:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
// Ловим ленивую загрузку только вне продакшена
Model::preventLazyLoading(! $this->app->isProduction());
}
Точное имя метода и нюансы поведения между версиями сверяйте в разделе документации Eloquent «Preventing Lazy Loading».
N+1 не падает в тестах и не пишет в лог ошибок. Он просто медленно съедает базу, пока кто-то не откроет отчёт APM и не увидит тысячу одинаковых SELECT на один HTTP-запрос.
Ошибка 2. Неверный внешний ключ в hasMany / belongsTo
Eloquent угадывает имя внешнего ключа по имени модели: для belongsTo(Customer::class) он ждёт колонку customer_id. Если в базе колонка называется иначе — например client_id — связь молча вернёт пустоту или null, без ошибки.
// Колонка в orders называется client_id, а не customer_id
public function customer()
{
// Второй аргумент — внешний ключ, третий — ключ на родителе
return $this->belongsTo(Customer::class, 'client_id', 'id');
}
// Для hasMany порядок аргументов другой:
// hasMany(Model, внешний_ключ_на_дочерней, локальный_ключ)
public function orders()
{
return $this->hasMany(Order::class, 'client_id', 'id');
}
Порядок аргументов у hasMany и belongsTo разный — это и есть источник путаницы. У belongsTo второй аргумент — колонка на текущей (дочерней) модели. У hasMany второй аргумент — колонка на связанной (дочерней) модели. Перепутали местами — получите пустую коллекцию без единого предупреждения.
Ошибка 3. Дубли и лишние запросы в belongsToMany
Связь многие-ко-многим через промежуточную таблицу — отдельный источник неприятностей. Три типичных промаха:
- Забытые поля pivot. По умолчанию доступны только ключи связи. Если в промежуточной таблице есть
quantityилиcreated_at, их нужно объявить черезwithPivot('quantity')иwithTimestamps(), иначе они не подгрузятся и не запишутся. - attach в цикле. Вызов
$order->products()->attach($id)внутриforeachделает по одному INSERT на итерацию. Массовыйattachпринимает массив:$order->products()->attach([1, 2, 3])— один запрос. - sync там, где нужен attach.
syncудаляет все связи, которых нет в переданном массиве. Если вы хотели добавить одну связь и вызвалиsync([5]), вы стёрли все остальные. Для добавления без удаления естьsyncWithoutDetaching.
// Правильно: pivot-поля объявлены в определении связи
public function products()
{
return $this->belongsToMany(Product::class)
->withPivot('quantity')
->withTimestamps();
}
// Обновить количество на существующей связи
$order->products()->updateExistingPivot($productId, [
'quantity' => 3,
]);
Ошибка 4. Удаление родителя без чистки потомков
Вызвали $customer->delete(), а заказы остались висеть с customer_id, указывающим в никуда. Eloquent сам по себе не удаляет связанные записи. Есть два разных механизма, и их часто путают.
Каскад на уровне базы
Настраивается в миграции внешнего ключа. Работает всегда, даже при удалении напрямую SQL, и не грузит записи в память:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('customer_id')
->constrained()
->cascadeOnDelete(); // при удалении клиента заказы уйдут
$table->timestamps();
});
Удаление на уровне модели
Нужно, если у потомков есть свои события, наблюдатели или мягкое удаление, которые должны отработать. Тогда чистят в событии модели или явно в коде. Каскад базы события Eloquent не запускает — это важное различие: если на удаление заказа навешан Observer, каскад базы его молча обойдёт.
Когда что выбирать
| Ситуация | Механизм удаления | Почему |
|---|---|---|
| У потомков нет событий и observer'ов | Каскад базы (cascadeOnDelete) | Быстро, надёжно, не грузит в память |
| У потомков есть SoftDeletes или Observer | Удаление через модель | Только так отработают события Eloquent |
| Нужно сохранить историю | SoftDeletes у потомков + чистка в коде | Каскад базы физически сотрёт записи |
Ошибка 5. Проверка существования связи через count
Чтобы получить клиентов, у которых есть хотя бы один заказ, новички фильтруют в PHP:
// Плохо: грузит всех клиентов и их заказы в память
$customers = Customer::with('orders')->get()
->filter(fn ($c) => $c->orders->count() > 0);
Для этого есть has и whereHas — они уходят в базу подзапросом:
// Клиенты хотя бы с одним заказом
$customers = Customer::has('orders')->get();
// Клиенты с заказами дороже 1000
$customers = Customer::whereHas('orders', function ($query) {
$query->where('total', '>', 1000);
})->get();
// Только посчитать, не грузя сами заказы
$customers = Customer::withCount('orders')->get();
// доступно как $customer->orders_count
Что ломается на практике
- Полиморфные связи и morphMap. Если в
*_typeхранится полное имя класса, то переименование или перенос модели в другой неймспейс ломает все старые записи. ЗадайтеRelation::enforceMorphMap([...])и храните короткие алиасы — но помните, что менять карту после накопления данных больно. - with внутри accessor. Ленивая подгрузка внутри вычисляемого атрибута возвращает N+1 обратно, даже если в контроллере вы всё загрузили жадно.
- Циклические жадные загрузки.
with('orders.customer.orders')тянет данные, которые у вас уже есть, и раздувает память.
Инвалидация кеша связей и очередь событий при массовом удалении — отдельные темы, здесь их не разворачиваем.
Что делать дальше
Включите preventLazyLoading в дев-окружении прямо сейчас — это выловит большинство N+1 до релиза. Поставьте laravel/telescope или пакет отладочной панели и смотрите на счётчик запросов на каждой странице списка: если он растёт линейно с числом строк, у вас ленивая загрузка. В проде подключите APM (например, тот, что уже есть у вас для метрик) и настройте алерт на число SQL-запросов в рамках одного HTTP-запроса — это самый честный индикатор проблем со связями.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Documentation — Eloquent: Relationships, Laravel Documentation — Eloquent (Preventing Lazy Loading)
Частые вопросы
У меня Laravel 8, preventLazyLoading работает?
Почему связь возвращает null, хотя данные в базе есть?
Каскадное удаление в миграции не сработало, заказы остались. Почему?
sync стёр все мои связи вместо добавления одной. Как откатить?
whereHas работает медленно на большой таблице. Что делать?
Как понять, что на странице есть N+1, если всё работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.