Symfony HttpKernel Events: Understanding the Request Lifecycle in Detail
AI generated
SF
{ }
Symfony · HttpKernel · Events · Architecture
Symfony HttpKernel Events
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.

16 min read HttpKernel events Symfony 7 · event lifecycle

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

11. FAQ: HttpKernel Events: Key Facts

1In what order do the HttpKernel events run?
RequestEvent first, then ControllerEvent, possibly kernel.view, then ResponseEvent, and finally TerminateEvent. ExceptionEvent can fire in parallel at any point once an unhandled exception occurs.
2What is RequestEvent best suited for?
Logic that should apply to every request before the controller is resolved, say locale setting, maintenance mode checks, or early access control with a direct response via setResponse().
3Why is ResponseEvent the right place for security headers?
Because it still fires even when an exception occurred earlier and was converted into an error response, so security headers get applied consistently to every outgoing response, including error pages.
4When exactly does TerminateEvent run?
Only after the response has already been fully sent to the client, which makes it suitable for time-consuming follow-up work like analytics or audit logging that shouldn't affect perceived response time.
5How does error handling work through ExceptionEvent?
A listener can produce a matching error response via setResponse(), overriding Symfony's default error handling, and multiple listeners can each handle different exception types.
6What do priorities mean for multiple listeners on the same event?
Higher priority values run first, lower and negative values run later. This matters whenever one listener depends on another's work, say locale resolution running before translation.
7What's the difference between a main request and a sub-request?
The main request is the one originally initiated by the client, a sub-request is created by forward() or ESI fragments. isMainRequest() distinguishes the two, which matters for listeners meant to run only once per client request.
8What is ControllerEvent typically used for?
Less often for custom application logic, more often for framework-internal mechanisms like attribute-based security checks, since by this point the concrete controller method to be called is already known.
9Can a listener on RequestEvent prevent controller execution entirely?
Yes, calling setResponse() inside RequestEvent skips further processing up through kernel.view and jumps straight to kernel.response, so the controller never gets called at all.
10Does TerminateEvent behave differently under FrankenPHP than under PHP-FPM?
Behavior can differ, since TerminateEvent reliably runs after the response is sent under classic PHP-FPM, while long-running worker setups may require additional testing.