Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit e993a98

Browse filesBrowse files
[HttpKernel] Add #[Cache] to describe the default HTTP cache headers on controllers
1 parent 2633877 commit e993a98
Copy full SHA for e993a98

File tree

6 files changed

+630
-0
lines changed
Filter options

6 files changed

+630
-0
lines changed

‎src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php
+5Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use Symfony\Component\HttpKernel\Controller\ArgumentResolver\VariadicValueResolver;
2525
use Symfony\Component\HttpKernel\Controller\ErrorController;
2626
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadataFactory;
27+
use Symfony\Component\HttpKernel\EventListener\CacheAttributeListener;
2728
use Symfony\Component\HttpKernel\EventListener\DisallowRobotsIndexingListener;
2829
use Symfony\Component\HttpKernel\EventListener\ErrorListener;
2930
use Symfony\Component\HttpKernel\EventListener\LocaleListener;
@@ -117,5 +118,9 @@
117118
])
118119
->tag('kernel.event_subscriber')
119120
->tag('monolog.logger', ['channel' => 'request'])
121+
122+
->set('controller.cache_attribute_listener', CacheAttributeListener::class)
123+
->tag('kernel.event_subscriber')
124+
120125
;
121126
};
+84Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\Attribute;
13+
14+
/**
15+
* Describes the default HTTP cache headers on controllers.
16+
*
17+
* @author Fabien Potencier <fabien@symfony.com>
18+
*/
19+
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
20+
class Cache
21+
{
22+
public function __construct(
23+
/**
24+
* The expiration date as a valid date for the strtotime() function.
25+
*/
26+
public ?string $expires = null,
27+
28+
/**
29+
* The number of seconds that the response is considered fresh by a private
30+
* cache like a web browser.
31+
*/
32+
public int|string|null $maxage = null,
33+
34+
/**
35+
* The number of seconds that the response is considered fresh by a public
36+
* cache like a reverse proxy cache.
37+
*/
38+
public int|string|null $smaxage = null,
39+
40+
/**
41+
* Whether the response is public or not.
42+
*/
43+
public ?bool $public = null,
44+
45+
/**
46+
* Whether or not the response must be revalidated.
47+
*/
48+
public bool $mustRevalidate = false,
49+
50+
/**
51+
* Additional "Vary:"-headers.
52+
*/
53+
public array $vary = [],
54+
55+
/**
56+
* An expression to compute the Last-Modified HTTP header.
57+
*/
58+
public ?string $lastModified = null,
59+
60+
/**
61+
* An expression to compute the ETag HTTP header.
62+
*/
63+
public ?string $etag = null,
64+
65+
/**
66+
* max-stale Cache-Control header
67+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
68+
*/
69+
public int|string|null $maxStale = null,
70+
71+
/**
72+
* stale-while-revalidate Cache-Control header
73+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
74+
*/
75+
public int|string|null $staleWhileRevalidate = null,
76+
77+
/**
78+
* stale-if-error Cache-Control header
79+
* It can be expressed in seconds or with a relative time format (1 day, 2 weeks, ...).
80+
*/
81+
public int|string|null $staleIfError = null,
82+
) {
83+
}
84+
}

‎src/Symfony/Component/HttpKernel/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/HttpKernel/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ CHANGELOG
66

77
* Add constructor argument `bool $catchThrowable` to `HttpKernel`
88
* Add `ControllerEvent::getAttributes()` to handle attributes on controllers
9+
* Add `#[Cache]` to describe the default HTTP cache headers on controllers
910

