8000 feature #59301 [Cache][HttpKernel] Add a `noStore` argument to the `#… · symfony/symfony@78648f0 · GitHub
[go: up one dir, main page]

Skip to content

Commit 78648f0

Browse files
committed
feature #59301 [Cache][HttpKernel] Add a noStore argument to the # attribute (smnandre)
This PR was squashed before being merged into the 7.3 branch. Discussion ---------- [Cache][HttpKernel] Add a `noStore` argument to the `#` attribute | Q | A | ------------- | --- | Branch? | 7.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | Fix #... | License | MIT This PR introduces a `noStore` argument to the `#[Cache]` attribute, allowing controllers to easily set the [no-store](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2.2.3) directive. ```php use Symfony\Component\HttpKernel\Attribute\Cache; #[Cache(noStore: true)] final class MyController { public function __invoke(): Response { // This response will NOT be stored in ANY cache ``` When set to `true`, it also supersedes the `public` / `private` value. --- I recently encountered issues with the back-forward cache ([bfcache](https://web.dev/articles/bfcache)), a browser feature that stores entire pages in memory to make navigating back and forth faster. It can cause problems when pages rely on JavaScript initialization, dynamic content, or state-changing resources. For example, an edit form might reappear after submission just by hitting “Back” (even after a redirection), with no HTTP request being triggered—leading to unexpected behavior and frustrating the user. Standard cache headers like `Cache-Control: no-cache` don’t stop this behavior. The _**only**_ reliable way to disable the bfcache across all major browsers is by using the `no-store` directive. ```php use Symfony\Component\HttpKernel\Attribute\Cache; final class MyController { #[Cache(public: false)] public function private(): Response { // ❌ This page can (and probably will) be cached in the browser bfc } #[Cache(noStore: true)] public function notStored(): Response { // ✅ This page will NOT be cached -- not even in the browser bfc } } ``` The [HTTP cache documentation](https://symfony.com/doc/current/http_cache.html) states that all options available for the `Response::setCache()` method can also be used with the `#[Cache]` attribute. However, the `no-store` option is currently missing. > [!NOTE] > This is a very "raw" implementation.. not sure about it or potential consequences I might not have considered... but I wanted to start the discussion :) --- **Resources:** * [Understanding bfcache (web.dev)](https://web.dev/articles/bfcache) * [Minimizing the impact of no-store on bfcache (web.dev)](https://web.dev/articles/bfcache#minimize-no-store) * [MDN Glossary: bfcache](https://developer.mozilla.org/en-US/docs/Glossary/bfcache) * [RFC7234 - Storing Responses in Caches](https://datatracker.ietf.org/doc/html/rfc7234#section-3) * [RFC7234 - no-store](https://datatracker.ietf.org/doc/html/rfc7234#section-5.2.2.3) Commits ------- ecc8c33 [Cache][HttpKernel] Add a `noStore` argument to the `#` attribute
2 parents ec3f12b + ecc8c33 commit 78648f0

File tree

3 files changed

+68
-0
lines changed

3 files changed

+68
-0
lines changed

src/Symfony/Component/HttpKernel/Attribute/Cache.php

+12
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ public function __construct(
102102
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
103103
*/
104104
public int|string|null $staleIfError = null,
105+
106+
/**
107+
* Add the "no-store" Cache-Control directive when set to true.
108+
*
109+
* This directive indicates that no part of the response can be cached
110+
* in any cache (not in a shared cache, nor in a private cache).
111+
*
112+
* Supersedes the "$public" and "$smaxage" values.
113+
*
114+
* @see https://datatracker.ietf.org/doc/html/rfc7234#section-5.2.2.3
115+
*/
116+
public ?bool $noStore = null,
105117
) {
106118
}
107119
}

src/Symfony/Component/HttpKernel/EventListener/CacheAttributeListener.php

