From 8b3796a7301d6a75cdee7c000f304027a10daf4a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 16 Jul 2026 20:08:05 -0400 Subject: [PATCH 01/15] Revalidate purged static cache URLs --- README.md | 27 +++++ src/Module.php | 10 +- src/StaticCache.php | 147 ++++++++++++++++++++++++-- src/events/PurgeEvent.php | 20 ++++ tests/unit/StaticCacheTest.php | 188 ++++++++++++++++++++++++++++++++- 5 files changed, 375 insertions(+), 17 deletions(-) create mode 100644 src/events/PurgeEvent.php diff --git a/README.md b/README.md index cf519488..e71e4da1 100644 --- a/README.md +++ b/README.md @@ -217,5 +217,32 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl | `useAssetCdn` | `boolean` | Whether or not to enable the CDN for uploaded assets. | | `useArtifactCdn` | `boolean` | Whether or not to enable the CDN for build artifacts and asset bundles. | | `staticCacheDuration` | `int` | The default duration, in seconds, to statically cache requests. | + > [!TIP] > These options can also be set via environment overrides beginning with `CRAFT_CLOUD_`. + +### Static cache + +Static cache purge events can be configured through Yii’s dependency injection container in `config/app.php`: + +```php +use craft\cloud\events\PurgeEvent; +use craft\cloud\StaticCache; + +return [ + 'container' => [ + 'definitions' => [ + StaticCache::class => [ + 'class' => StaticCache::class, + 'on ' . StaticCache::EVENT_BEFORE_PURGE => static function(PurgeEvent $event): void { + if ($event->element?->uri === 'health-check') { + $event->isValid = false; + } + }, + ], + ], + ], +]; +``` + +When an element purge proceeds, its deduplicated public site URLs are included in tag-based gateway API requests as the optional `fetchUrls` field. After a successful purge, the gateway asynchronously fetches those URLs to repopulate the cache. Drafts, revisions, disabled or unroutable elements, and non-HTTP(S) URLs are omitted. Canceling the purge also prevents those URLs from being fetched. diff --git a/src/Module.php b/src/Module.php index 47cb30dd..cf6b2435 100644 --- a/src/Module.php +++ b/src/Module.php @@ -55,7 +55,7 @@ class Module extends \yii\base\Module implements \yii\base\BootstrapInterface */ private const MAX_EXECUTION_SECONDS_CLI = 900 - 10; - private Config $_config; + private Config $config; public static function log(int $level, string $message, array $context = []): void { @@ -227,8 +227,8 @@ private function isRemoteCloudAsset(Asset $asset): bool public function getConfig(): Config { - if (isset($this->_config)) { - return $this->_config; + if (isset($this->config)) { + return $this->config; } $fileConfig = Craft::$app->getConfig()->getConfigFromFile($this->id); @@ -238,9 +238,9 @@ public function getConfig(): Config ? Craft::createObject(['class' => Config::class] + $fileConfig) : $fileConfig; - $this->_config = Craft::configure($config, App::envConfig(Config::class, 'CRAFT_CLOUD_')); + $this->config = Craft::configure($config, App::envConfig(Config::class, 'CRAFT_CLOUD_')); - return $this->_config; + return $this->config; } protected function bootstrapCloud(ConsoleApplication|WebApplication $app): void diff --git a/src/StaticCache.php b/src/StaticCache.php index 48af5002..4eeaa91d 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -4,6 +4,7 @@ use Craft; use craft\base\ElementInterface; +use craft\cloud\events\PurgeEvent; use craft\events\ElementEvent; use craft\events\InvalidateElementCachesEvent; use craft\events\RegisterCacheOptionsEvent; @@ -39,6 +40,11 @@ class StaticCache extends \yii\base\Component { public const CDN_PREFIX = 'cdn:'; + /** + * @event PurgeEvent The event that is triggered before static cache tags are purged. + */ + public const EVENT_BEFORE_PURGE = 'beforePurge'; + /** * Cloudflare's documented Cache-Tag limits. * @@ -50,12 +56,14 @@ class StaticCache extends \yii\base\Component private ?int $cacheDuration = null; private Collection $tags; private Collection $tagsToPurge; + private Collection $fetchUrls; private bool $collectingCacheInfo = false; public function init(): void { $this->tags = Collection::make(); $this->tagsToPurge = Collection::make(); + $this->fetchUrls = Collection::make(); } public function registerEventHandlers(): void @@ -105,7 +113,10 @@ public function registerEventHandlers(): void Craft::$app->onAfterRequest(function() { if ($this->tagsToPurge->isNotEmpty()) { try { - $this->purgeTags(...$this->tagsToPurge); + $this->sendPurgeTags( + $this->tagsToPurge, + $this->fetchUrls->all(), + ); } catch (\Throwable $e) { // TODO: log exception once output payload isn't a concern Module::error('Failed to purge tags after request'); @@ -166,6 +177,7 @@ private function handleInvalidateElementCaches(InvalidateElementCachesEvent $eve return; } + $tags = $this->preparePurgeTags($event->element ?? null, ...$tags); $this->tagsToPurge->push(...$tags); } @@ -196,12 +208,20 @@ public function purgeAll(): void public function purgeGateway(): void { - $this->tagsToPurge->push(Module::getInstance()->getConfig()->environmentId); + $tags = $this->preparePurgeTags( + null, + Module::getInstance()->getConfig()->environmentId, + ); + $this->tagsToPurge->push(...$tags); } public function purgeCdn(): void { - $this->tagsToPurge->push(self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId); + $tags = $this->preparePurgeTags( + null, + self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId, + ); + $this->tagsToPurge->push(...$tags); } private function purgeElementUri(ElementInterface $element): void @@ -217,7 +237,8 @@ private function purgeElementUri(ElementInterface $element): void : Path::new($uri)->withLeadingSlash()->withoutTrailingSlash(); $environmentId = Module::getInstance()->getConfig()->environmentId; - $this->tagsToPurge->prepend("$environmentId:$uri"); + $tags = $this->preparePurgeTags($element, "$environmentId:$uri"); + $this->tagsToPurge = $tags->concat($this->tagsToPurge); } private function addCacheHeadersToWebResponse(): void @@ -245,6 +266,19 @@ public function purgeTags(string|StaticCacheTag ...$tags): void { $tags = Collection::make($tags); $response = Craft::$app->getResponse(); + + if ($response instanceof \craft\web\Response) { + $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); + $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); + } + + $tags = $this->preparePurgeTags(null, ...$tags); + $this->sendPurgeTags($tags); + } + + private function sendPurgeTags(Collection $tags, array $fetchUrls = []): void + { + $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; // Add any existing tags from the response headers @@ -259,7 +293,7 @@ public function purgeTags(string|StaticCacheTag ...$tags): void return; } - if ($isWebResponse) { + if ($isWebResponse && empty($fetchUrls)) { $tags = $this->truncateCacheTagsForHeader($tags); Module::info('Purging tags', [ @@ -276,16 +310,111 @@ public function purgeTags(string|StaticCacheTag ...$tags): void Module::info('Purging tags', [ 'tags' => $tags, + 'fetchUrls' => $fetchUrls, ]); - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + $payload = [ // Mapping to string because: https://github.com/laravel/framework/pull/54630 - RequestOptions::JSON => [ - 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), - ], + 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), + ]; + + if (!empty($fetchUrls)) { + $payload['fetchUrls'] = $fetchUrls; + } + + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => $payload, ]); } + private function preparePurgeTags( + ?ElementInterface $element, + string|StaticCacheTag ...$tags, + ): Collection { + $tags = $this->normalizeCacheTags(...$tags); + + if ($tags->isEmpty()) { + return $tags; + } + + $event = new PurgeEvent([ + 'tags' => $tags->values()->all(), + 'element' => $element, + ]); + $this->trigger(self::EVENT_BEFORE_PURGE, $event); + + if (!$event->isValid) { + return Collection::make(); + } + + $tags = $this->normalizeCacheTags(...$event->tags); + + if ($tags->isNotEmpty()) { + $this->queueFetchUrls($element); + } + + return $tags; + } + + private function queueFetchUrls(?ElementInterface $element): void + { + if ($element === null) { + return; + } + + $elements = Collection::make([$element]); + + if ($element->id && !ElementHelper::isDraftOrRevision($element)) { + try { + $elements->push(...$element::find() + ->id($element->id) + ->siteId('*') + ->status(null) + ->trashed(null) + ->all()); + } catch (\Throwable) { + // The triggering site variant can still be fetched. + } + } + + $this->fetchUrls = $this->fetchUrls + ->merge($elements->map(fn(ElementInterface $element) => $this->fetchUrl($element))->filter()) + ->unique() + ->values(); + } + + private function fetchUrl(ElementInterface $element): ?string + { + if ( + ElementHelper::isDraftOrRevision($element) || + !$element::hasUris() || + !$element->uri || + !$element->enabled || + !$element->getEnabledForSite() || + $element->archived + ) { + return null; + } + + try { + $site = $element->getSite(); + $url = $site->getEnabled() && $site->hasUrls && $element->getRoute() !== null + ? $element->getUrl() + : null; + } catch (\Throwable) { + return null; + } + + $parts = $url !== null ? parse_url($url) : false; + + return + is_array($parts) && + in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && + !empty($parts['host']) + ? $url + : null; + } + public function purgeUrlPrefixes(string ...$urlPrefixes): void { $urlPrefixes = Collection::make($urlPrefixes)->filter()->unique(); diff --git a/src/events/PurgeEvent.php b/src/events/PurgeEvent.php new file mode 100644 index 00000000..981d1b2e --- /dev/null +++ b/src/events/PurgeEvent.php @@ -0,0 +1,20 @@ +previousModule = Module::getInstance(); - Module::setInstance(new Module('cloud')); + $module = new Module('cloud'); + Module::setInstance($module); Craft::$app->getRequest()->setIsCpRequest(false); Craft::$app->getResponse()->clear(); Craft::$app->getResponse()->setStatusCode(200); - $this->environmentId = Module::getInstance()->getConfig()->environmentId; - Module::getInstance()->getConfig()->environmentId = '123-environment-id'; + $this->environmentId = $module->getConfig()->environmentId; + $module->getConfig()->environmentId = '123-environment-id'; + $module->getConfig()->signingKey = 'test-signing-key'; + $module->set('requestSigner', new class(function(RequestInterface $request) { + $this->gatewayRequest = $request; + }) extends RequestSigner { + public function __construct(private readonly \Closure $capture) + { + parent::__construct('test-signing-key'); + } + + public function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack + { + return new HandlerStack(function(RequestInterface $request) { + ($this->capture)($request); + + return Create::promiseFor(new Response(204)); + }); + } + }); $this->requestMethod = $_SERVER['REQUEST_METHOD'] ?? null; $_SERVER['REQUEST_METHOD'] = 'GET'; @@ -184,6 +211,119 @@ public function testPurgeTagsKeepExistingHeaderTags(): void ); } + public function testBeforePurgeEventCanCancelPurge(): void + { + $staticCache = new StaticCache(); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $this->assertContainsOnlyInstancesOf(StaticCacheTag::class, $event->tags); + $event->isValid = false; + }); + + $staticCache->purgeTags('first'); + + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventCanCancelExistingHeaderTags(): void + { + $staticCache = new StaticCache(); + Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'first'); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = false; + }); + + $staticCache->purgeTags(); + + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventCanReplaceTags(): void + { + $staticCache = new StaticCache(); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->tags = [StaticCacheTag::create('second')]; + }); + + $staticCache->purgeTags('first'); + + $this->assertSame( + 'second', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + + public function testBeforePurgeEventReceivesElement(): void + { + $staticCache = new StaticCache(); + $element = new Entry(['uri' => 'news']); + $purgeEvent = null; + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) use (&$purgeEvent) { + $purgeEvent = $event; + }); + + $this->purgeElementUri($staticCache, $element); + + $this->assertSame($element, $purgeEvent->element); + $this->assertSame('123-environment-id:/news', $purgeEvent->tags[0]->getValue()); + } + + public function testCancelledElementPurgeDoesNotQueueFetch(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = false; + }); + + $this->purgeElementUri($staticCache, $element); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); + $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + } + + public function testElementPurgeRequestIncludesPublicFetchUrls(): void + { + $staticCache = new StaticCache(); + $englishElement = new FetchableElement(['uri' => 'news']); + $englishElement->fetchUrl = 'https://example.com/news'; + $frenchElement = clone $englishElement; + $frenchElement->fetchUrl = 'https://example.com/fr/nouvelles'; + $relativeElement = clone $englishElement; + $relativeElement->fetchUrl = '/news'; + $privateElement = clone $englishElement; + $privateElement->isPublic = false; + $disabledElement = clone $englishElement; + $disabledElement->enabled = false; + + $this->purgeElementUri($staticCache, $englishElement); + $this->purgeElementUri($staticCache, $englishElement); + $this->purgeElementUri($staticCache, $frenchElement); + $this->purgeElementUri($staticCache, $relativeElement); + $this->purgeElementUri($staticCache, $privateElement); + $this->purgeElementUri($staticCache, $disabledElement); + $this->sendPendingPurgeTags($staticCache); + + $payload = json_decode( + (string) $this->gatewayRequest?->getBody(), + true, + flags: JSON_THROW_ON_ERROR, + ); + + $this->assertSame(['123-environment-id:/news'], $payload['tags']); + $this->assertSame([ + 'https://example.com/news', + 'https://example.com/fr/nouvelles', + ], $payload['fetchUrls']); + $this->assertFalse( + Craft::$app->getResponse()->getHeaders()->has(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + public function testExistingCacheTagHeaderIsSplit(): void { $staticCache = new StaticCache(); @@ -235,6 +375,32 @@ private function addCacheHeadersToWebResponse(StaticCache $staticCache): void $method->invoke($staticCache); } + private function purgeElementUri(StaticCache $staticCache, Entry $element): void + { + $method = new ReflectionMethod($staticCache, 'purgeElementUri'); + $method->setAccessible(true); + $method->invoke($staticCache, $element); + } + + private function sendPendingPurgeTags(StaticCache $staticCache): void + { + $method = new ReflectionMethod($staticCache, 'sendPurgeTags'); + $method->setAccessible(true); + $method->invoke( + $staticCache, + $this->collectionProperty($staticCache, 'tagsToPurge'), + $this->collectionProperty($staticCache, 'fetchUrls')->all(), + ); + } + + private function collectionProperty(StaticCache $staticCache, string $name): Collection + { + $property = new ReflectionProperty($staticCache, $name); + $property->setAccessible(true); + + return $property->getValue($staticCache); + } + private function setTags(StaticCache $staticCache, Collection $tags): void { $property = new ReflectionProperty($staticCache, 'tags'); @@ -242,3 +408,19 @@ private function setTags(StaticCache $staticCache, Collection $tags): void $property->setValue($staticCache, $tags); } } + +class FetchableElement extends Entry +{ + public ?string $fetchUrl = null; + public bool $isPublic = true; + + public function getUrl(): ?string + { + return $this->fetchUrl; + } + + protected function route(): array|string|null + { + return $this->isPublic ? 'test/route' : null; + } +} From 1d4e99c3f76ec15201e17de4e51210f5651f084f Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Thu, 16 Jul 2026 20:58:42 -0400 Subject: [PATCH 02/15] Fix Craft 4 element type narrowing --- src/StaticCache.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 4eeaa91d..6c4f7e43 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -3,6 +3,7 @@ namespace craft\cloud; use Craft; +use craft\base\Element; use craft\base\ElementInterface; use craft\cloud\events\PurgeEvent; use craft\events\ElementEvent; @@ -358,10 +359,11 @@ private function preparePurgeTags( private function queueFetchUrls(?ElementInterface $element): void { - if ($element === null) { + if (!$element instanceof Element) { return; } + /** @var Collection $elements */ $elements = Collection::make([$element]); if ($element->id && !ElementHelper::isDraftOrRevision($element)) { @@ -386,6 +388,7 @@ private function queueFetchUrls(?ElementInterface $element): void private function fetchUrl(ElementInterface $element): ?string { if ( + !$element instanceof Element || ElementHelper::isDraftOrRevision($element) || !$element::hasUris() || !$element->uri || From 0f543ef224836571143bc6da03fc403f711660b5 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 07:20:46 -0400 Subject: [PATCH 03/15] Apply purge event to queued header tags --- src/StaticCache.php | 3 ++- tests/unit/StaticCacheTest.php | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 6c4f7e43..44e132eb 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -284,8 +284,9 @@ private function sendPurgeTags(Collection $tags, array $fetchUrls = []): void // Add any existing tags from the response headers if ($isWebResponse) { - $tags->push(...$this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value)); + $headerTags = $this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value); $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); + $tags->push(...$this->preparePurgeTags(null, ...$headerTags)); } $tags = $this->normalizeCacheTags(...$tags); diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index c9acab46..34679304 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -289,6 +289,10 @@ public function testCancelledElementPurgeDoesNotQueueFetch(): void public function testElementPurgeRequestIncludesPublicFetchUrls(): void { $staticCache = new StaticCache(); + Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'cancelled'); + $staticCache->on(StaticCache::EVENT_BEFORE_PURGE, function(PurgeEvent $event) { + $event->isValid = $event->element !== null; + }); $englishElement = new FetchableElement(['uri' => 'news']); $englishElement->fetchUrl = 'https://example.com/news'; $frenchElement = clone $englishElement; From 5e56c922f1c02d882cf5fcb8f1583154edd32790 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 07:34:50 -0400 Subject: [PATCH 04/15] Skip trashed elements during cache revalidation --- README.md | 2 +- src/StaticCache.php | 1 + tests/unit/StaticCacheTest.php | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e71e4da1..3027d0b6 100644 --- a/README.md +++ b/README.md @@ -245,4 +245,4 @@ return [ ]; ``` -When an element purge proceeds, its deduplicated public site URLs are included in tag-based gateway API requests as the optional `fetchUrls` field. After a successful purge, the gateway asynchronously fetches those URLs to repopulate the cache. Drafts, revisions, disabled or unroutable elements, and non-HTTP(S) URLs are omitted. Canceling the purge also prevents those URLs from being fetched. +When an element purge proceeds, its deduplicated public site URLs are included in tag-based gateway API requests as the optional `fetchUrls` field. After a successful purge, the gateway asynchronously fetches those URLs to repopulate the cache. Drafts, revisions, deleted, disabled or unroutable elements, and non-HTTP(S) URLs are omitted. Canceling the purge also prevents those URLs from being fetched. diff --git a/src/StaticCache.php b/src/StaticCache.php index 44e132eb..88bd9220 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -395,6 +395,7 @@ private function fetchUrl(ElementInterface $element): ?string !$element->uri || !$element->enabled || !$element->getEnabledForSite() || + $element->trashed || $element->archived ) { return null; diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 34679304..ba7e82ed 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -303,6 +303,8 @@ public function testElementPurgeRequestIncludesPublicFetchUrls(): void $privateElement->isPublic = false; $disabledElement = clone $englishElement; $disabledElement->enabled = false; + $trashedElement = clone $englishElement; + $trashedElement->trashed = true; $this->purgeElementUri($staticCache, $englishElement); $this->purgeElementUri($staticCache, $englishElement); @@ -310,6 +312,7 @@ public function testElementPurgeRequestIncludesPublicFetchUrls(): void $this->purgeElementUri($staticCache, $relativeElement); $this->purgeElementUri($staticCache, $privateElement); $this->purgeElementUri($staticCache, $disabledElement); + $this->purgeElementUri($staticCache, $trashedElement); $this->sendPendingPurgeTags($staticCache); $payload = json_decode( From 14aff2bfd64281468d95f96c4edc349e77959685 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 08:33:29 -0400 Subject: [PATCH 05/15] Simplify cache revalidation URL collection --- src/StaticCache.php | 94 ++++++++++------------------------ tests/unit/StaticCacheTest.php | 2 +- 2 files changed, 27 insertions(+), 69 deletions(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 88bd9220..330337b2 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -116,7 +116,7 @@ public function registerEventHandlers(): void try { $this->sendPurgeTags( $this->tagsToPurge, - $this->fetchUrls->all(), + $this->fetchUrls, ); } catch (\Throwable $e) { // TODO: log exception once output payload isn't a concern @@ -277,7 +277,7 @@ public function purgeTags(string|StaticCacheTag ...$tags): void $this->sendPurgeTags($tags); } - private function sendPurgeTags(Collection $tags, array $fetchUrls = []): void + private function sendPurgeTags(Collection $tags, ?Collection $fetchUrls = null): void { $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; @@ -295,7 +295,7 @@ private function sendPurgeTags(Collection $tags, array $fetchUrls = []): void return; } - if ($isWebResponse && empty($fetchUrls)) { + if ($isWebResponse && !$fetchUrls?->isNotEmpty()) { $tags = $this->truncateCacheTagsForHeader($tags); Module::info('Purging tags', [ @@ -320,8 +320,11 @@ private function sendPurgeTags(Collection $tags, array $fetchUrls = []): void 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), ]; - if (!empty($fetchUrls)) { - $payload['fetchUrls'] = $fetchUrls; + if ($fetchUrls?->isNotEmpty()) { + $payload['fetchUrls'] = $fetchUrls + ->unique() + ->values() + ->all(); } Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ @@ -343,6 +346,7 @@ private function preparePurgeTags( 'tags' => $tags->values()->all(), 'element' => $element, ]); + $this->trigger(self::EVENT_BEFORE_PURGE, $event); if (!$event->isValid) { @@ -351,73 +355,27 @@ private function preparePurgeTags( $tags = $this->normalizeCacheTags(...$event->tags); - if ($tags->isNotEmpty()) { - $this->queueFetchUrls($element); - } - - return $tags; - } - - private function queueFetchUrls(?ElementInterface $element): void - { - if (!$element instanceof Element) { - return; - } - - /** @var Collection $elements */ - $elements = Collection::make([$element]); - - if ($element->id && !ElementHelper::isDraftOrRevision($element)) { - try { - $elements->push(...$element::find() - ->id($element->id) - ->siteId('*') - ->status(null) - ->trashed(null) - ->all()); - } catch (\Throwable) { - // The triggering site variant can still be fetched. - } - } - - $this->fetchUrls = $this->fetchUrls - ->merge($elements->map(fn(ElementInterface $element) => $this->fetchUrl($element))->filter()) - ->unique() - ->values(); - } - - private function fetchUrl(ElementInterface $element): ?string - { - if ( - !$element instanceof Element || - ElementHelper::isDraftOrRevision($element) || - !$element::hasUris() || - !$element->uri || - !$element->enabled || - !$element->getEnabledForSite() || - $element->trashed || - $element->archived - ) { - return null; - } - try { - $site = $element->getSite(); - $url = $site->getEnabled() && $site->hasUrls && $element->getRoute() !== null - ? $element->getUrl() - : null; + if ( + $tags->isNotEmpty() && + $element instanceof Element && + !ElementHelper::isDraftOrRevision($element) && + $element->enabled && + $element->getEnabledForSite() && + !$element->trashed && + !$element->archived && + $element->getRoute() !== null && + ($url = $element->getUrl()) !== null && + in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) && + parse_url($url, PHP_URL_HOST) + ) { + $this->fetchUrls->push($url); + } } catch (\Throwable) { - return null; + // The tags can still be purged without fetching the URL. } - $parts = $url !== null ? parse_url($url) : false; - - return - is_array($parts) && - in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && - !empty($parts['host']) - ? $url - : null; + return $tags; } public function purgeUrlPrefixes(string ...$urlPrefixes): void diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index ba7e82ed..b24ad0f5 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -396,7 +396,7 @@ private function sendPendingPurgeTags(StaticCache $staticCache): void $method->invoke( $staticCache, $this->collectionProperty($staticCache, 'tagsToPurge'), - $this->collectionProperty($staticCache, 'fetchUrls')->all(), + $this->collectionProperty($staticCache, 'fetchUrls'), ); } From f6e6793e43547c74ae6518ed75867321d77eae40 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 08:35:23 -0400 Subject: [PATCH 06/15] Skip fetches for disabled sites --- src/StaticCache.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/StaticCache.php b/src/StaticCache.php index 330337b2..2ec2c5de 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -364,6 +364,8 @@ private function preparePurgeTags( $element->getEnabledForSite() && !$element->trashed && !$element->archived && + $element->getSite()->getEnabled() && + $element->getSite()->hasUrls && $element->getRoute() !== null && ($url = $element->getUrl()) !== null && in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) && From 025086841d555dfb36d74d3ce3a1da15a65a38a1 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 08:45:22 -0400 Subject: [PATCH 07/15] Fall back to purge headers on API errors --- src/StaticCache.php | 17 ++++++++++++++--- tests/unit/StaticCacheTest.php | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 2ec2c5de..2db99521 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -327,9 +327,20 @@ private function sendPurgeTags(Collection $tags, ?Collection $fetchUrls = null): ->all(); } - Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ - RequestOptions::JSON => $payload, - ]); + try { + Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ + RequestOptions::JSON => $payload, + ]); + } catch (\Throwable $e) { + if ($isWebResponse) { + $this->setCacheTagHeader( + HeaderEnum::CACHE_PURGE_TAG->value, + $this->truncateCacheTagsForHeader($tags), + ); + } + + throw $e; + } } private function preparePurgeTags( diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index b24ad0f5..ba7c4a04 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -31,6 +31,7 @@ class StaticCacheTest extends Unit private ?string $environmentId = null; private ?Module $previousModule = null; private ?RequestInterface $gatewayRequest = null; + private ?\Throwable $gatewayException = null; protected function _before(): void { @@ -49,6 +50,10 @@ protected function _before(): void $module->getConfig()->signingKey = 'test-signing-key'; $module->set('requestSigner', new class(function(RequestInterface $request) { $this->gatewayRequest = $request; + + if ($this->gatewayException) { + throw $this->gatewayException; + } }) extends RequestSigner { public function __construct(private readonly \Closure $capture) { @@ -226,6 +231,26 @@ public function testBeforePurgeEventCanCancelPurge(): void ); } + public function testFailedFetchRequestFallsBackToPurgeHeader(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + $this->gatewayException = new \RuntimeException(); + + $this->purgeElementUri($staticCache, $element); + + try { + $this->sendPendingPurgeTags($staticCache); + } catch (\RuntimeException) { + } + + $this->assertSame( + '123-environment-id:/news', + Craft::$app->getResponse()->getHeaders()->get(HeaderEnum::CACHE_PURGE_TAG->value), + ); + } + public function testBeforePurgeEventCanCancelExistingHeaderTags(): void { $staticCache = new StaticCache(); From 98f6f712fb5bd8f9856fb741ccba4f4118b46e5a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 13:04:49 -0400 Subject: [PATCH 08/15] Clarify cache purge transport --- src/StaticCache.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 2db99521..cb594f95 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -281,6 +281,7 @@ private function sendPurgeTags(Collection $tags, ?Collection $fetchUrls = null): { $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; + $sendApiRequest = !$isWebResponse || $fetchUrls?->isNotEmpty(); // Add any existing tags from the response headers if ($isWebResponse) { @@ -295,7 +296,7 @@ private function sendPurgeTags(Collection $tags, ?Collection $fetchUrls = null): return; } - if ($isWebResponse && !$fetchUrls?->isNotEmpty()) { + if (!$sendApiRequest) { $tags = $this->truncateCacheTagsForHeader($tags); Module::info('Purging tags', [ From 517e3e4c4638c1770811f8cc693607a1446487d9 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 17 Jul 2026 13:19:10 -0400 Subject: [PATCH 09/15] Skip deleted element cache fetches --- src/StaticCache.php | 1 + tests/unit/StaticCacheTest.php | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/StaticCache.php b/src/StaticCache.php index cb594f95..7e61e4e2 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -375,6 +375,7 @@ private function preparePurgeTags( $element->enabled && $element->getEnabledForSite() && !$element->trashed && + $element->dateDeleted === null && !$element->archived && $element->getSite()->getEnabled() && $element->getSite()->hasUrls && diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index ba7c4a04..c2086d40 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -330,6 +330,8 @@ public function testElementPurgeRequestIncludesPublicFetchUrls(): void $disabledElement->enabled = false; $trashedElement = clone $englishElement; $trashedElement->trashed = true; + $deletedElement = clone $englishElement; + $deletedElement->dateDeleted = new \DateTime(); $this->purgeElementUri($staticCache, $englishElement); $this->purgeElementUri($staticCache, $englishElement); @@ -338,6 +340,7 @@ public function testElementPurgeRequestIncludesPublicFetchUrls(): void $this->purgeElementUri($staticCache, $privateElement); $this->purgeElementUri($staticCache, $disabledElement); $this->purgeElementUri($staticCache, $trashedElement); + $this->purgeElementUri($staticCache, $deletedElement); $this->sendPendingPurgeTags($staticCache); $payload = json_decode( From 5e71d7bc8d6dc0a51544334157320048009f697a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sat, 18 Jul 2026 08:03:49 -0400 Subject: [PATCH 10/15] Clarify static cache purge naming --- src/StaticCache.php | 22 +++++++++++++++++----- src/cli/controllers/UpController.php | 2 +- tests/unit/StaticCacheTest.php | 2 +- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/StaticCache.php b/src/StaticCache.php index 7e61e4e2..6f316549 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -114,7 +114,7 @@ public function registerEventHandlers(): void Craft::$app->onAfterRequest(function() { if ($this->tagsToPurge->isNotEmpty()) { try { - $this->sendPurgeTags( + $this->purgePreparedTags( $this->tagsToPurge, $this->fetchUrls, ); @@ -203,11 +203,11 @@ private function handleDeleteElement(ElementEvent $event): void public function purgeAll(): void { - $this->purgeGateway(); + $this->purgeOrigin(); $this->purgeCdn(); } - public function purgeGateway(): void + public function purgeOrigin(): void { $tags = $this->preparePurgeTags( null, @@ -216,6 +216,18 @@ public function purgeGateway(): void $this->tagsToPurge->push(...$tags); } + /** + * @deprecated in 3.5.0. Use [[purgeOrigin()]] instead. + */ + public function purgeGateway(): void + { + Craft::$app->getDeprecator()->log( + __METHOD__, + '`purgeGateway()` has been deprecated. Use `purgeOrigin()` instead.', + ); + $this->purgeOrigin(); + } + public function purgeCdn(): void { $tags = $this->preparePurgeTags( @@ -274,10 +286,10 @@ public function purgeTags(string|StaticCacheTag ...$tags): void } $tags = $this->preparePurgeTags(null, ...$tags); - $this->sendPurgeTags($tags); + $this->purgePreparedTags($tags); } - private function sendPurgeTags(Collection $tags, ?Collection $fetchUrls = null): void + private function purgePreparedTags(Collection $tags, ?Collection $fetchUrls = null): void { $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; diff --git a/src/cli/controllers/UpController.php b/src/cli/controllers/UpController.php index e5b68767..10c45c7d 100644 --- a/src/cli/controllers/UpController.php +++ b/src/cli/controllers/UpController.php @@ -28,7 +28,7 @@ public function actionIndex(): int if (Craft::$app->getIsInstalled()) { $this->mustRun('/up'); - Module::getInstance()->getStaticCache()->purgeGateway(); + Module::getInstance()->getStaticCache()->purgeOrigin(); } $event = new CancelableEvent(); diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index c2086d40..329e8079 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -419,7 +419,7 @@ private function purgeElementUri(StaticCache $staticCache, Entry $element): void private function sendPendingPurgeTags(StaticCache $staticCache): void { - $method = new ReflectionMethod($staticCache, 'sendPurgeTags'); + $method = new ReflectionMethod($staticCache, 'purgePreparedTags'); $method->setAccessible(true); $method->invoke( $staticCache, From 1cfd4f818693df249f3ce23504cedcea0a5b0a15 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sat, 18 Jul 2026 23:21:39 -0400 Subject: [PATCH 11/15] Simplify static cache revalidation --- README.md | 2 +- src/StaticCache.php | 70 +++++++++++++--------------------- tests/unit/StaticCacheTest.php | 68 +++++++++++++++++---------------- 3 files changed, 63 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 3027d0b6..34dce56e 100644 --- a/README.md +++ b/README.md @@ -245,4 +245,4 @@ return [ ]; ``` -When an element purge proceeds, its deduplicated public site URLs are included in tag-based gateway API requests as the optional `fetchUrls` field. After a successful purge, the gateway asynchronously fetches those URLs to repopulate the cache. Drafts, revisions, deleted, disabled or unroutable elements, and non-HTTP(S) URLs are omitted. Canceling the purge also prevents those URLs from being fetched. +When a saved element purge proceeds, its non-null site URL is included in tag-based gateway API requests as the optional `fetchUrls` field. URLs are deduplicated, and the gateway asynchronously fetches them after a successful purge to repopulate the cache. Drafts, revisions, deletions, and canceled purges do not enqueue URLs. diff --git a/src/StaticCache.php b/src/StaticCache.php index 6f316549..91a6bd1e 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -3,7 +3,6 @@ namespace craft\cloud; use Craft; -use craft\base\Element; use craft\base\ElementInterface; use craft\cloud\events\PurgeEvent; use craft\events\ElementEvent; @@ -114,13 +113,14 @@ public function registerEventHandlers(): void Craft::$app->onAfterRequest(function() { if ($this->tagsToPurge->isNotEmpty()) { try { - $this->purgePreparedTags( + $this->sendTagPurgeRequest( $this->tagsToPurge, $this->fetchUrls, ); } catch (\Throwable $e) { - // TODO: log exception once output payload isn't a concern - Module::error('Failed to purge tags after request'); + Module::error('Failed to purge tags after request', [ + 'exceptionMessage' => $e->getMessage(), + ]); } } }); @@ -178,7 +178,7 @@ private function handleInvalidateElementCaches(InvalidateElementCachesEvent $eve return; } - $tags = $this->preparePurgeTags($event->element ?? null, ...$tags); + $tags = $this->beforePurge($event->element ?? null, ...$tags); $this->tagsToPurge->push(...$tags); } @@ -193,7 +193,15 @@ private function handleRegisterCacheOptions(RegisterCacheOptionsEvent $event): v private function handleSaveElement(ElementEvent $event): void { - $this->purgeElementUri($event->element); + if (!$this->purgeElementUri($event->element)) { + return; + } + + $url = $event->element->getUrl(); + + if ($url !== null) { + $this->fetchUrls->push($url); + } } private function handleDeleteElement(ElementEvent $event): void @@ -209,7 +217,7 @@ public function purgeAll(): void public function purgeOrigin(): void { - $tags = $this->preparePurgeTags( + $tags = $this->beforePurge( null, Module::getInstance()->getConfig()->environmentId, ); @@ -230,19 +238,19 @@ public function purgeGateway(): void public function purgeCdn(): void { - $tags = $this->preparePurgeTags( + $tags = $this->beforePurge( null, self::CDN_PREFIX . Module::getInstance()->getConfig()->environmentId, ); $this->tagsToPurge->push(...$tags); } - private function purgeElementUri(ElementInterface $element): void + private function purgeElementUri(ElementInterface $element): bool { $uri = $element->uri ?? null; if (ElementHelper::isDraftOrRevision($element) || !$uri) { - return; + return false; } $uri = $element->getIsHomepage() @@ -250,8 +258,10 @@ private function purgeElementUri(ElementInterface $element): void : Path::new($uri)->withLeadingSlash()->withoutTrailingSlash(); $environmentId = Module::getInstance()->getConfig()->environmentId; - $tags = $this->preparePurgeTags($element, "$environmentId:$uri"); + $tags = $this->beforePurge($element, "$environmentId:$uri"); $this->tagsToPurge = $tags->concat($this->tagsToPurge); + + return $tags->isNotEmpty(); } private function addCacheHeadersToWebResponse(): void @@ -285,11 +295,11 @@ public function purgeTags(string|StaticCacheTag ...$tags): void $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); } - $tags = $this->preparePurgeTags(null, ...$tags); - $this->purgePreparedTags($tags); + $tags = $this->beforePurge(null, ...$tags); + $this->sendTagPurgeRequest($tags); } - private function purgePreparedTags(Collection $tags, ?Collection $fetchUrls = null): void + private function sendTagPurgeRequest(Collection $tags, ?Collection $fetchUrls = null): void { $response = Craft::$app->getResponse(); $isWebResponse = $response instanceof \craft\web\Response; @@ -299,7 +309,7 @@ private function purgePreparedTags(Collection $tags, ?Collection $fetchUrls = nu if ($isWebResponse) { $headerTags = $this->parseCacheTagsFromHeader(HeaderEnum::CACHE_PURGE_TAG->value); $response->getHeaders()->remove(HeaderEnum::CACHE_PURGE_TAG->value); - $tags->push(...$this->preparePurgeTags(null, ...$headerTags)); + $tags->push(...$this->beforePurge(null, ...$headerTags)); } $tags = $this->normalizeCacheTags(...$tags); @@ -329,7 +339,6 @@ private function purgePreparedTags(Collection $tags, ?Collection $fetchUrls = nu ]); $payload = [ - // Mapping to string because: https://github.com/laravel/framework/pull/54630 'tags' => $tags->map(fn(StaticCacheTag $tag) => (string) $tag)->values()->all(), ]; @@ -356,7 +365,7 @@ private function purgePreparedTags(Collection $tags, ?Collection $fetchUrls = nu } } - private function preparePurgeTags( + private function beforePurge( ?ElementInterface $element, string|StaticCacheTag ...$tags, ): Collection { @@ -377,32 +386,7 @@ private function preparePurgeTags( return Collection::make(); } - $tags = $this->normalizeCacheTags(...$event->tags); - - try { - if ( - $tags->isNotEmpty() && - $element instanceof Element && - !ElementHelper::isDraftOrRevision($element) && - $element->enabled && - $element->getEnabledForSite() && - !$element->trashed && - $element->dateDeleted === null && - !$element->archived && - $element->getSite()->getEnabled() && - $element->getSite()->hasUrls && - $element->getRoute() !== null && - ($url = $element->getUrl()) !== null && - in_array(strtolower((string)parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true) && - parse_url($url, PHP_URL_HOST) - ) { - $this->fetchUrls->push($url); - } - } catch (\Throwable) { - // The tags can still be purged without fetching the URL. - } - - return $tags; + return Collection::make($event->tags); } public function purgeUrlPrefixes(string ...$urlPrefixes): void diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 329e8079..758d72e4 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -11,6 +11,7 @@ use craft\cloud\StaticCache; use craft\cloud\StaticCacheTag; use craft\elements\Entry; +use craft\events\ElementEvent; use craft\helpers\StringHelper; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; @@ -238,7 +239,7 @@ public function testFailedFetchRequestFallsBackToPurgeHeader(): void $element->fetchUrl = 'https://example.com/news'; $this->gatewayException = new \RuntimeException(); - $this->purgeElementUri($staticCache, $element); + $this->saveElement($staticCache, $element); try { $this->sendPendingPurgeTags($staticCache); @@ -290,7 +291,7 @@ public function testBeforePurgeEventReceivesElement(): void $purgeEvent = $event; }); - $this->purgeElementUri($staticCache, $element); + $this->saveElement($staticCache, $element); $this->assertSame($element, $purgeEvent->element); $this->assertSame('123-environment-id:/news', $purgeEvent->tags[0]->getValue()); @@ -305,13 +306,25 @@ public function testCancelledElementPurgeDoesNotQueueFetch(): void $event->isValid = false; }); - $this->purgeElementUri($staticCache, $element); + $this->saveElement($staticCache, $element); $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); } - public function testElementPurgeRequestIncludesPublicFetchUrls(): void + public function testDeletedElementPurgeDoesNotQueueFetch(): void + { + $staticCache = new StaticCache(); + $element = new FetchableElement(['uri' => 'news']); + $element->fetchUrl = 'https://example.com/news'; + + $this->deleteElement($staticCache, $element); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isNotEmpty()); + $this->assertTrue($this->collectionProperty($staticCache, 'fetchUrls')->isEmpty()); + } + + public function testSavedElementPurgeRequestIncludesFetchUrls(): void { $staticCache = new StaticCache(); Craft::$app->getResponse()->getHeaders()->set(HeaderEnum::CACHE_PURGE_TAG->value, 'cancelled'); @@ -322,25 +335,13 @@ public function testElementPurgeRequestIncludesPublicFetchUrls(): void $englishElement->fetchUrl = 'https://example.com/news'; $frenchElement = clone $englishElement; $frenchElement->fetchUrl = 'https://example.com/fr/nouvelles'; - $relativeElement = clone $englishElement; - $relativeElement->fetchUrl = '/news'; - $privateElement = clone $englishElement; - $privateElement->isPublic = false; - $disabledElement = clone $englishElement; - $disabledElement->enabled = false; - $trashedElement = clone $englishElement; - $trashedElement->trashed = true; - $deletedElement = clone $englishElement; - $deletedElement->dateDeleted = new \DateTime(); - - $this->purgeElementUri($staticCache, $englishElement); - $this->purgeElementUri($staticCache, $englishElement); - $this->purgeElementUri($staticCache, $frenchElement); - $this->purgeElementUri($staticCache, $relativeElement); - $this->purgeElementUri($staticCache, $privateElement); - $this->purgeElementUri($staticCache, $disabledElement); - $this->purgeElementUri($staticCache, $trashedElement); - $this->purgeElementUri($staticCache, $deletedElement); + $urlLessElement = clone $englishElement; + $urlLessElement->fetchUrl = null; + + $this->saveElement($staticCache, $englishElement); + $this->saveElement($staticCache, $englishElement); + $this->saveElement($staticCache, $frenchElement); + $this->saveElement($staticCache, $urlLessElement); $this->sendPendingPurgeTags($staticCache); $payload = json_decode( @@ -410,16 +411,23 @@ private function addCacheHeadersToWebResponse(StaticCache $staticCache): void $method->invoke($staticCache); } - private function purgeElementUri(StaticCache $staticCache, Entry $element): void + private function saveElement(StaticCache $staticCache, Entry $element): void + { + $method = new ReflectionMethod($staticCache, 'handleSaveElement'); + $method->setAccessible(true); + $method->invoke($staticCache, new ElementEvent(['element' => $element])); + } + + private function deleteElement(StaticCache $staticCache, Entry $element): void { - $method = new ReflectionMethod($staticCache, 'purgeElementUri'); + $method = new ReflectionMethod($staticCache, 'handleDeleteElement'); $method->setAccessible(true); - $method->invoke($staticCache, $element); + $method->invoke($staticCache, new ElementEvent(['element' => $element])); } private function sendPendingPurgeTags(StaticCache $staticCache): void { - $method = new ReflectionMethod($staticCache, 'purgePreparedTags'); + $method = new ReflectionMethod($staticCache, 'sendTagPurgeRequest'); $method->setAccessible(true); $method->invoke( $staticCache, @@ -447,15 +455,9 @@ private function setTags(StaticCache $staticCache, Collection $tags): void class FetchableElement extends Entry { public ?string $fetchUrl = null; - public bool $isPublic = true; public function getUrl(): ?string { return $this->fetchUrl; } - - protected function route(): array|string|null - { - return $this->isPublic ? 'test/route' : null; - } } From 24e3bfdef869fcd660e2d24b5929d4eb9fdcb2fe Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sat, 18 Jul 2026 23:34:51 -0400 Subject: [PATCH 12/15] Bound gateway purge requests --- src/StaticCache.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/StaticCache.php b/src/StaticCache.php index 91a6bd1e..03c828a8 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -352,6 +352,7 @@ private function sendTagPurgeRequest(Collection $tags, ?Collection $fetchUrls = try { Helper::createGatewayApiClient()->request('POST', 'cache/purge', [ RequestOptions::JSON => $payload, + RequestOptions::TIMEOUT => 3, ]); } catch (\Throwable $e) { if ($isWebResponse) { From 913d6f9277e78e9fd2cad88f0f357d3e5967e79a Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 19 Jul 2026 06:39:28 -0400 Subject: [PATCH 13/15] Use element context for cache invalidation --- composer.json | 2 +- composer.lock | 2 +- src/StaticCache.php | 11 +++-------- tests/unit/StaticCacheTest.php | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 96977e4c..458c8066 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "ext-curl": "*", "bref/bref": "^3", "bref/extra-php-extensions": "^3", - "craftcms/cms": "^4.6 || ^5", + "craftcms/cms": "^4.10 || ^5.2", "craftcms/flysystem": "^1.0.0 || ^2.0.0", "craftcms/http-message-signatures": "^1.0", "guzzlehttp/guzzle": "^7.4.5", diff --git a/composer.lock b/composer.lock index e1602cb0..9058b5cf 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46f338fb48697e1600687619ae69a5fe", + "content-hash": "812c32f379e365e2457615d78b3e0b58", "packages": [ { "name": "aws/aws-crt-php", diff --git a/src/StaticCache.php b/src/StaticCache.php index 03c828a8..822d6d8e 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -168,17 +168,12 @@ private function handleBeforeRenderPageTemplate(TemplateEvent $event): void private function handleInvalidateElementCaches(InvalidateElementCachesEvent $event): void { - $tags = Collection::make($event->tags)->map(fn(string $tag) => StaticCacheTag::create($tag)->minify(true)); - - $skip = $tags->contains(function(StaticCacheTag $tag) { - return preg_match('/element::craft\\\\elements\\\\\S+::(drafts|revisions)/', $tag->originalValue); - }); - - if ($skip) { + if ($event->element && ElementHelper::isDraftOrRevision($event->element)) { return; } - $tags = $this->beforePurge($event->element ?? null, ...$tags); + $tags = Collection::make($event->tags)->map(fn(string $tag) => StaticCacheTag::create($tag)->minify(true)); + $tags = $this->beforePurge($event->element, ...$tags); $this->tagsToPurge->push(...$tags); } diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 758d72e4..8bbd2c75 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -12,6 +12,7 @@ use craft\cloud\StaticCacheTag; use craft\elements\Entry; use craft\events\ElementEvent; +use craft\events\InvalidateElementCachesEvent; use craft\helpers\StringHelper; use GuzzleHttp\HandlerStack; use GuzzleHttp\Promise\Create; @@ -232,6 +233,19 @@ public function testBeforePurgeEventCanCancelPurge(): void ); } + public function testDraftCacheInvalidationDoesNotPurge(): void + { + $staticCache = new StaticCache(); + $method = new ReflectionMethod($staticCache, 'handleInvalidateElementCaches'); + $method->setAccessible(true); + $method->invoke($staticCache, new InvalidateElementCachesEvent([ + 'element' => new Entry(['draftId' => 1]), + 'tags' => ['element::craft\\elements\\Entry::1'], + ])); + + $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); + } + public function testFailedFetchRequestFallsBackToPurgeHeader(): void { $staticCache = new StaticCache(); From ce32a1ee9783dcd95445a0906b16af59cf43a5e8 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 19 Jul 2026 09:30:39 -0400 Subject: [PATCH 14/15] Retain older Craft cache invalidation support --- composer.json | 2 +- composer.lock | 2 +- src/StaticCache.php | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 458c8066..96977e4c 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "ext-curl": "*", "bref/bref": "^3", "bref/extra-php-extensions": "^3", - "craftcms/cms": "^4.10 || ^5.2", + "craftcms/cms": "^4.6 || ^5", "craftcms/flysystem": "^1.0.0 || ^2.0.0", "craftcms/http-message-signatures": "^1.0", "guzzlehttp/guzzle": "^7.4.5", diff --git a/composer.lock b/composer.lock index 9058b5cf..e1602cb0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "812c32f379e365e2457615d78b3e0b58", + "content-hash": "46f338fb48697e1600687619ae69a5fe", "packages": [ { "name": "aws/aws-crt-php", diff --git a/src/StaticCache.php b/src/StaticCache.php index 822d6d8e..8104e46b 100644 --- a/src/StaticCache.php +++ b/src/StaticCache.php @@ -168,12 +168,25 @@ private function handleBeforeRenderPageTemplate(TemplateEvent $event): void private function handleInvalidateElementCaches(InvalidateElementCachesEvent $event): void { - if ($event->element && ElementHelper::isDraftOrRevision($event->element)) { + $tags = Collection::make($event->tags)->map(fn(string $tag) => StaticCacheTag::create($tag)->minify(true)); + + // InvalidateElementCachesEvent::$element was added in Craft 4.10/5.2: + // https://github.com/craftcms/cms/pull/14950 + if (property_exists($event, 'element')) { + $element = $event->element; + $skip = $element && ElementHelper::isDraftOrRevision($element); + } else { + $element = null; + $skip = $tags->contains(function(StaticCacheTag $tag) { + return preg_match('/element::craft\\\\elements\\\\\S+::(drafts|revisions)/', $tag->originalValue); + }); + } + + if ($skip) { return; } - $tags = Collection::make($event->tags)->map(fn(string $tag) => StaticCacheTag::create($tag)->minify(true)); - $tags = $this->beforePurge($event->element, ...$tags); + $tags = $this->beforePurge($element, ...$tags); $this->tagsToPurge->push(...$tags); } From 55627503f4c58688d0d86e6efa7880df3d96a1e2 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Sun, 19 Jul 2026 09:35:37 -0400 Subject: [PATCH 15/15] Cover legacy invalidation events --- tests/unit/StaticCacheTest.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/unit/StaticCacheTest.php b/tests/unit/StaticCacheTest.php index 8bbd2c75..ce87696d 100644 --- a/tests/unit/StaticCacheTest.php +++ b/tests/unit/StaticCacheTest.php @@ -238,10 +238,15 @@ public function testDraftCacheInvalidationDoesNotPurge(): void $staticCache = new StaticCache(); $method = new ReflectionMethod($staticCache, 'handleInvalidateElementCaches'); $method->setAccessible(true); - $method->invoke($staticCache, new InvalidateElementCachesEvent([ - 'element' => new Entry(['draftId' => 1]), - 'tags' => ['element::craft\\elements\\Entry::1'], - ])); + $eventConfig = property_exists(InvalidateElementCachesEvent::class, 'element') + ? [ + 'element' => new Entry(['draftId' => 1]), + 'tags' => ['element::craft\\elements\\Entry::1'], + ] + : [ + 'tags' => ['element::craft\\elements\\Entry::drafts'], + ]; + $method->invoke($staticCache, new InvalidateElementCachesEvent($eventConfig)); $this->assertTrue($this->collectionProperty($staticCache, 'tagsToPurge')->isEmpty()); }