1011
6.1
1112
---
+186Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\HttpKernel\EventListener;
13+
14+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
16+
use Symfony\Component\HttpFoundation\Response;
17+
use Symfony\Component\HttpKernel\Attribute\Cache;
18+
use Symfony\Component\HttpKernel\Event\ControllerEvent;
19+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20+
use Symfony\Component\HttpKernel\KernelEvents;
21+
22+
/**
23+
* Handles HTTP cache headers configured via the Cache attribute.
24+
*
25+
* @author Fabien Potencier <fabien@symfony.com>
26+
*/
27+
class CacheAttributeListener implements EventSubscriberInterface
28+
{
29+
private ?ExpressionLanguage $expressionLanguage;
30+
private \SplObjectStorage $lastModified;
31+
private \SplObjectStorage $etags;
32+
33+
public function __construct(ExpressionLanguage $expressionLanguage = null)
34+
{
35+
$this->expressionLanguage = $expressionLanguage;
36+
$this->lastModified = new \SplObjectStorage();
37+
$this->etags = new \SplObjectStorage();
38+
}
39+
40+
/**
41+
* Handles HTTP validation headers.
42+
*/
43+
public function onKernelController(ControllerEvent $event)
44+
{
45+
$request = $event->getRequest();
46+
47+
if (!\is_array($attributes = $request->attributes->get('_cache') ?? $event->getAttributes()[Cache::class] ?? [])) {
48+
return;
49+
}
50+
51+
$request->attributes->set('_cache', $attributes);
52+
$response = null;
53+
$lastModified = null;
54+
$etag = null;
55+
56+
/** @var Cache[] $attributes */
57+
foreach ($attributes as $cache) {
58+
if (null !== $cache->lastModified) {
59+
$lastModified = $this->getExpressionLanguage()->evaluate($cache->lastModified, $request->attributes->all());
60+
($response ??= new Response())->setLastModified($lastModified);
61+
}
62+
63+
if (null !== $cache->etag) {
64+
$etag = hash('sha256', $this->getExpressionLanguage()->evaluate($cache->etag, $request->attributes->all()));
65+
($response ??= new Response())->setEtag($etag);
66+
}
67+
}
68+
69+
if ($response?->isNotModified($request)) {
70+
$event->setController(static fn () => $response);
71+
$event->stopPropagation();
72+
} else {
73+
if (null !== $etag) {
74+
$this->etags[$request] = $etag;
75+
}
76+
if (null !== $lastModified) {
77+
$this->lastModified[$request] = $lastModified;
78+
}
79+
}
80+
}
81+
82+
/**
83+
* Modifies the response to apply HTTP cache headers when needed.
84+
*/
85+
public function onKernelResponse(ResponseEvent $event)
86+
{
87+
$request = $event->getRequest();
88+
89+
/** @var Cache[] $attributes */
90+
if (!\is_array($attributes = $request->attributes->get('_cache'))) {
91+
return;
92+
}
93+
$response = $event->getResponse();
94+
95+
// http://tools.ietf.org/html/draft-ietf-httpbis-p4-conditional-12#section-3.1
96+
if (!\in_array($response->getStatusCode(), [200, 203, 300, 301, 302, 304, 404, 410])) {
97+
unset($this->lastModified[$request]);
98+
unset($this->etags[$request]);
99+
100+
return;
101+
}
102+
103+
if (isset($this->lastModified[$request]) && !$response->headers->has('Last-Modified')) {
104+
$response->setLastModified($this->lastModified[$request]);
105+
}
106+
107+
if (isset($this->etags[$request]) && !$response->headers->has('Etag')) {
108+
$response->setEtag($this->etags[$request]);
109+
}
110+
111+
unset($this->lastModified[$request]);
112+
unset($this->etags[$request]);
113+
$hasVary = $response->headers->has('Vary');
114+
115+
foreach (array_reverse($attributes) as $cache) {
116+
if (null !== $cache->smaxage && !$response->headers->hasCacheControlDirective('s-maxage')) {
117+
$response->setSharedMaxAge($this->toSeconds($cache->smaxage));
118+
}
119+
120+
if ($cache->mustRevalidate) {
121+
$response->headers->addCacheControlDirective('must-revalidate');
122+
}
123+
124+
if (null !== $cache->maxage && !$response->headers->hasCacheControlDirective('max-age')) {
125+
$response->setMaxAge($this->toSeconds($cache->maxage));
126+
}
127+
128+
if (null !== $cache->maxStale && !$response->headers->hasCacheControlDirective('max-stale')) {
129+
$response->headers->addCacheControlDirective('max-stale', $this->toSeconds($cache->maxStale));
130+
}
131+
132+
if (null !== $cache->staleWhileRevalidate && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) {
133+
$response->headers->addCacheControlDirective('stale-while-revalidate', $this->toSeconds($cache->staleWhileRevalidate));
134+
}
135+
136+
if (null !== $cache->staleIfError && !$response->headers->hasCacheControlDirective('stale-if-error')) {
137+
$response->headers->addCacheControlDirective('stale-if-error', $this->toSeconds($cache->staleIfError));
138+
}
139+
140+
if (null !== $cache->expires && !$response->headers->has('Expires')) {
141+
$response->setExpires(new \DateTimeImmutable('@'.strtotime($cache->expires, time())));
142+
}
143+
144+
if (!$hasVary && $cache->vary) {
145+
$response->setVary($cache->vary, false);
146+
}
147+
}
148+
149+
foreach ($attributes as $cache) {
150+
if (true === $cache->public) {
151+
$response->setPublic();
152+
}
153+
154+
if (false === $cache->public) {
155+
$response->setPrivate();
156+
}
157+
}
158+
}
159+
160+
public static function getSubscribedEvents(): array
161+
{
162+
return [
163+
KernelEvents::CONTROLLER => 'onKernelController',
164+
KernelEvents::RESPONSE => 'onKernelResponse',
165+
];
166+
}
167+
168+
private function getExpressionLanguage(): ExpressionLanguage
169+
{
170+
if (!$this->expressionLanguage && !class_exists(ExpressionLanguage::class)) {
171+
throw new \LogicException('Unable to use expressions as the Symfony ExpressionLanguage component is not installed. Try running "composer require symfony/expression-language".');
172+
}
173+
174+
return $this->expressionLanguage ??= new ExpressionLanguage();
175+
}
176+
177+
private function toSeconds(int|string $time): int
178+
{
179+
if (!is_numeric($time)) {
180+
$now = time();
181+
$time = strtotime($time, $now) - $now;
182+
}
183+
184+
return $time;
185+
}
186+
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.