+9
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,15 @@ public function onKernelResponse(ResponseEvent $event): void
163163
if (false === $cache->public) {
164164
$response->setPrivate();
165165
}
166+
167+
if (true === $cache->noStore) {
168+
$response->setPrivate();
169+
$response->headers->addCacheControlDirective('no-store');
170+
}
171+
172+
if (false === $cache->noStore) {
173+
$response->headers->removeCacheControlDirective('no-store');
174+
}
166175
}
167176
}
168177

src/Symfony/Component/HttpKernel/Tests/EventListener/CacheAttributeListenerTest.php

+47
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,50 @@ public function testResponseIsPrivateIfConfigurationIsPublicFalse()
9191
$this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
9292
}
9393

94+
public function testResponseIsPublicIfConfigurationIsPublicTrueNoStoreFalse()
95+
{
96+
$request = $this->createRequest(new Cache(public: true, noStore: false));
97+
98+
$this->listener->onKernelResponse($this->createEventMock($request, $this->response));
99+
100+
$this->assertTrue($this->response->headers->hasCacheControlDirective('public'));
101+
$this->assertFalse($this->response->headers->hasCacheControlDirective('private'));
102+
$this->assertFalse($this->response->headers->hasCacheControlDirective('no-store'));
103+
}
104+
105+
public function testResponseIsPrivateIfConfigurationIsPublicTrueNoStoreTrue()
106+
{
107+
$request = $this->createRequest(new Cache(public: true, noStore: true));
108+
109+
$this->listener->onKernelResponse($this->createEventMock($request, $this->response));
110+
111+
$this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
112+
$this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
113+
$this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
114+
}
115+
116+
public function testResponseIsPrivateNoStoreIfConfigurationIsNoStoreTrue()
117+
{
118+
$request = $this->createRequest(new Cache(noStore: true));
119+
120+
$this->listener->onKernelResponse($this->createEventMock($request, $this->response));
121+
122+
$this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
123+
$this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
124+
$this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
125+
}
126+
127+
public function testResponseIsPrivateIfSharedMaxAgeSetAndNoStoreIsTrue()
128+
{
129+
$request = $this->createRequest(new Cache(smaxage: 1, noStore: true));
130+
131+
$this->listener->onKernelResponse($this->createEventMock($request, $this->response));
132+
133+
$this->assertFalse($this->response->headers->hasCacheControlDirective('public'));
134+
$this->assertTrue($this->response->headers->hasCacheControlDirective('private'));
135+
$this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
136+
}
137+
94138
public function testResponseVary()
95139
{
96140
$vary = ['foobar'];
@@ -132,6 +176,7 @@ public function testAttributeConfigurationsAreSetOnResponse()
132176
$this->assertFalse($this->response->headers->hasCacheControlDirective('max-stale'));
133177
$this->assertFalse($this->response->headers->hasCacheControlDirective('stale-while-revalidate'));
134178
$this->assertFalse($this->response->headers->hasCacheControlDirective('stale-if-error'));
179+
$this->assertFalse($this->response->headers->hasCacheControlDirective('no-store'));
135180

136181
$this->request->attributes->set('_cache', [new Cache(
137182
expires: 'tomorrow',
@@ -140,6 +185,7 @@ public function testAttributeConfigurationsAreSetOnResponse()
140185
maxStale: '5',
141186
staleWhileRevalidate: '6',
142187
staleIfError: '7',
188+
noStore: true,
143189
)]);
144190

145191
$this->listener->onKernelResponse($this->event);
@@ -149,6 +195,7 @@ public function testAttributeConfigurationsAreSetOnResponse()
149195
$this->assertSame('5', $this->response->headers->getCacheControlDirective('max-stale'));
150196
$this->assertSame('6', $this->response->headers->getCacheControlDirective('stale-while-revalidate'));
151197
$this->assertSame('7', $this->response->headers->getCacheControlDirective('stale-if-error'));
198+
$this->assertTrue($this->response->headers->hasCacheControlDirective('no-store'));
152199
$this->assertInstanceOf(\DateTimeInterface::class, $this->response->getExpires());
153200
}
154201

0 commit comments

Comments
 (0)
0