HttpKernel перетворює Request на Response, дорогою розсилаючи події: kernel.request (маршрутизація), kernel.controller і kernel.controller_arguments (резолвінг контролера й аргументів), kernel.view (якщо контролер повернув не Response), kernel.response, kernel.terminate після відправлення відповіді.
Уся Symfony зводиться до одного контракту: HttpKernelInterface::handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = true): Response. Фронт-контролер public/index.php (з Symfony 5.3 це кілька рядків, які повертають замикання, а решту робить компонент Runtime через vendor/autoload_runtime.php) створює Request::createFromGlobals(), віддає його ядру, відправляє отриманий Response і викликає terminate(). Клас Kernel із src/Kernel.php спершу завантажується — реєструє бандли й піднімає скомпільований контейнер, — а потім передає запит сервісу http_kernel. Саме HttpKernel виконує прохід від запиту до відповіді й у ключових точках розсилає події через EventDispatcher. Фреймворк тут нічого не «знає» наперед: маршрутизацію, безпеку, профайлер додають звичайні слухачі.
Порядок такий. kernel.request — найбагатша подія: тут RouterListener (пріоритет 32) зіставляє URL із маршрутом і кладе результат у $request->attributes (_route, _controller, параметри шляху), тут же працює firewall. Якщо будь-який слухач викличе $event->setResponse(), поширення події зупиняється й HttpKernel одразу переходить до kernel.response — контролер не викликається взагалі. Далі ControllerResolver перетворює _controller на callable, диспатчиться kernel.controller (контролер ще можна підмінити), ArgumentResolver збирає аргументи, диспатчиться kernel.controller_arguments — і лише тоді викликається ваш метод.
Що станеться з результатом, залежить від його типу. Повернули Response — Symfony одразу диспатчить kernel.response, де слухачі правлять заголовки, кладуть cookie, вставляють toolbar профайлера. Повернули щось інше — масив, DTO, сутність — диспатчиться kernel.view, і хтось має перетворити це на Response: атрибут #[Template] віддасть дані у Twig, у API-проєктах це робить слухач, що серіалізує обʼєкт у JSON. Якщо після kernel.view відповіді досі немає, ви отримаєте LogicException про те, що контролер має повертати Response. Виняток у будь-якому місці циклу (за $catch = true) веде до kernel.exception: слухачі можуть підставити свою відповідь, інакше ErrorListener рендерить сторінку помилки, а код береться з HttpExceptionInterface, якщо виняток його реалізує.
Останні два кроки часто плутають. kernel.finish_request закриває обробку конкретного запиту (важливо для під-запитів: він відновлює контекст батьківського запиту, наприклад локаль), а kernel.terminate спрацьовує вже після $response->send() — відповідь у користувача, а процес доробляє «хвіст». Тому змінювати відповідь там безглуздо, зате доречно відправити лист чи дописати статистику; щось справді довге має йти в чергу. І весь цей цикл повторюється для кожного під-запиту з render(controller(...)) та ESI, тому будь-який слухач, дія якого має бути одноразовою, починається з if (!$event->isMainRequest()) { return; }.
Практичний спосіб побачити все це самому: php bin/console debug:event-dispatcher kernel.request покаже реальний список слухачів із пріоритетами, а вкладка Events у веб-профайлері — які з них відпрацювали на конкретному запиті. Це швидше за читання схеми й одразу пояснює, чому ваш listener бачить або не бачить _route.
// src/EventSubscriber/MaintenanceSubscriber.php
final class MaintenanceSubscriber implements EventSubscriberInterface
{
public function __construct(private MaintenanceMode $maintenance) {}
public static function getSubscribedEvents(): array
{
return [
// 33 > 32 (пріоритет RouterListener): маршрут ще не визначено, _route буде null
KernelEvents::REQUEST => ['onRequest', 33],
KernelEvents::RESPONSE => 'onResponse',
];
}
public function onRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return; // під-запити з render()/ESI не чіпаємо
}
if ($this->maintenance->isOn()) {
// setResponse зупиняє поширення події: контролер не викличеться взагалі,
// далі одразу піде kernel.response
$event->setResponse(new Response('Технічні роботи', 503));
}
}
public function onResponse(ResponseEvent $event): void
{
// сюди потрапляє і звичайна відповідь, і сторінка помилки з kernel.exception
$event->getResponse()->headers->set('X-App', 'phpukraine');
}
}
// src/Controller/ArticleController.php
#[Route('/articles/{slug}', name: 'article_show')]
public function show(Article $article): Response // обʼєкт підставив EntityValueResolver
{
return $this->render('article/show.html.twig', ['article' => $article]);
}
Проговоріть ланцюжок уголос у порядку подій і назвіть для кожної реального слухача: `kernel.request` → `RouterListener` і `FirewallListener`, `kernel.controller_arguments` → резолвери аргументів, `kernel.view` → `#[Template]`, `kernel.exception` → `ErrorListener`, `kernel.terminate` → відправлення листів. Це одразу показує, що ви бачили стек, а не читали схему.