Contao heavily relies on HTTP caching and tries its very best to comply to the HTTP standards to provide the best performance possible.
It’s thus essential that you understand the basics of HTTP caching. Before we get into details of Contao too much, let’s talk HTTP basics first.
A Private Cache
is a cache that is intended for a single user only and must not be stored by a shared cache.
A Shared Cache
is a cache that can be shared amongst many users. It’s also known as Public Cache
or Reverse Proxy Cache
.
Whether your response can only be cached by a single user only or also by a Shared Cache
that sits between Contao
and the client can be controlled very easily using the Cache-Control
header:
use Symfony\Component\HttpFoundation\Response;
/** @var Response $response */
$response->headers->set('Cache-Control', 'private'); // private
$response->headers->set('Cache-Control', 'public'); // public
The three caching methods involve:
All of them cover a different use case and Cache Invalidation
only works for Shared Caches
.
Let’s dive a bit more into them.
Using Cache Expiration
you can control how long a cache entry will be kept in the cache until it expires.
Essentially you have two different headers here: Expires
and Cache-Control
.
We’ve already seen Cache-Control
before where we wanted to control whether a cache entry is targeted at a single
user only or if it can be cached by a Shared Cache
. But Cache-Control
can combine multiple directives which is
why Symfony provides an abstraction to it so you don’t have to write the Cache-Control
header yourself:
use Symfony\Component\HttpFoundation\Response;
/** @var Response $response */
$response->headers->addCacheControlDirective('private'); // private
$response->headers->addCacheControlDirective('max-age', 60); // can be stored for 60 seconds
use Symfony\Component\HttpFoundation\Response;
/** @var Response $response */
$response->headers->addCacheControlDirective('public'); // public
$response->headers->addCacheControlDirective('s-maxage', 60); // can be stored for 60 seconds on the shared cache
$response->headers->addCacheControlDirective('max-age', 600); // can be stored for 600 seconds in the private cache
You can read more on the different headers for example on MDN web docs which is an excellent resource.
Sometimes it’s not possible to define how long a cache entry can be cached. A request then has to be made to the server
but yet still, if the content still is the same, it does not need to be sent over the wire again.
That’s when Cache Validation
comes into place. Whether or not a cache entry is still valid can be determined by two
different methods:
You can either send a Last-Modified
header that contains a date or an ETag
header that associates your response
with a certain unique key. When the client then requests the server again, it can send along the If-Not-Modified-Since
or If-None-Match
headers to ask the server to only redeliver the content if it wasn’t modified since the last
modified date or if it does not match the last provided ETag
. If the response is unmodified, the server can reply
with a 304 Not Modified
HTTP response without any content, otherwise the regular response with an updated Last-Modified
or ETag
header is sent.
Read more on Cache Validation
on MDN web docs and in the Symfony documentation.
The best way to cache would be “forever until I tell you that the cache entry is now invalid”. Cache Invalidation
does
exactly that except for the fact that in the world of HTTP “forever” means “1 year”. It’s impossible to have cache entries
that last longer than a year.
Now to be able to tell a cache that it shall invalidate a previously sent response, you need access to it. In other words
this method only applies to Shared Caches
where we have access to and know how certain cache entries can be invalidated.
Sounds pretty advanced, why do we need that?
Every Contao Managed Edition ships with the Symfony Reverse Proxy (HttpCache
). That means, every Contao setup provides
a reverse proxy aka Shared Cache
that is able to cache our generated responses. It’s thus very important you get your
caching headers right, otherwise you’ll suddenly end up having responses of e.g. your content elements being cached even
if they were not meant to.
The whole caching framework around Contao is built on top of the awesome FOSHttpCacheBundle which allows different
reverse proxies to be configured. As outlined before, Contao comes pre-configured using Symfony’s HttpCache
as proxy but
uses the alternative storage back end toflar/psr6-symfony-http-cache-store which is a bit more advanced and in
contrast to the one provided by Symfony itself also supports cache invalidation by tags.
So when you generate a response, you can tag it by any number of tags and later on, purge them. Let’s say you had a news entry with ID 42 and you want to make sure that whenever a back end user modifies e.g. the news title, all pages that may list this news entry somewhere must be invalidated. You’ll certainly have a detail page of that news but potentially there are also pages that may show the title of this news such as a news archive module or maybe a sidebar that lists the latest news entries. You’ll end up having multiple pages that need to be invalidated:
Now what you can do is whenever your news ID 42 is used somewhere, you’ll just tag that response with a tag news-42
which will associate all of these four URLs with the tag news-42
.
You can then ask the reverse proxy to purge all responses associated with the tag news-42
when the back end user
edits that news entry.
Contao provides a nice framework for that so you don’t have to re-invent the wheel over and over again.
Make sure you inject the service fos_http_cache.http.symfony_response_tagger
and add your desired tags to this
service. It will collect all the tags during the current request and add them to the response using a kernel.response
event listener (if you’ve never heard of those, please head over to the Symfony docs).
This might look something like this:
// src/Controller/MySuperController.php
namespace App\Controller;
use Symfony\Component\HttpFoundation\Response;
use FOS\HttpCache\ResponseTagger;
class MySuperController
{
/**
* @var ResponseTagger
*/
private $responseTagger;
public function __construct(ResponseTagger $responseTagger)
{
$this->responseTagger = $responseTagger;
}
public function __invoke(): Response
{
$this->responseTagger->addTags(['news-42']);
return new Response();
}
}
If you are working with Contao fragments such as content elements, front end modules etc. note that you may want to
extend the Contao\CoreBundle\Controller\AbstractFragmentController
and use its tagResponse()
method for
convenience:
// src/Controller/ContentElement/MyContentElementController.php
namespace App\Controller\ContentElement;
use Contao\ContentModel;
use Contao\CoreBundle\Controller\ContentElement\AbstractContentElementController;
use Contao\CoreBundle\DependencyInjection\Attribute\AsContentElement;
use Contao\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
#[AsContentElement(category: 'texts')]
class MyContentElementController extends AbstractContentElementController
{
protected function getResponse(Template $template, ContentModel $model, Request $request): Response
{
// Do your stuff
$this->tagResponse(['news-42']);
return $template->getResponse();
}
}
To invalidate a given set of tags, inject the service fos_http_cache.cache_manager
which might look like so:
// src/EventListener/UserChangedSomethingListener.php
namespace App\EventListener;
use FOS\HttpCacheBundle\CacheManager;
class UserChangedSomethingListener
{
/**
* @var CacheManager
*/
private $cacheManager;
public function __construct(CacheManager $cacheManager)
{
$this->cacheManager = $cacheManager;
}
public function onUserChangedSomething()
{
$this->cacheManager->invalidateTags(['news-42']);
}
}
Invalidation requests will be collected during the same request too and only be executed during the kernel.terminate
event.
When working with DCAs in the Contao back end you don’t have to register callbacks at various places to make sure certain tags are being invalidated. This is because Contao invalidates a certain set of tags whenever a back end entry is created, updated or deleted.
The tags are as follows:
contao.db.<table-name>.<id>
(The record itself)contao.db.<table-name>
(Only if the DCA has no parent table defined)If the DCA has a parent table, Contao recursively iterates upwards the table hierarchy and invalidates the following tags as well:
contao.db.<parent-table-name>
(Only for the topmost parent table)contao.db.<parent-table-name>.<pid>
If the DCA has one or many child tables, Contao recursively iterates downwards the table hierarchy and invalidates the following tags as well:
contao.db.<child-table-name>.<cid>
Imagine you have edited a news article with ID 42. Contao will now automatically send an invalidation request to the reverse proxy to invalidate all responses associated with the following tags:
contao.db.tl_news_archive
(The topmost parent table)contao.db.tl_news_archive.1
(The parent record)contao.db.tl_news.42
(The record itself)contao.db.tl_content.420
(The first child record)contao.db.tl_content.421
(The second child record)Only the topmost parent table tag will be invalidated, i.e. contao.db.tl_news_archive
, but not contao.db.tl_news
or contao.db.tl_content
.
Imagine now you have your own DCA table tl_contact_details
with no parent or child tables. When you edit the contact
record with ID 42, Contao will automatically invalidate the following tags:
contao.db.tl_contact_details.42
(the contact record itself)contao.db.tl_contact_details
(the table itself)If you follow this convention and tag your responses accordingly in the front end, you don’t have to do any work in the back end at all!
Moreover, you don’t have to register to all the different callbacks such as onsubmit_callback
or ondelete_callback
.
You can register to the oninvalidate_cache_tags
callback and add your own tags.
since 4.13 You can also use the EntityCacheTags helper service to add and invalidate tags based on entity or model classes and instances.
In Contao, content elements and front end modules can be implemented as so called fragment controllers. These fragments are rendered with their defined renderer and merged into the main content. Each fragment returns a response and thus can tell whether it can be cached or not.
There are two fundamentally different ways to render fragments:
inline
renderer, but Contao provides its own default forward
renderer, which copies the original
request (whereas Symfony does not) to e.g. pass POST data to the subrequest.esi
the reverse
proxy (cache) will parse these placeholders before delivering to the client.
There’s also an hinclude
renderer which allows browsers to merge fragments using JavaScript, but the client-side
part is not provided out-of-the-box.Before Contao 4.9, inline fragments cannot affect the cache time of the page response.
By default, fragments like frontend modules and content elements are rendered directly inside the main request. This means the content of an inline fragment will be cached for as long as the page is cached, as configured in the page setting. If a fragment returns caching information in its response, the page’s cache time and the fragment will be merged to the lowest common denominator. For example, if the page is cacheable for a day, but the fragment only for one hour, the whole page will only be cached for one hour.
On the other hand, consider an example of a fragment that renders the current year. This fragment would be cacheable until the end of 31st of December. If the page is cached on the 10th of December for one day, the page cache time will not be affected. However, if the page is cached at 12pm on the 31st of December, the cache time will be lowered to 12 instead of 24 hours. The page cache will expire at the end of the year and regenerated on the 1st of January.
class CurrentYearController extends AbstractFrontendModuleController
{
protected function getResponse(Template $template, ModuleModel $model, Request $request): Response
{
$year = (int) date('Y');
$template->year = $year;
$response = $template->getResponse();
// Cache until the end of the year
$response->setPublic();
$response->setMaxAge(strtotime(($year + 1).'-01-01 00:00:00') - time());
return $response;
}
}
By setting the fragment renderer to esi
, the fragment will be cached separately from the main content.
The page can be cached for 24 hours, but the fragment can be cached for one week. On every request to the page,
the reverse proxy will merge the two pieces, rebuilding each if its cache has expired.
A common use case for this is a fragment that can certainly be cached longer than the current page, but is expensive to generate. Something like a weather preview, which requires an API request, but only updates once a day.
While ESI might sound tempting in order to improve performance, it only really ever makes sense if your fragment
itself is cacheable! If you are using ESI in order to circumvent the HTTP cache, you are most likely using the
wrong technology to solve your problem. Basically, if your fragment returns a $response
that is either private
or not cacheable at all. It will cause your reverse proxy to always boot the whole system anyway in order to
render your fragment. It might even worsen the performance if you e.g. use multiple ESI requests that have
to be called individually and thus slow down the whole page in total whereas it would’ve been faster to just generate
it all together. You have to really think things through to the end in order to decide, whether or not ESI can provide
a benefit. Do not use ESI if your fragment is inexpensive to generate (like e.g. the {{date::Y}}
insert tag). For inexpensive
cases, it is most likely better to cache the whole page and re-generate more often than having the proxy merge multiple
ESI fragments on every single request. But again, that very much depends on the individual caching times of a fragment.
Also remember that most problems can be solved on client-side using JavaScript. Always use the right tools for the right purpose!
Symfony automatically detects if it is talking to a gateway cache that supports ESI (like Symfony’s built in reverse proxy, that the Contao Managed Edition uses). ESI is also supported by reverse proxies like Varnish and several major CDNs. If ESI is not supported by the reverse proxy, the fragments will be rendered inline automatically.
See also Symfony’s documentation on Edge Side Includes.