Ядро Symfony: жизненный цикл HTTP-запроса от Request до Response
Между входящим HTTP-запросом и ответом Symfony проходит через шесть событий HttpKernel. Разбираем, какое событие за что отвечает и где чаще всего ломается обработка запроса.

Symfony превращает Request в Response не одним вызовом, а цепочкой из шести событий, которые диспетчеризует класс HttpKernel. Каждое событие — точка расширения: сюда цепляются аутентификация, сериализация, обработка исключений и логирование. Когда ответ приходит не тот, что ждали, или подписчик срабатывает не в том порядке, причина почти всегда в том, что разработчик неверно представляет, на каком этапе жизненного цикла он находится. Материал написан для Symfony 6.4 / 7.x; последовательность событий не менялась начиная с версии 2, а вот способ подписки на них — да.
Разбираем именно последовательность событий и что происходит между ними. Устройство контейнера зависимостей и компиляция бандлов — отдельная тема, здесь её не трогаем.
Точка входа: front controller и HttpKernel
Всё начинается в public/index.php. Веб-сервер направляет любой запрос сюда, скрипт создаёт объект Request из суперглобальных переменных и передаёт его ядру:
<?php
// public/index.php, Symfony 7.x
use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
Класс App\Kernel наследует HttpKernel, а точнее — оборачивает его. Метод handle(Request $request) и запускает весь цикл. Он возвращает Response, после чего вызывается send() и, наконец, terminate().
Ключевая мысль: контроллер — это не начало и не конец обработки. Это середина. До него отрабатывают четыре потенциальные точки прерывания, после — ещё две. Контроллер, который «просто возвращает ответ», на деле стоит внутри длинной цепочки, способной перехватить управление в любой момент.
Шесть событий по порядку
Все события — константы класса KernelEvents. Вот их порядок и назначение.
| Событие | Класс события | Когда срабатывает | Типичный подписчик |
|---|---|---|---|
| kernel.request | RequestEvent | До определения контроллера | Роутинг, аутентификация (firewall), локаль |
| kernel.controller | ControllerEvent | Контроллер найден, но не вызван | Проверка прав через атрибуты, инициализация |
| kernel.controller_arguments | ControllerArgumentsEvent | Аргументы контроллера собраны | Разрешение аргументов, ParamConverter-логика |
| kernel.view | ViewEvent | Контроллер вернул НЕ Response | Сериализация в JSON, рендер шаблона |
| kernel.response | ResponseEvent | Response готов, до отправки | Заголовки, куки, веб-профайлер |
| kernel.terminate | TerminateEvent | После отправки ответа клиенту | Отправка писем, тяжёлое логирование |
Отдельно стоит kernel.exception (ExceptionEvent): оно срабатывает, если на любом этапе выброшено необработанное исключение. Именно здесь ErrorListener превращает исключение в страницу ошибки или JSON-ответ.
kernel.request — до того, как ядро знает, что вызывать
На этом этапе контроллер ещё не выбран. Здесь отрабатывает RouterListener: он сопоставляет путь запроса с маршрутом и кладёт результат в атрибуты запроса — _controller, _route и параметры пути. Здесь же работает система безопасности: firewall перехватывает запрос и может вернуть редирект на форму входа, не доводя дело до контроллера.
Если подписчик на kernel.request вызовет $event->setResponse(...), цикл сразу перепрыгивает к kernel.response. Контроллер не вызывается вовсе. На этом строятся maintenance mode и ранние проверки.
kernel.controller и kernel.controller_arguments
После роутинга ControllerResolver превращает строку _controller в вызываемый объект. Событие kernel.controller даёт последний шанс подменить контроллер целиком. Затем ArgumentResolver собирает аргументы: читает типы параметров метода и подставляет сущности, сервисы, значения из запроса. На kernel.controller_arguments уже известно, с какими именно аргументами уйдёт вызов.
kernel.view — только когда контроллер вернул не Response
Если контроллер вернул массив или объект вместо Response, ядро само по себе не знает, что с этим делать, и диспетчеризует kernel.view. Подписчик должен превратить возвращённое значение в Response. Так работает автосериализация в API Platform и рендер через атрибут #[Template]. Если ни один подписчик не установил ответ — Symfony бросит исключение The controller must return a Response.
kernel.terminate — после отправки
Это единственное событие, которое выполняется после того, как клиент уже получил ответ (при работе через run() в PHP-FPM). Здесь место тяжёлым операциям, которые не должны задерживать отдачу страницы: отправка почты, синхронизация с внешним API, объёмное логирование.
Как подписаться на событие
Рабочий подписчик, который добавляет заголовок ко всем ответам. Symfony 6.4+ автоматически регистрирует классы, реализующие EventSubscriberInterface, если включён autoconfigure (по умолчанию так и есть):
<?php
// src/EventSubscriber/ResponseHeaderSubscriber.php
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ResponseHeaderSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
// Число — приоритет: чем больше, тем раньше вызов
return [
KernelEvents::RESPONSE => ['onKernelResponse', 0],
];
}
public function onKernelResponse(ResponseEvent $event): void
{
// Реагируем только на главный запрос, не на sub-request
if (!$event->isMainRequest()) {
return;
}
$response = $event->getResponse();
$response->headers->set('X-Handled-By', 'app');
}
}
С Symfony 6.1 появился атрибут #[AsEventListener] — можно повесить его прямо на метод и не описывать getSubscribedEvents(). Оба способа рабочие; для одного метода атрибут короче, для нескольких событий в одном классе удобнее интерфейс.
Главный запрос и sub-request
Внутри одного HTTP-запроса Symfony может запустить дочерние — например, при рендере фрагмента через render(controller(...)) в Twig. Каждый sub-request проходит те же kernel.request, kernel.controller и остальные события. Отсюда самая частая ошибка: подписчик, который должен отработать один раз, срабатывает на каждом фрагменте.
Проверка $event->isMainRequest() (до Symfony 5.3 метод назывался isMasterRequest()) отсекает дочерние запросы. Если ваш подписчик выставляет куку, редиректит или меняет глобальное состояние — почти наверняка эта проверка ему нужна.
Приоритеты: почему подписчик отработал не вовремя
На одно событие вешаются десятки слушателей — и Symfony, и ваши. Порядок задаёт число приоритета: больше — раньше. Firewall на kernel.request имеет высокий приоритет (порядка 8), чтобы отработать до вашей логики. Если вы ждёте, что пользователь уже аутентифицирован, а ваш слушатель встал раньше firewall — получите анонима там, где ждали залогиненного.
Посмотреть реальный порядок для конкретного события:
# Symfony 6.4 / 7.x, из корня проекта
php bin/console debug:event-dispatcher kernel.request
Команда выведет всех слушателей события с их приоритетами в порядке вызова. Это первое, что стоит запустить, когда подписчик «не работает», хотя код верный.
Где цикл ломается чаще всего
- Ответ не тот, потому что перехватили раньше. Слушатель на
kernel.requestсsetResponse()обрывает цикл. Если два таких слушателя, побеждает тот, у кого приоритет выше, — а не тот, что «логичнее». - Подписчик срабатывает на sub-request. Нет проверки
isMainRequest()— редирект или установка куки происходит внутри рендера фрагмента и ломает основной ответ. - The controller must return a Response. Контроллер вернул массив или сущность, а подписчика на
kernel.viewнет. Либо вернитеResponseсам, либо подключите то, что сериализует (API Platform, атрибут#[Template]). - Тяжёлая работа задерживает ответ. Отправка письма из контроллера держит соединение открытым. Перенесите её на
kernel.terminateили в очередь Messenger. - Исключение не даёт ожидаемый статус. В
dev-окружении вы видите страницу профайлера, вprod— другой ответ. Формат ответа на исключение настраивается черезkernel.exception, поведение зависит от окружения.
Что делать дальше
Запустите php bin/console debug:event-dispatcher без аргумента — увидите все события ядра и полный список слушателей своего приложения. Это карта того, что реально происходит с запросом в вашем проекте, а не в документации. Дальше загляните в веб-профайлер (панель Events на странице любого запроса в dev): он показывает, какие слушатели отработали для конкретного запроса и сколько заняли. Когда картина последовательности в голове совпадёт с тем, что показывают эти два инструмента, отладка чужих бандлов и своей безопасности перестанет быть гаданием.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: The HttpKernel Component — Symfony Documentation, Built-in Symfony Events (KernelEvents) — Symfony Documentation
Частые вопросы
У меня Symfony 5.4, порядок событий тот же?
Мой подписчик не срабатывает, хотя код верный. Что проверить?
Чем EventSubscriber отличается от EventListener в Symfony?
Почему на kernel.terminate нельзя изменить ответ?
Как отдать JSON-ответ, не создавая Response вручную в каждом контроллере?
Мой слушатель на kernel.request ломает рендер Twig-фрагментов, что не так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.