Centralizing custom HTTP response headers with a Symfony event listener
By: Paulo Valle | April 14, 2026 | development and symfony
Caching plays a critical role in the performance and scalability of websites. One of the most important tools for controlling caching behaviour is the Cache-Control HTTP header. This header defines how responses are cached by browsers, reverse proxies, and CDNs, and how long they remain valid.
In complex websites, different page types often require different caching strategies. For example, on a news website, an article page may be cached for a few hours to a day, while the homepage — where content changes frequently — may only be cached for a few minutes.
Creating caching HTTP response headers in Symfony can be straightforward. Often, this will be accomplished in Symfony using controllers- PHP functions that read information from the request object and return the response. This is a standard implementation, and what developers typically utilize in most of their projects. However, without a clear strategy, these rules often end up duplicated across multiple controllers, making them harder to maintain, inconsistent across pages, and easy to misconfigure. As demonstrated in this post, we decided to standardize the Cache-Control header for one of our projects. This makes the caching rules easy to update in a single place, avoids duplications, and provides a safe default configuration that does not require any additional code in the controllers.
What is the idea?
Instead of adding HTTP response headers directly in controller methods or creating global helper functions, we've created a listener to listen to kernel response events. This listener centralizes all header-related logic for the application, offering a single place to customize and modify HTTP response headers after the controller has executed. This ensures that every response passes through a consistent rule engine before being sent to the client.
As a result, a single piece of logic sets all required Cache-Control headers for all pages of the site, taking into account the different requirements of each page type. It ensures consistency by grouping similar pages under the same rules and provides a default configuration when the Cache-Control header is not explicitly defined in a controller.
How does the listener work?
The Ibexa CMS receives the request and builds the page as expected. When the response is fully prepared and ready to be sent back to the requester, Symfony triggers the Kernel Response event, and a listener injects the logic to set the appropriate Cache-Control headers.
The first thing the listener must do is check whether the response belongs to the main HTTP request initiated by the client. This is important because Symfony can generate multiple sub-requests within a single main request. Embedded controller outputs, like headers, footers, or sidebars, are good examples of sub-requests. Surely, the sub-requests do not need their own Cache-Control headers, so in those cases, the listener can safely skip applying the logic.
/** @var \Symfony\Component\HttpKernel\Event\ResponseEvent $event */
if (!$event->isMasterRequest()) {
// don't do anything if it's not the master request
return;
}
In Ibexa, the main requests typically come from either a ContentType view or a Symfony route. Initially, for ContentType view requests, we decided to generate Cache-Control headers based on the content type.
$request = $event->getRequest();
$view = $request->attributes->get('view');
// checking view instance of will ensure the following is applied to new stack responses only
if($view instanceof ContentView){
$contentType = '';
$parameters = $view->getParameters();
if (array_key_exists('content', $parameters) && $parameters['content'] instanceof Content){
$contentType = $parameters['content']->contentInfo->contentTypeIdentifier;
}
// get cache headers based on content type
$cacheHeaders = $this->getCacheHeadersByContentType($contentType);
Once the content type is available, we created a method that retrieves the appropriate Cache-Control header for that content type. Simple conditions based on the provided content type can be used to determine the response headers you want.
private function getCacheHeadersByContentType(String $contentType):Array {
$cacheHeaders = array();
switch ($contentType) {
case 'podcast':
case 'podcast_season':
case 'frontpage':
$cacheHeaders = array(
'cache-control' => 'max-age=300, must-revalidate, public',
'mugo-cache-policy' => '300'
);
break;
default:
$cacheHeaders = array(
'cache-control' => 'max-age=180, must-revalidate, public',
'mugo-cache-policy' => '180'
);
}
return $cacheHeaders;
}
The Route information is also available if you want to set response headers for specific routes.
$requestRoute = $request->attributes->get('_route');
if ($requestRoute) {
// get cache headers based on route
$cacheHeaders = $this->getCacheHeadersByRoute($requestRoute);
}
The possibilities are endless, ranging from exact matches to regular expressions that can target groups of routes if your routes are standardized. As you can see, it's easy to manage all headers in a single place. Our method to set the Cache-Control header for routes looks like:
private function getCacheHeadersByRoute(String $route):Array {
$cacheHeaders = array();
switch( $route ) {
case (preg_match('/mugorss.public_url.feed.*/', $route) ? true : false) :
// feeds
$cacheHeaders = array(
'cache-control' => 'max-age=300, must-revalidate, public',
'mugo-cache-policy' => '300'
);
Break;
// extra cases
default:
// default cache policy for all routes
}
return $cacheHeaders;
}
Bonus tip
You may notice that both getCacheHeadersByContentType() and getCacheHeadersByRoute() also create a custom response header called "mugo-cache-policy". Consider this a “bonus” tip in this post.
Imagine you have two caching layers, such as Varnish and CloudFront. You want Varnish to cache the page for the duration defined in the Cache-Control header. However, Varnish also overrides the Cache-Control header, forcing CloudFront to cache the page for a shorter period. In this case, the public Cache-Control header returned when requesting the page will reflect only the value overridden by Varnish for CloudFront. The custom mugo-cache-policy header helps you identify how long Varnish actually caches the page.
Let’s illustrate this scenario! You request a Podcast page. Ibexa builds the page, and the custom event listener sets the Cache-Control header to 300 seconds (5 minutes). Varnish caches the page for 5 minutes but overrides the Cache-Control header to 30 seconds, so CloudFront only caches it for 30 seconds. Finally, the response headers for this Podcast page will provide all the relevant information:
cache-control: public, s-maxage=30
mugo-cache-policy: 300
The complete implementation
First, register your event listener in the services config file.
services:
...
# Register EventListener onKernelResponse AppBundle\EventListener\RequestListener:
tags:
- { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }
Finally, you can create your listener in the EventListener folder in your bundle.
<?php
namespace AppBundle\EventListener;
use Netgen\Bundle\IbexaSiteApiBundle\View\ContentView;
use Netgen\IbexaSiteApi\Core\Site\Values\Content;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
class RequestListener
{
public function onKernelResponse(ResponseEvent $event)
{
if (!$event->isMasterRequest()) {
// don't do anything if it's not the master request
return;
}
// get content
$request = $event->getRequest();
$view = $request->attributes->get('view');
$cacheHeaders = false;
// checking view instance of will ensure the following is applied to new stack responses only
if($view instanceof ContentView){
$contentType = '';
$parameters = $view->getParameters();
if (array_key_exists('content', $parameters) && $parameters['content'] instanceof Content){
$contentType = $parameters['content']->contentInfo->contentTypeIdentifier;
}
// get cache headers based on content type
$cacheHeaders = $this->getCacheHeadersByContentType($contentType);
} else {
$requestRoute = $request->attributes->get('_route');
if ($requestRoute) {
// get cache headers based on route
$cacheHeaders = $this->getCacheHeadersByRoute($requestRoute);
}
}
if ($cacheHeaders) {
// get response
$response = $event->getResponse();
// add cache headers to response
foreach ($cacheHeaders as $cacheHeaderIndex => $cacheHeaderValue) {
$response->headers->set($cacheHeaderIndex, $cacheHeaderValue);
}
}
}
private function getCacheHeadersByContentType(String $contentType):Array {
$cacheHeaders = array();
switch ($contentType) {
case 'podcast':
case 'podcast_season':
case 'frontpage':
$cacheHeaders = array(
'cache-control' => 'max-age=300, must-revalidate, public',
'mugo-cache-policy' => 'long'
);
break;
default:
$cacheHeaders = array(
'cache-control' => 'max-age=180, must-revalidate, public',
'mugo-cache-policy' => 'default'
);
}
return $cacheHeaders;
}
private function getCacheHeadersByRoute(String $route):Array {
$cacheHeaders = array();
switch( $route ) {
case (preg_match('/mugorss.public_url.feed.*/', $route) ? true : false) :
// feeds
$cacheHeaders = array(
'cache-control' => 'max-age=300, must-revalidate, public',
'mugo-cache-policy' => 'route-long'
);
break;
default:
// default cache policy for all routes
}
return $cacheHeaders;
}
}
Conclusion
Centralizing the Response Header logic in a listener not only simplifies header management but also provides a clear, consistent, and robust way to define headers based on different page types. Moreover, you can confidently optimize your Response header strategies, avoid unexpected overrides, and keep your site fast and responsive.
Additionally, it adds a safety layer for future development. If you have a poor memory like me, or for any other reason, forget to set the Cache-Control header for a new content type, don’t worry. The event listener has your back, setting a default Cache-Control header that will be applied to your new pages.

