Cross-Cutting Concerns
Add a header to every response, time every request, and use priority to decide which listener runs first.
#[AsEventListener(event: KernelEvents::RESPONSE, priority: -1024)]Some requirements belong to every request rather than to any one controller: security headers, a response time measurement, a correlation id, a check that a request looks the way the application expects. Copying them into each controller means forgetting one. The lifecycle events are where they belong.
Pick the event by what you need
Before anything else, use kernel.request; the routing information is not available at a high priority yet, but the request is. To act once the controller is known but before it runs, use kernel.controller. To change what goes back, use kernel.response. For work that should not delay the client, use kernel.terminate, which runs after the response has been sent.
Ordering
Every registration has an integer priority, default 0; higher runs earlier. A listener that wants the last word on a response takes a negative priority, so the framework's own response listeners have already finished. A listener that must see the request before anything else takes a large positive one. php bin/console debug:event-dispatcher kernel.response prints the real order for an event, including the framework's listeners, which is the only reliable way to choose a number.
What trips people up
Pairs of listeners need state, and the right place is $request->attributes, not a property on the listener, because a listener is one shared service that also sees sub-requests. Note too that kernel.response runs for error responses as well, so a header added there also lands on a 500.
Example
<?php
// src/EventSubscriber/ResponseTimeSubscriber.php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class ResponseTimeSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
// High priority: start the clock before routing.
KernelEvents::REQUEST => [['start', 1024]],
// Low priority: stop it after every other response listener.
KernelEvents::RESPONSE => [['stop', -1024]],
];
}
public function start(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
// Per-request state lives on the request, not on $this.
// hrtime(true) returns nanoseconds as an int.
$event->getRequest()->attributes->set('app_started_at', hrtime(true));
}
public function stop(ResponseEvent $event): void
{
$startedAt = $event->getRequest()->attributes->get('app_started_at');
if (!is_int($startedAt)) {
return; // sub-request, or start() never ran
}
$headers = $event->getResponse()->headers;
$headers->set(
'X-Response-Time-Ms',
sprintf('%.1f', (hrtime(true) - $startedAt) / 1_000_000),
);
$headers->set('X-Content-Type-Options', 'nosniff');
}
}When to use it
- Put security headers on every response from one place instead of per controller.
- Measure how long each request takes and expose it as a response header.
- Reject malformed requests to an API path group before any controller runs.
- Record usage or metrics after the response has been sent, so the user does not wait.
More examples
The same ordering, set in configuration
Use this form when the priority is an operational decision rather than a fact about the class, or when you need the same class registered differently in two environments. It is also what you will meet in projects written before attributes, so you need to recognise it. The inverse is worth remembering too: a tag with no event and no method is the shortest spelling of all, because Symfony reads the event from the method's type declaration.
# config/services.yaml
services:
# Longhand for the two attributes
# #[AsEventListener(event: 'kernel.request', priority: 1024)]
# #[AsEventListener(event: 'kernel.response', priority: -1024)]
# written on the class instead. Same registrations, same order.
App\EventListener\ResponseTimeListener:
tags:
- name: kernel.event_listener
event: kernel.request
method: start
priority: 1024
- name: kernel.event_listener
event: kernel.response
method: stop
priority: -1024Work that must not delay the response
kernel.terminate is for the work you were tempted to put in a controller and should not: writing an audit row, pushing a metric, warming a cache. The response has been sent, so the user waits for none of it. Two limits to keep in mind. Setting headers on the response at this point does nothing, because it is already on the wire. And an exception here cannot produce an error page, so anything fragile belongs in a queued message instead.
<?php
// src/EventListener/UsageAccountingListener.php
declare(strict_types=1);
namespace App\EventListener;
use App\Service\UsageRecorder;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\TerminateEvent;
#[AsEventListener]
final class UsageAccountingListener
{
public function __construct(
private readonly UsageRecorder $recorder,
) {
}
public function __invoke(TerminateEvent $event): void
{
// The client already has the response. Nothing written to the
// response here can reach the browser; only side effects count.
$this->recorder->record(
$event->getRequest()->getPathInfo(),
$event->getResponse()->getStatusCode(),
);
}
}A check that runs before the controller
A guard on kernel.controller: the controller has been resolved but not called, so refusing here means it never runs. POST /api/comments with a form-encoded body answers 415 and the controller is untouched; the same request with application/json passes straight through. Throwing an HTTP exception rather than setting a response is deliberate, because it lets one exception listener decide how every failure is rendered. The repeated early returns are the normal shape of this code: a listener runs on everything, so most of it is deciding not to act.
<?php
// src/EventListener/RequireJsonBodyListener.php
declare(strict_types=1);
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
#[AsEventListener]
final class RequireJsonBodyListener
{
public function __invoke(ControllerEvent $event): void
{
$request = $event->getRequest();
if (!$event->isMainRequest()) {
return;
}
if (!str_starts_with($request->getPathInfo(), '/api/')) {
return;
}
if (!in_array($request->getMethod(), ['POST', 'PUT', 'PATCH'], true)) {
return;
}
if ('json' !== $request->getContentTypeFormat()) {
// Thrown here, this becomes kernel.exception and then a 415.
throw new UnsupportedMediaTypeHttpException(
'This endpoint accepts application/json only.',
);
}
}
}
Discussion