Upcoming changes for action requests/controllers and static caching in Craft CMS 4.14 and 5.6

James White
8 min readJan 1, 2025

--

A small but important change is being made for Craft CMS 4.14 and 5.6 releases, which will send cache related HTTP headers on action controllers to essentially disable any caching of such requests. If you use static caching, this is something to be aware of as it might change your approach depending on your current caching policy.

The background

The original reason for this change was raised from the fact that the health-check controller endpoint that has been around since Craft CMS 3.5.6, was possible to be cached by static caching solutions such as the Cloudflare static caching used by Craft Cloud, breaking the purpose of receiving a valid 200 OK response to detect application readiness. Instead, you could end up with a 304 response, which is just serving the same 200 OK response captured previously back to you from a cache. Not ideal.

The original proposed solution was simply to send the required cache headers to disable this on this controller action itself, however it raised a bigger question. Should action controllers be cached by default in the first place?

Brandon Kelly believes this shouldn’t be the case and pending the next major release of Craft CMS for version 4 and 5, Craft CMS itself will send the following HTTP headers on all action controllers and control panel requests going forward:

Cache-Control: no-cache, no-store, must-revalidate
Expires: 0
Pragma: no-cache

This will essentially prevent caching of action controller responses with any static caching solution that may be in use. If you don’t use static caching, these headers being present won’t change or do any harm, they are processed by static caching solutions to determine what to do with a request if it is eligible for caching. Sending these headers makes the request ineligible for caching.

The rationale for not caching action controllers

Since reporting the original issue with static caching and the health check endpoint it has opened up a bigger question around should action controllers be cached by default in the first place. Craft CMS itself, plugins and modules will all provide controllers for a variety of different requirements and reasons, the volume of controllers that exist across plugins/modules outside of Craft CMS itself, how many of those are potentially not designed around being cached? The ultimate answer to this based on the views expressed in the discussion it is probably not a good idea. There are concerns around static caching causing unexpected behaviour or issues which aren’t immediately obvious, until you dive into the deeper inner workings. Plugin developers may not be necessarily considering this factor and not expect such behaviour as Craft CMS itself does not employ any static caching solution itself, instead it is left to other plugins such as the Blitz plugin or implementing it through your hosting infrastructure with services like Cloudflare or similar, which is what Craft Cloud does.

There are some examples of controllers being cached causing issues where it was not expected or intended:

The rationale for caching action controller requests

It would be easy to say caching action controllers is a bug and shouldn’t be happening, but I don’t think it is as simple as that. There are legitimate scenarios where you might want to cache an action controller response. After all controllers often return some form of web response, which could range from very small to larger data such as XML/RSS feeds or a JSON response of objects. Sure, you can do this in Twig but formatting and overhead is sometimes the issue.

If such data requires a query that has some overhead or is based on an element type like an entry, there’s benefit to having that statically cached and then have your static cache refreshed based on when an element part of that query is modified in some way to cause a cache invalidation. Rather than constantly serving the content from the database or setting a fixed cache time, which could potentially cause stale content.

It is a careful balance of caching when suitable rather than being quite general and letting any 200 GET response regardless of the type of request be cached. The decision has been made the best solution is opt-in caching for action controllers.

Opt-in caching for action controllers going forward

The pending update in 4.14 and 5.6 essentially makes action controllers an opt-in cache scenario now, rather than opt-out, which is what the Craft Cloud static caching behaviour has previously been designed around.

Within the main Web Application class of Craft CMS, these headers are always set regardless of what controller is being called. By default, all action controllers will send the typical “no cache” headers.

// Set no-cache headers for all action and CP requests
if ($request->getIsActionRequest() || $request->getIsCpRequest()) {
$response->setNoCacheHeaders();
}

Plugins and custom modules can override those headers, by setting explicit cache headers in any controller action.

<?php

namespace modules\examplemodule\controllers;

use craft\web\Controller;
use craft\web\Response;

class ExampleController extends Controller
{
public function actionIndex(): Response
{
$this->response->setCacheHeaders();
return $this->asJson('This response can be cached!');
}
}

You can also set cache headers in the beforeAction function, if you want to apply this across multiple actions under a single controller to save repeating the setCacheHeaders() method in each action.

The default cache time is 1 year expressed in seconds as 31536000. The idea being that your static cache will be invalidated long before you reach that time, so a long duration is set by default. You can set your own value if you wish e.g. 86400 which would be 24 hours.

Craft Cloud caching behaviour changes

This change being made directly in Craft CMS itself now has an impact on the static caching behaviour and documentation of Craft Cloud.

