From 26faed98b0703a040a928315e4476c2e4981e128 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joachim=20R=C3=BCtter?= Date: Thu, 16 Jul 2026 19:29:15 +0200 Subject: [PATCH] Invalidate the old URL when an entry's slug changes The invalidator only built URLs from the entry's current state, so after a slug change the previously cached page kept being served at the old URL. Rebuild the old URI from the original slug and invalidate it, along with anything cached beneath it (descendants, mounted collections). In background recache mode the old URLs are invalidated rather than refreshed, since they no longer resolve and could never be recached. Co-Authored-By: Claude Fable 5 --- src/StaticCaching/DefaultInvalidator.php | 34 +++++- .../StaticCaching/DefaultInvalidatorTest.php | 105 ++++++++++++++++++ 2 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/StaticCaching/DefaultInvalidator.php b/src/StaticCaching/DefaultInvalidator.php index 6a77d450597..51aea7011c0 100644 --- a/src/StaticCaching/DefaultInvalidator.php +++ b/src/StaticCaching/DefaultInvalidator.php @@ -8,6 +8,7 @@ use Statamic\Contracts\Entries\Entry; use Statamic\Contracts\Forms\Form; use Statamic\Contracts\Globals\Variables; +use Statamic\Contracts\Routing\UrlBuilder; use Statamic\Contracts\Structures\Nav; use Statamic\Contracts\Structures\NavTree; use Statamic\Facades\Antlers; @@ -32,6 +33,11 @@ public function __construct(Cacher $cacher, $rules = []) public function invalidate($item) { + // Old URLs no longer resolve so they cannot be recached, only invalidated. + if ($this->refreshing && ($oldUrls = $this->getItemOldUrls($item))) { + $this->cacher->invalidateUrls($oldUrls); + } + if ($this->rules === 'all') { $this->refreshing ? $this->cacher->refreshUrls($this->cacher->getUrls()->all()) @@ -44,7 +50,7 @@ public function invalidate($item) $this->refreshing ? $this->cacher->refreshUrls($urls) - : $this->cacher->invalidateUrls($urls); + : $this->cacher->invalidateUrls([...$urls, ...$this->getItemOldUrls($item)]); } public function refresh($item) @@ -92,6 +98,32 @@ protected function getItemUrls($item) return $urls; } + protected function getItemOldUrls($item) + { + return $item instanceof Entry ? $this->getOldEntryUrls($item) : []; + } + + protected function getOldEntryUrls($entry) + { + if ( + is_null($originalSlug = $entry->getOriginal('slug')) + || $originalSlug === $entry->slug() + || ! ($route = $entry->route()) + ) { + return []; + } + + $uri = app(UrlBuilder::class)->content($entry)->merge([ + 'parent_uri' => $entry->parent()?->uri(), + 'slug' => $originalSlug, + ])->build($route); + + $oldUrl = URL::tidy($entry->site()->url().'/'.$uri); + + // Anything cached under the old URL (descendants, mounted collections) is stale too. + return [$oldUrl, Str::finish($oldUrl, '/').'*']; + } + protected function getFormUrls($form) { $rules = collect(Arr::get($this->rules, "forms.{$form->handle()}.urls")); diff --git a/tests/StaticCaching/DefaultInvalidatorTest.php b/tests/StaticCaching/DefaultInvalidatorTest.php index c13315a7892..d6cc9a65d2e 100644 --- a/tests/StaticCaching/DefaultInvalidatorTest.php +++ b/tests/StaticCaching/DefaultInvalidatorTest.php @@ -314,6 +314,7 @@ public function collection_urls_can_be_invalidated_by_an_entry() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -368,6 +369,7 @@ public function collection_urls_can_be_invalidated_by_an_entry_in_a_multisite() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::get('fr')); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -417,6 +419,7 @@ public function invalidation_urls_respect_trailing_slash_enforcement() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -456,6 +459,7 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() $m->shouldReceive('collectionHandle')->andReturn('blog'); $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() @@ -479,6 +483,68 @@ public function entry_urls_are_not_invalidated_by_an_entry_with_a_redirect() $this->assertNull($invalidator->invalidate($entry)); } + #[Test] + public function old_entry_url_is_invalidated_when_the_slug_changes() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/new-slug', + 'http://localhost/blog/old-slug', + 'http://localhost/blog/old-slug/*', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/new-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('old-slug'); + $m->shouldReceive('slug')->andReturn('new-slug'); + $m->shouldReceive('route')->andReturn('/blog/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'new-slug']); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->invalidate($entry)); + } + + #[Test] + public function old_entry_url_is_not_invalidated_when_the_slug_is_unchanged() + { + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->with([ + 'http://localhost/blog/my-slug', + ])->once(); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/my-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('my-slug'); + $m->shouldReceive('slug')->andReturn('my-slug'); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->invalidate($entry)); + } + #[Test] public function taxonomy_urls_can_be_invalidated() { @@ -961,6 +1027,7 @@ public function it_doesnt_recache_when_background_recache_token_is_disabled() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1001,6 +1068,7 @@ public function it_recaches_when_background_recache_token_is_enabled() $m->shouldReceive('descendants')->andReturn(collect()); $m->shouldReceive('site')->andReturn(Site::default()); $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->andReturnNull(); $m->shouldReceive('toAugmentedCollection') ->andReturnSelf() ->shouldReceive('merge') @@ -1021,6 +1089,43 @@ public function it_recaches_when_background_recache_token_is_enabled() $this->assertNull($invalidator->refresh($entry)); } + #[Test] + public function old_entry_url_is_invalidated_rather_than_recached_when_background_recache_is_enabled() + { + config()->set('statamic.static_caching.background_recache', true); + + $cacher = tap(Mockery::mock(Cacher::class), function ($cacher) { + $cacher->shouldReceive('invalidateUrls')->once()->with([ + 'http://localhost/blog/old-slug', + 'http://localhost/blog/old-slug/*', + ]); + $cacher->shouldReceive('refreshUrls')->once()->with([ + 'http://localhost/blog/new-slug', + ]); + }); + + $entry = tap(Mockery::mock(Entry::class), function ($m) { + $m->shouldReceive('isRedirect')->andReturn(false); + $m->shouldReceive('absoluteUrl')->andReturn('http://localhost/blog/new-slug'); + $m->shouldReceive('collectionHandle')->andReturn('blog'); + $m->shouldReceive('descendants')->andReturn(collect()); + $m->shouldReceive('site')->andReturn(Site::default()); + $m->shouldReceive('parent')->andReturnNull(); + $m->shouldReceive('getOriginal')->with('slug')->andReturn('old-slug'); + $m->shouldReceive('slug')->andReturn('new-slug'); + $m->shouldReceive('route')->andReturn('/blog/{slug}'); + $m->shouldReceive('routeData')->andReturn(['slug' => 'new-slug']); + $m->shouldReceive('toAugmentedCollection') + ->andReturnSelf() + ->shouldReceive('merge') + ->andReturn(collect(['parent_uri' => null])); + }); + + $invalidator = new Invalidator($cacher, []); + + $this->assertNull($invalidator->refresh($entry)); + } + #[Test] public function it_calls_the_custom_invalidate_method_when_background_recache_is_enabled() {