understanding the request lifecycle in detail
Between an HTTP request arriving and the response being sent, Symfony runs through a fixed chain of kernel events, each with its own clearly scoped purpose. Knowing exactly when RequestEvent, ControllerEvent, ResponseEvent, TerminateEvent, and ExceptionEvent fire lets you place cross-cutting concerns like authentication, header injection, or logging precisely where they belong, instead of scattering them across controllers or services where they're easily forgotten or applied inconsistently.
Table of Contents
- 1. Overview of a request's event chain
- 2. RequestEvent: request modification right at the start
- 3. ControllerEvent: intervening right before controller execution
- 4. ResponseEvent: the default place for header injection
- 5. TerminateEvent: follow-up work after the response is sent
- 6. ExceptionEvent: centralized error handling
- 7. Priorities for multiple listeners on the same event
- 8. Main request vs. sub-request with ESI and forward
- 9. A practical checklist for choosing the right event
- 10. Summary
- 11. FAQ
1. Overview of a request's event chain
A single HTTP request runs through a clearly defined sequence of kernel events in Symfony, starting with kernel.request immediately after the request arrives, followed by kernel.controller shortly before the resolved controller gets called, then kernel.view if the controller doesn't return a Response object, kernel.response once the response has been successfully built, and finally kernel.terminate after the response has already been sent to the client. In parallel, kernel.exception can fire at any point once an unhandled exception occurs during processing.
This order isn't an arbitrary implementation choice, it reflects the actual processing flow of the HttpKernel. Each event carries its own event object with specific methods, say getRequest(), getResponse(), or setResponse(), which are only meaningful or even available at certain points in the lifecycle. Trying to access a response inside kernel.request fails, since at that point none exists yet.
2. RequestEvent: request modification right at the start
The RequestEvent fires immediately after the request arrives, before Symfony has even determined which controller is responsible. That makes it the ideal spot for logic that should apply to every request regardless of the eventual route, say setting the locale based on a header, intercepting maintenance-mode requests early, or short-circuiting processing entirely via setResponse() when a request shouldn't reach a controller at all based on certain criteria.
A common practical use case is a CorsListener that, for OPTIONS preflight requests, already sets an appropriate response with the necessary CORS headers inside RequestEvent, preventing the actual controller from ever being called for these technical pre-flight requests. It's important that a response set via setResponse() completely skips the rest of the chain for kernel.controller and kernel.view and jumps straight to kernel.response.
<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
/**
* Sets the application locale based on a custom request header.
*/
final class LocaleFromHeaderSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['onKernelRequest', 20],
];
}
public function onKernelRequest(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
$locale = $event->getRequest()->headers->get('X-App-Locale');
if ($locale !== null) {
$event->getRequest()->setLocale($locale);
}
}
}
3. ControllerEvent: intervening right before controller execution
The ControllerEvent fires after Symfony has resolved the responsible controller through routing, but before it's actually called. At that point, the controller already exists as a callable, retrievable via getController() and even fully replaceable via setController(), which can be used for scenarios like A/B testing or dynamically redirecting to an alternative controller based on feature flags.
In practice, this event is used less often for custom logic than RequestEvent or ResponseEvent, but it plays a central role in attribute-based mechanisms like security voter checks driven by #[IsGranted] attributes, which are evaluated exactly at this point, since by now it's already known which controller method will be called and therefore which attributes are attached to it.
4. ResponseEvent: the default place for header injection
The ResponseEvent fires once a response has been finalized, regardless of whether it was returned directly by the controller or produced from a non-response return value through a kernel.view event. This event is the default place for anything that should apply to every outgoing response, say setting security headers like Content-Security-Policy or X-Frame-Options, adding caching headers, or attaching a request ID header for tracing purposes.
An important difference from RequestEvent is that ResponseEvent still fires even when an exception occurred earlier and was converted into an error response via kernel.exception. A security header listener registered on ResponseEvent therefore also applies to error pages, which is explicitly desirable for security-relevant headers, since a 404 or 500 page needs the same protection as a regular response.
5. TerminateEvent: follow-up work after the response is sent
The TerminateEvent only fires after the response has already been fully sent to the client, making it the ideal place for work that's time-consuming but shouldn't affect the user's perceived response time. Typical examples include asynchronously sending analytics events, writing detailed audit logs, or dispatching Messenger messages that don't strictly need to be part of the original transaction.
It's worth noting that TerminateEvent only reliably runs after the response has been sent under the classic PHP-FPM model. Under FrankenPHP or other long-running worker setups, this behavior can differ, so purely time-critical follow-up work should be additionally tested in such environments instead of blindly relying on the classic behavior.
6. ExceptionEvent: centralized error handling
The ExceptionEvent fires whenever an unhandled exception occurs anywhere in the processing chain, whether in routing, in the controller, or in another listener. A listener can produce a matching error response here via setResponse(), say a formatted JSON error message for API endpoints or a user-friendly error page for regular web requests, thereby overriding Symfony's default error handling.
Multiple ExceptionEvent listeners can each handle different exception types, say one listener specifically for ValidationFailedException and another as a generic fallback for everything else. Once a listener has called setResponse(), processing of the kernel.exception chain normally continues, so subsequent listeners with lower priority can still modify the already-set response, which needs to be kept in mind when ordering and prioritizing multiple exception listeners.
7. Priorities for multiple listeners on the same event
When multiple listeners are registered on the same event, the priority value determines the order in which they run. Higher priority values run first, lower and negative values run later, and the default when unspecified is 0. This order matters whenever one listener depends on the work of another, say when a LocaleListener needs to run before every translation-dependent listener so the correct language is already set by the time other listeners need it.
Symfony's own internal listeners, say the router listener or the firewall listener from the security bundle, already carry fixed priorities that you should know about before registering custom listeners with potentially colliding priorities. A custom security-relevant listener that needs to run before the firewall check absolutely requires a higher priority than the firewall listener itself, otherwise your own logic only kicks in after security has already decided to deny access.
8. Main request vs. sub-request with ESI and forward
Every kernel event carries an isMainRequest() method that distinguishes whether the event applies to the original client-initiated request or to an internal sub-request, say one created by a forward() inside a controller or by an Edge Side Include fragment. Many listeners, especially those setting security headers or sending analytics events, should run exclusively for the main request, since a sub-request is merely part of processing the main request and doesn't produce its own response sent to the client.
Forgetting this distinction can cause unwanted duplicate execution, say an analytics event that fires both for the main request and for every sub-request embedded within it, or security headers that get set multiple times and merged inconsistently. Checking isMainRequest() should therefore be the first thing inside any listener method whose logic is meant only for the original client request.
9. A practical checklist for choosing the right event
A simple rule of thumb helps with picking the right event: anything that should run before business logic, say locale resolution, maintenance mode, or early access control, belongs in RequestEvent. Anything that depends on the actual response, say header injection or response manipulation, belongs in ResponseEvent. And anything that can be done after the response is sent without affecting response time belongs in TerminateEvent.
For error handling, ExceptionEvent is practically always the right choice, while ControllerEvent is used less often for custom application logic and mostly remains the domain of frameworks or bundles that need to work with the concrete controller callable at the attribute or annotation level. Sticking to this rough mapping gets you to the right event in the vast majority of cases without having to consult the event reference every single time.
| Event | Timing | Typical use case |
|---|---|---|
| kernel.request | right after the request arrives | set locale, early access control |
| kernel.controller | before the controller is called | swap controller, attribute checks |
| kernel.response | after the response is built | set security and caching headers |
| kernel.terminate | after the response is sent | analytics, audit logs, async follow-up work |
| kernel.exception | on an unhandled exception | produce a centralized error response |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
HttpKernel Events: Key Facts
Order
RequestEvent, ControllerEvent, ResponseEvent, TerminateEvent, ExceptionEvent runs in parallel
RequestEvent
ideal spot for locale, maintenance mode, early response short-circuits
ResponseEvent
default place for header injection, still applies to error pages
Priorities
higher values run first, critical when listeners depend on each other