Жизненный цикл запроса в Laravel: что ревьюить и как рефакторить
Логика в контроллере, запросы к базе в цикле шаблона, middleware, который лезет в БД на каждый запрос — где в жизненном цикле Laravel рождаются проблемы и какие замечания писать на ревью.

Laravel скрывает от разработчика половину того, что происходит между приходом HTTP-запроса и отдачей ответа: контейнер, провайдеры, middleware-пайплайн, роутинг, разрешение зависимостей контроллера. Пока приложение маленькое, это не мешает. Но когда на ревью прилетает pull request с валидацией внутри контроллера, запросом в базу из middleware и Eloquent-циклом в Blade, полезно понимать, на каком этапе жизненного цикла это выстрелит и почему. Статья про то, что смотреть в PR по этапам обработки запроса и как формулировать замечания. Версия — Laravel 11.x.
Этапы жизненного цикла и где в них прячутся проблемы
Запрос в Laravel проходит фиксированную последовательность. На ревью полезно держать её в голове, потому что каждый этап — это отдельный класс мест, где код ломается или тормозит.
- public/index.php — точка входа, создаёт экземпляр приложения из
bootstrap/app.php. Здесь редко что-то ревьюят, но именно тут регистрируются глобальный обработчик исключений и группы middleware (в Laravel 11 конфигурация переехала изapp/Http/Kernel.phpвbootstrap/app.php). - Загрузка сервис-провайдеров — методы
register()иboot(). Ошибка ревью №1: тяжёлая работа вregister(). Там нельзя обращаться к другим сервисам, потому что не факт, что они уже зарегистрированы. - Пайплайн middleware — запрос проходит через global, а затем group и route middleware. Здесь чаще всего рождается лишняя нагрузка на БД.
- Роутинг и разрешение контроллера — контейнер собирает зависимости через рефлексию. Route model binding подгружает модели ещё до входа в метод.
- Метод контроллера — здесь должна быть оркестрация, а не бизнес-логика и не прямые запросы к базе.
- Формирование ответа — рендер Blade или сериализация ресурса. Классическое место для N+1.
- Terminable middleware — код в
terminate(), который выполняется после отправки ответа клиенту.
Middleware: запрос к базе на каждый чих
Самое частое замечание на ревью в этом слое — обращение к базе внутри middleware, которое навешано глобально или на большую группу. Пример: middleware проверяет настройки пользователя и на каждый запрос делает отдельный SELECT.
// Плохо: SELECT к settings на каждый запрос, включая статику через web-группу
class CheckMaintenance
{
public function handle(Request $request, Closure $next)
{
$settings = AppSetting::where('key', 'maintenance')->first();
if ($settings && $settings->value === '1') {
abort(503);
}
return $next($request);
}
}Замечание, которое стоит написать: «Этот запрос выполняется на каждый HTTP-запрос, включая ассеты и health-check. Настройки меняются редко — вынеси в кеш с TTL или используй Cache::remember(). Иначе под нагрузкой в 300 rps получишь 300 лишних SELECT в секунду по одной строке».
use Illuminate\Support\Facades\Cache;
$isMaintenance = Cache::remember('maintenance_flag', now()->addMinutes(5), function () {
return AppSetting::where('key', 'maintenance')->value('value') === '1';
});
if ($isMaintenance) {
abort(503);
}Middleware выполняется до того, как вы вообще узнали, что делает запрос. Всё, что тяжелее проверки заголовка, здесь нужно защищать кешем или переносить ближе к месту, где оно реально нужно.
Порядок middleware имеет значение
Второе, что стоит проверить — порядок. Если middleware аутентификации стоит после middleware, который логирует пользователя, лог получит null. В Laravel 11 приоритет задаётся через метод priority в bootstrap/app.php; если вы полагаетесь на порядок, но не зафиксировали приоритет, поведение может измениться при добавлении нового middleware.
Контроллер: оркестрация вместо логики
Толстый контроллер — вечная тема ревью, но в контексте жизненного цикла у неё есть конкретный аргумент. Метод контроллера — это последняя точка перед формированием ответа, и когда в нём одновременно валидация, бизнес-правила и запросы, всё это невозможно переиспользовать и тяжело тестировать без прогона всего пайплайна.
Что выносить и куда:
- Валидация — в Form Request. Он резолвится контейнером до входа в метод, и если проверка не прошла, метод контроллера вообще не вызовется.
- Бизнес-логика — в сервис или action-класс.
- Сборка данных для БД — в репозиторий или прямо в модель, но не размазывать по контроллеру.
// Было: всё в контроллере
public function store(Request $request)
{
$request->validate([
'email' => 'required|email|unique:users',
'name' => 'required|string|max:255',
]);
$user = User::create($request->only('email', 'name'));
Mail::to($user)->send(new WelcomeMail($user));
return redirect()->route('users.show', $user);
}
// Стало: валидация в Form Request, побочка в action
public function store(StoreUserRequest $request, RegisterUser $registerUser)
{
$user = $registerUser->handle($request->validated());
return redirect()->route('users.show', $user);
}Формулировка замечания: «StoreUserRequest резолвится до метода, так что валидные данные приходят готовыми через validated(). Отправку письма вынеси в action — сейчас её нельзя переиспользовать из консольной команды или очереди».
Route model binding и скрытые запросы
Когда в сигнатуре метода стоит User $user, Laravel делает запрос к базе до входа в метод. Это удобно, но на ревью стоит проверить: не грузите ли вы модель, которая тут же перезапрашивается с нужными связями. Если внутри метода снова User::with('orders')->find($user->id) — это два запроса вместо одного, и биндинг тут только мешает.
Формирование ответа: где живёт N+1
N+1 — проблема этапа рендера, и на ревью её видно по паре «цикл + обращение к связи». Классика:
// Контроллер отдаёт коллекцию без eager loading
$posts = Post::all();
// Blade
@foreach ($posts as $post)
{{ $post->author->name }} {{-- отдельный SELECT на каждый пост --}}
@endforeachЗамечание: «Здесь N+1: на 50 постов будет 51 запрос. Добавь Post::with('author')->get() в контроллере». Чтобы такие вещи не проходили ревью глазами, включите на дев-окружении защиту от ленивой загрузки — в AppServiceProvider::boot():
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}Тогда любая незагруженная связь в цикле бросит исключение прямо в разработке, и ревьюеру не придётся ловить N+1 вручную. Метод preventLazyLoading доступен начиная с Laravel 8.43; в 11.x работает штатно.
Таблица: что ревьюить на каждом этапе
| Этап | Что искать | Формулировка замечания |
|---|---|---|
| Сервис-провайдер | Работа в register(), обращение к другим сервисам | Перенеси в boot() — в register контейнер ещё не собран |
| Middleware | Запросы к БД, тяжёлые проверки, порядок | Оберни в кеш или перенеси ближе к использованию |
| Контроллер | Валидация, бизнес-логика, прямые SELECT | Валидацию — в Form Request, логику — в action |
| Model binding | Повторный запрос модели с другими связями | Загрузи связи через load() или убери биндинг |
| Ответ | Обращение к связям в цикле | Добавь eager loading, включи preventLazyLoading |
| terminate() | Тяжёлая работа, блокирующая соединение | Перенеси в очередь |
Terminable middleware: место для тяжёлых хвостов
Метод terminate() выполняется после того, как ответ отправлен клиенту, но соединение по-прежнему занято, пока PHP-процесс не завершит работу. На ревью встречается заблуждение, будто в terminate() можно безопасно делать долгую работу — «клиент же уже получил ответ». Клиент получил, но воркер FPM занят, и под нагрузкой пул воркеров кончится. Логирование строки — нормально. Отправка письма или обращение к внешнему API — в очередь.
Когда рефакторить, а когда оставить
Не всякое обращение к базе в middleware — грех. Критерий простой:
- Данные меняются редко и одинаковы для всех запросов (флаги, настройки) — кешируйте.
- Данные привязаны к пользователю и всё равно понадобятся дальше (текущий пользователь при аутентификации) — оставьте, это оправданный запрос.
- Middleware навешан глобально, но нужен только на части маршрутов — перенесите в группу или на конкретные роуты, чтобы он не срабатывал на ассетах и health-check.
Что делать дальше
Поставьте на дев-окружение Laravel Telescope или Debugbar — оба показывают количество запросов на страницу и время каждого. Ревью «на глаз» пропускает N+1, а панель с числом «312 queries» не пропустит. Включите Model::preventLazyLoading() вне продакшена прямо сейчас: это разовое изменение в провайдере, которое переводит целый класс ошибок из категории «заметили в проде» в «упало на CI». Следующий показатель, за которым стоит следить в проде, — время до первого байта (TTFB) и количество запросов к БД на эндпоинт; резкий рост числа запросов почти всегда означает, что в цикл рендера просочилась ленивая загрузка.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Request Lifecycle — Laravel Documentation, Eloquent: preventLazyLoading — Laravel Documentation
Частые вопросы
У меня Laravel 10, а не 11 — конфигурация middleware в bootstrap/app.php не работает
preventLazyLoading бросает исключение на связи, которую я загружаю условно — что делать
Можно ли делать запросы к базе в конструкторе контроллера?
Стоит ли выносить всю логику в action-классы или это оверинжиниринг?
Я вынес валидацию в Form Request, но правила зависят от роли пользователя — как быть?
Как понять, что тормозит именно жизненный цикл, а не сам SQL-запрос?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.