Жирные контроллеры Laravel: что ломается под нагрузкой
Контроллер Laravel может работать быстро на тестовых данных и посыпаться в проде: растут SQL-запросы, память и время ответа. Разбираем, что искать на ревью и какие изменения действительно снижают цену запроса.

Почему контроллер ломается раньше базы
Контроллер Laravel редко падает из-за одной длинной функции. Обычно проблема появляется на границе нескольких решений: запрос к базе загружает лишние строки, сериализатор трогает связи, цикл запускает дополнительные SQL-запросы, а всё это выполняется внутри одного HTTP-запроса. На маленьком наборе данных такой код выглядит исправным. Под нагрузкой первым растёт не «сложность контроллера», а время ответа, число запросов и объём памяти на PHP-воркер.
Ниже — разбор для Laravel 10 и 11 с Eloquent и типичным PHP-FPM-приложением. Цифры производительности нельзя переносить между проектами: перед изменениями зафиксируйте версию PHP, Laravel, СУБД, объём данных и профиль запросов.
Жирный контроллер опасен не количеством строк. Опасно то, что в нём невозможно заранее увидеть цену одного HTTP-запроса.
Что проверять на ревью до чтения бизнес-логики
Начинайте с пути запроса. Важно понять, сколько данных контроллер получает, сколько раз обращается к базе и где материализует результат в память.
- Границы выборки. Есть ли фильтры, сортировка и пагинация до вызова
get()или->get()? - Колонки. Нужны ли все поля модели, или запрос можно ограничить через
select? - Связи. Загружаются ли они явно через
with, или вызываются внутри цикла и ресурса? - Материализация. Не превращается ли большая выборка в коллекцию до того, как применены фильтры?
- Побочные действия. Не отправляет ли контроллер письма, HTTP-запросы или тяжёлые расчёты синхронно?
- Формат ответа. Не сериализуется ли вместе с моделью граф связей, который клиенту не нужен?
Затем откройте фактический план запросов и логи. Название метода вроде index ничего не говорит о цене операции: один такой метод может выполнить два запроса, а другой — несколько тысяч.
Первый кандидат на поломку: N+1
N+1 возникает, когда сначала выбирается список моделей, а затем для каждой модели отдельно читается связь. Например, контроллер получает заказы и в цикле обращается к клиенту или позициям заказа. На пяти строках это незаметно. На тысяче заказов приложение может отправить один запрос за списком и ещё по запросу на каждую модель.
На ревью ищите доступ к отношениям внутри foreach, в API Resource, в аксессорах и в шаблонах. Особенно опасны конструкции, где объект передаётся в несколько слоёв: запрос к связи может быть спрятан далеко от контроллера.
// Laravel 10 и 11: связь загружается до цикла
$orders = Order::query()
->with(['customer:id,name', 'items:id,order_id,price'])
->latest('id')
->paginate(50);
return OrderResource::collection($orders);
В этом примере не стоит автоматически считать число запросов идеальным. Нужно проверить, что выбранные поля содержат внешние ключи, необходимые Eloquent для сопоставления связей, и что ресурс не обращается к другим отношениям.
Для диагностики в тестах можно включить предотвращение ленивой загрузки, если это соответствует политике проекта. Тогда скрытый доступ к незагруженной связи проявится раньше, а не после роста трафика. Название и способ включения такой проверки зависят от версии Laravel и настроек приложения, поэтому закрепите их по официальной документации именно вашей версии.
Второй кандидат: запрос без границы
Самый дорогой вызов часто выглядит безобидно: Model::query()->get(). Он загружает все подходящие строки, создаёт объекты Eloquent и держит коллекцию в памяти до формирования ответа. При росте таблицы это увеличивает и время SQL, и расход памяти PHP.
Если клиенту нужна страница, используйте пагинацию. Если нужно пройти большой объём для фоновой обработки, выбирайте пакетную обработку, а не один огромный результат.
// Для экрана списка: ограничиваем страницу и поля
$users = User::query()
->select(['id', 'name', 'email'])
->where('is_active', true)
->orderBy('id')
->paginate(50);
return UserResource::collection($users);
paginate обычно выполняет отдельный запрос для подсчёта общего количества строк. Это удобно для интерфейса с номерами страниц, но может стать дорогим на сложном фильтре. Если интерфейсу не нужно знать общее число страниц, сравните обычную пагинацию с курсорным вариантом, доступным в Laravel, и измерьте оба сценария на вашей СУБД.
Третий кандидат: «быстрый» PHP-цикл после базы
Перенос фильтрации из SQL в PHP выглядит проще, но заставляет приложение передать и создать больше данных, чем нужно. Конструкции вроде get()->filter()->sortBy() особенно опасны на коллекциях, размер которых определяется пользователем или ростом таблицы.
Если условие можно выразить средствами запроса, применяйте его до получения результата. Это не универсальное правило: сложная бизнес-логика иногда действительно должна выполняться в PHP. Но тогда размер входных данных должен быть ограничен, а стоимость — измерена.
// Фильтрация выполняется в базе, а не после загрузки всей таблицы
$invoices = Invoice::query()
->where('status', 'overdue')
->where('due_at', '<', now())
->select(['id', 'account_id', 'amount', 'due_at'])
->paginate(100);
Проверяйте план выполнения: само наличие where не гарантирует быстрый доступ. Индекс должен соответствовать реальному фильтру, сортировке и распределению значений. Добавлять индексы вслепую нельзя — они занимают место и удорожают операции записи.
Что растёт под нагрузкой
| Проблема на ревью | Что происходит | Первый способ проверки | Цена исправления |
|---|---|---|---|
| Ленивая загрузка связи в цикле | Число SQL-запросов растёт вместе с числом объектов | Счётчик запросов и SQL-лог | Перенести загрузку связей в запрос, проверить поля и ресурс |
get() без ограничения | Растут память PHP, время сериализации и размер ответа | Размер выборки, память воркера, время ответа | Пагинация, курсорная обработка или фоновая задача |
| Фильтрация после запроса | База и PHP обрабатывают лишние строки | Сравнение SQL и PHP-профиля | Перенести условие в запрос, при необходимости добавить индекс |
| Внешний HTTP-вызов в контроллере | Ответ зависит от чужого сервиса и занимает воркер | Тайминги и ошибки внешнего клиента | Очередь, таймаут, повторная попытка и отдельный статус операции |
| Тяжёлый ресурс или аксессор | Сериализация запускает скрытые вычисления и запросы | Профилирование формирования JSON | Явный набор данных и разделение представления |
Где толстый контроллер становится архитектурной проблемой
Вынос кода из контроллера сам по себе не ускоряет приложение. Если те же запросы и циклы переехали в сервисный класс, цена HTTP-запроса не изменилась. Ревью должно оценивать не длину файла, а границы ответственности и возможность измерить каждую тяжёлую операцию.
Контроллер
Контроллер должен собрать входные данные, вызвать сценарий и вернуть HTTP-ответ. Валидация, авторизация и преобразование результата могут быть отдельными слоями, если это принято в проекте. Контроллеру не обязательно быть коротким любой ценой, но в нём не должно быть неограниченных выборок и скрытых побочных действий.
Сервис или action-класс
Сценарий заказа, оплаты или импорта удобно вынести в отдельный класс, когда его нужно тестировать независимо от HTTP и повторно запускать из очереди или команды. Это упрощает тестирование, но не отменяет контроля транзакций, идемпотентности и количества запросов.
Очередь
Письмо, экспорт, пересчёт отчёта и вызов внешнего API часто не должны удерживать HTTP-воркер. Перенос в очередь меняет контракт: пользователь получает подтверждение постановки задачи, а не результат всей операции. Нужно продумать повторный запуск, дедупликацию и отображение ошибки.
Граничные случаи, которые ломают исправленный код
- Пагинация без стабильной сортировки. Если порядок строк меняется между запросами, пользователь может увидеть пропуски или дубли. Сортировка должна быть явной, а для больших изменяющихся таблиц стоит отдельно проверить курсорную пагинацию.
- Слишком широкая eager-загрузка. Убрав N+1, легко загрузить несколько больших связей на каждую строку. Смотрите не только на число запросов, но и на количество возвращённых строк и размер ответа.
- Связь в ресурсе. Контроллер может выглядеть правильно, но ресурс вызовет отношение, которое не было загружено. Явно определите, какие поля и связи входят в публичный ответ.
- Индекс не совпадает с запросом. Индекс на одном поле может не помочь фильтру по нескольким полям и сортировке. Используйте
EXPLAINвашей СУБД и проверяйте план на данных, близких к рабочим. - Слишком большой payload. Даже быстрый SQL не спасёт endpoint, который отдаёт десятки мегабайт JSON. Ограничивайте поля, размер страницы и вложенность ответа.
Как провести ревью с измерениями
- Зафиксируйте сценарий: endpoint, параметры, размер страницы, пользовательский сценарий и ожидаемый объём результата.
- Снимите базовые показатели: время ответа, количество SQL-запросов, суммарное время SQL, пиковую память PHP и размер ответа.
- Проверьте запросы с реальным объёмом данных. Тестовая база из нескольких десятков строк не покажет проблему, которая начинается на сотнях тысяч.
- Изменяйте одну причину за раз: eager loading, границу выборки, набор колонок, индекс или перенос операции в очередь.
- Повторите тот же сценарий и сравните показатели. Если стало меньше SQL-запросов, но выросли память или размер ответа, исправление неполное.
Для наблюдения под нагрузкой нужны профилировщик или APM, журнал медленных запросов СУБД и метрики PHP-FPM. Выбирайте конкретные инструменты, совместимые с политикой проекта и версией Laravel; не подключайте отладочный сборщик на рабочей среде без оценки объёма данных и влияния на производительность.
Что делать дальше
Возьмите один самый медленный endpoint и составьте для него карту цены: число запросов, строки на запрос, объём памяти, размер JSON и доля внешних вызовов. Затем добавьте регрессионный тест на количество запросов для основного сценария и отдельную проверку ответа с пустым, средним и большим набором данных. После этого смотрите не на длину контроллера, а на метрики в проде: p95 времени ответа, ошибки 5xx, время SQL и загрузку PHP-воркеров.
Примечание: eager loading — предварительная загрузка связанных моделей одним или несколькими отдельными запросами до обращения к связи. Это противоположность ленивой загрузке, при которой связь читается в момент первого обращения к ней.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel Documentation: Eloquent Relationships, Laravel Documentation: Eloquent Resources
Частые вопросы
Сколько строк в контроллере считается слишком много?
Поможет ли вынос логики в сервис ускорить Laravel?
Что проверять первым, если endpoint начал отдавать 502?
Нужно ли всегда использовать eager loading?
Что делать, если в контроллере есть внешний API-вызов?
Как понять, что пагинация действительно помогла?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.