Currently the static caching behaviour is described as:

By default, only 200-level GET responses are candidates for static caching; redirection and errors (300-level and higher) are not cached, unless you explicitly opt in.

This is now only applicable for site requests i.e. your front end. Action controllers will now be excluded by default by Craft CMS itself, regardless of if the controller is from Craft CMS or a plugin/module.

It is important to note, that Craft Cloud only caches web responses that were from GET requests that return the 200 OK status. All other status codes and request types such as POST are excluded.

The opt-in guidance remains valid where you can set the headers in Twig or PHP, but this is now going to be more important going forward as you’ll need to opt-in. In a controller you can use:

$this->response->setCacheHeaders();

If outside of a controller but influencing a request in some way e.g. a plugin/module, you can use the response component:

Craft::$app->getResponse()->setCacheHeaders();

Something like this may be beneficial if calling from a plugin/module init class or similar.

Note: The setCacheHeaders() method is only valid since Craft CMS 4.10 and 5.2 releases. If you are below these versions, you’d need to set the Cache-Control, Expires and Pragma HTTP headers individually.

Bypassing static caching

Static caching is great but sometimes it might get in the way of debugging/development. You might want to have your controller or front-end cached in production but perhaps have this disabled in other contexts. To save having to apply this to individual places, you can do something like this in a plugin/module init() function.

$requestService = Craft::$app->getRequest();

// Not required if running Craft CMS 4.14+ or 5.6+ as it will be included by default
if ($requestService->getIsActionRequest() || $requestService->getIsCpRequest()) {
Craft::$app->getResponse()->setNoCacheHeaders();
}

// Disable static caching on all site requests when dev mode is enabled on Craft Cloud
if (CloudHelper::isCraftCloud() && $requestService->getIsSiteRequest() && \craft\helpers\App::devMode()) {
Craft::$app->getResponse()->setNoCacheHeaders();
}

Here we are first applying the upcoming no cache HTTP header behaviour on action controllers. Control Panel requests are included here as well, but static caching shouldn’t be touching control panel requests, so it is more for safety.

Next if you are running on Craft Cloud, you can build a condition using the Craft Cloud Yii extension helper that determines if the environment is Craft Cloud (as you are unlikely doing static caching on your dev environment), followed by the type of request and finally if dev mode is enabled. Rather than specifically targeting an environment value, the dev mode flag allows for technically disabling static caching on any environment when dev mode is enabled, but for many reasons you should never run dev mode on a production environment.

If you are doing opt-in caching in a controller, you may want to wrap your setCacheHeaders() call with a devMode check to be consistent with the above logic.

<?php

namespace modules\examplemodule\controllers;

use craft\helpers\App;
use craft\web\Response;
use craft\web\Controller;

class ExampleController extends Controller
{
public function actionIndex(): Response
{
if (!App::devMode()) {
$this->response->setCacheHeaders();
}

return $this->asJson('This response can be cached when dev mode is disabled!');
}
}

By doing this, you set your cache policy for when dev mode is disabled, but when it is enabled, the no cache headers are being set on all action controllers by default.

Modifying the controller of a plugin/module

One other scenario is wanting to modify a controller you might not directly control e.g. another plugin. One option is to contact the plugin developer or make a pull request, the other is using a Yii event. Using our example controller code, the event can be used as below:

use Craft;
use craft\base\ActionEvent;
use craft\base\Event;
use craft\web\Controller;
use modules\examplemodule\controllers\ExampleController;

Event::on(Controller::class, Controller::EVENT_BEFORE_ACTION, function (ActionEvent $actionEvent) {
if ($actionEvent->action->controller instanceof ExampleController)
$actionEvent->action->id == 'example-action-id') {

// Example: Disable CSRF on this controller action for POST requests
Craft::$app->controller->enableCsrfValidation = false;

// Example: Send cache headers on this action
Craft::$app->getResponse()->sendCacheHeaders();
}
});

This event can be run in a plugin/module init function, we check the controller is an instance of the controller we are interested in, then checking the action ID value. We do an instanceof check to ensure it is the controller we are wanting to modify, as there is a possibility that two different controllers could have the same action ID value leading to potentially modifying the wrong controller.

Share your thoughts

This change ultimately makes static caching less likely to interfere with plugins or custom modules controllers, while still allowing control of the behaviour. If you have thoughts on the subject, chime in on the Craft Cloud discussion, this change is pending a new release and therefore is not released yet.

--

--

James White
James White

Written by James White

I'm a web developer, but also like writing about technical networking and security related topics, because I'm a massive nerd!

No responses yet