From 90d0ae54b90249e552e9a55229bc9573a0901a61 Mon Sep 17 00:00:00 2001 From: Ryan Mitchell Date: Fri, 4 Sep 2026 09:37:36 +0100 Subject: [PATCH] Remove site content when a site is deleted --- src/Listeners/CleanupSiteContent.php | 132 +++++++++++++++++++ src/Providers/EventServiceProvider.php | 1 + src/Taxonomies/Term.php | 12 ++ tests/Listeners/CleanupSiteContentTest.php | 144 +++++++++++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 src/Listeners/CleanupSiteContent.php create mode 100644 tests/Listeners/CleanupSiteContentTest.php diff --git a/src/Listeners/CleanupSiteContent.php b/src/Listeners/CleanupSiteContent.php new file mode 100644 index 00000000000..aab1c59f4a7 --- /dev/null +++ b/src/Listeners/CleanupSiteContent.php @@ -0,0 +1,132 @@ + 'handle', + ]; + + public function handle(SiteDeleted $event) + { + $site = $event->site->handle(); + + $this->cleanupCollections($site); + $this->cleanupTaxonomies($site); + $this->cleanupNavigations($site); + $this->cleanupGlobals($site); + } + + /** + * Delete a collection's entries and tree for the site, then drop the site + * from the collection's config so it stops being referenced. + */ + private function cleanupCollections(string $site) + { + Collection::all() + ->filter(fn ($collection) => $collection->sites()->contains($site)) + ->each(function ($collection) use ($site) { + $collection->queryEntries()->where('site', $site)->get()->each(function ($entry) { + // Re-root any localizations in surviving sites before removing their origin. + $entry->detachLocalizations(); + $entry->delete(); + }); + + if ($collection->hasStructure()) { + $collection->structure()->in($site)?->delete(); + } + + $remaining = $collection->sites() + ->reject(fn ($handle) => $handle === $site) + ->values() + ->all(); + + $collection->sites($remaining)->save(); + }); + } + + /** + * Drop the site from each taxonomy's config, then strip that site's data + * from every term (deleting terms that are left with no localizations). + */ + private function cleanupTaxonomies(string $site) + { + Taxonomy::all() + ->filter(fn ($taxonomy) => $taxonomy->sites()->contains($site)) + ->each(function ($taxonomy) use ($site) { + $remaining = $taxonomy->sites() + ->reject(fn ($handle) => $handle === $site) + ->values() + ->all(); + + $taxonomy->sites($remaining)->save(); + + // The term's default locale is derived from the taxonomy's sites, + // so make sure a stale copy isn't used when re-saving terms below. + Blink::forget("taxonomy-{$taxonomy->handle()}"); + + $sites = $taxonomy->sites(); + + $taxonomy->queryTerms()->get()->each(function ($term) use ($site, $sites) { + // Term queries yield localized terms; operate on the base term. + $term = $term instanceof LocalizedTerm ? $term->term() : $term; + + $term->removeLocalization($site); + + $surviving = $sites->filter(fn ($handle) => $term->hasLocalization($handle)); + + if ($surviving->isEmpty()) { + $term->delete(); + + return; + } + + // The term is written with the taxonomy's first site as its root + // locale. If the deleted site was that root, promote a surviving + // localization so the term still has valid root data. + if (! $term->hasLocalization($default = $sites->first())) { + $term->dataForLocale($default, $term->dataForLocale($surviving->first())->all()); + } + + $term->save(); + }); + }); + } + + /** + * Delete each navigation's tree for the site. A nav's available sites are + * derived from its trees, so nothing else needs to change. + */ + private function cleanupNavigations(string $site) + { + Nav::all()->each(fn ($nav) => $nav->in($site)?->delete()); + } + + /** + * Drop the site from each global set's config, null out any origins that + * pointed at it, and let the set delete the orphaned localization on save. + */ + private function cleanupGlobals(string $site) + { + GlobalSet::all() + ->filter(fn ($set) => $set->sites()->contains($site)) + ->each(function ($set) use ($site) { + $origins = $set->origins() + ->reject(fn ($origin, $handle) => $handle === $site) + ->map(fn ($origin) => $origin === $site ? null : $origin) + ->all(); + + $set->sites($origins)->save(); + }); + } +} diff --git a/src/Providers/EventServiceProvider.php b/src/Providers/EventServiceProvider.php index 6117a48812f..a2d13171116 100755 --- a/src/Providers/EventServiceProvider.php +++ b/src/Providers/EventServiceProvider.php @@ -41,6 +41,7 @@ class EventServiceProvider extends ServiceProvider \Statamic\Listeners\UpdateAssetReferences::class, \Statamic\Listeners\UpdateTermReferences::class, \Statamic\Listeners\InvalidateNavCache::class, + \Statamic\Listeners\CleanupSiteContent::class, ]; public function register() diff --git a/src/Taxonomies/Term.php b/src/Taxonomies/Term.php index 61a265f151e..4e5889edf64 100644 --- a/src/Taxonomies/Term.php +++ b/src/Taxonomies/Term.php @@ -160,6 +160,18 @@ public function localizations() }); } + public function hasLocalization($site) + { + return $this->data->has($site); + } + + public function removeLocalization($site) + { + $this->data->forget($site); + + return $this; + } + public function collection($collection = null) { return $this->fluentlyGetOrSet('collection')->args(func_get_args()); diff --git a/tests/Listeners/CleanupSiteContentTest.php b/tests/Listeners/CleanupSiteContentTest.php new file mode 100644 index 00000000000..8aa95cbdd36 --- /dev/null +++ b/tests/Listeners/CleanupSiteContentTest.php @@ -0,0 +1,144 @@ + ['name' => 'English', 'url' => '/', 'locale' => 'en_US'], + 'fr' => ['name' => 'French', 'url' => '/fr/', 'locale' => 'fr_FR'], + 'de' => ['name' => 'German', 'url' => '/de/', 'locale' => 'de_DE'], + ])); + + Site::swap(new Sites); + Config::set('statamic.system.multisite', true); + } + + private function deleteSite(string $handle): void + { + Site::setSites(collect(Site::config())->forget($handle)->all())->save(); + } + + #[Test] + public function it_deletes_content_belonging_to_a_removed_site() + { + $blog = tap(Collection::make('blog')->sites(['en', 'de']))->save(); + Entry::make()->id('en-1')->locale('en')->collection('blog')->slug('en-1')->data(['title' => 'EN 1'])->save(); + Entry::make()->id('de-1')->locale('de')->collection('blog')->slug('de-1')->data(['title' => 'DE 1'])->save(); + + $pages = tap(Collection::make('pages')->sites(['en', 'de'])->structureContents(['root' => true]))->save(); + Entry::make()->id('p-en')->locale('en')->collection('pages')->slug('p-en')->data(['title' => 'P EN'])->save(); + Entry::make()->id('p-de')->locale('de')->collection('pages')->slug('p-de')->data(['title' => 'P DE'])->save(); + $pages->structure()->in('en')->tree([['entry' => 'p-en']])->save(); + $pages->structure()->in('de')->tree([['entry' => 'p-de']])->save(); + + $nav = tap(Nav::make('main'))->save(); + $nav->makeTree('en', [['id' => 'a']])->save(); + $nav->makeTree('de', [['id' => 'b']])->save(); + + $tags = tap(Taxonomy::make('tags')->sites(['en', 'de']))->save(); + $alpha = Term::make()->taxonomy('tags')->slug('alpha'); + $alpha->dataForLocale('en', ['title' => 'Alpha EN']); + $alpha->dataForLocale('de', ['title' => 'Alpha DE']); + $alpha->save(); + + $company = tap(GlobalSet::make('company')->sites(['en' => null, 'de' => 'en']))->save(); + $company->in('en')->data(['name' => 'ACME'])->save(); + $company->in('de')->data(['name' => 'ACME DE'])->save(); + + $this->deleteSite('de'); + + $this->assertEquals(0, Entry::query()->where('site', 'de')->count()); + $this->assertNotNull(Entry::find('en-1')); + $this->assertNotNull(Entry::find('p-en')); + + $this->assertEquals(['en'], Collection::findByHandle('blog')->sites()->all()); + $this->assertEquals(['en'], Collection::findByHandle('pages')->sites()->all()); + $this->assertNull(Collection::findByHandle('pages')->structure()->in('de')); + $this->assertNull(app(CollectionTreeRepository::class)->find('pages', 'de')); + + $this->assertNull(Nav::findByHandle('main')->in('de')); + $this->assertNotNull(Nav::findByHandle('main')->in('en')); + + $this->assertEquals(['en'], Taxonomy::findByHandle('tags')->sites()->all()); + $alpha = Term::find('tags::alpha')?->term(); + $this->assertNotNull($alpha); + $this->assertTrue($alpha->dataForLocale('de')->isEmpty()); + $this->assertEquals('Alpha EN', $alpha->dataForLocale('en')->get('title')); + + $company = GlobalSet::findByHandle('company'); + $this->assertNull($company->in('de')); + $this->assertFalse($company->sites()->contains('de')); + } + + #[Test] + public function it_flattens_localizations_whose_origin_was_in_the_removed_site() + { + tap(Collection::make('blog')->sites(['en', 'de']))->save(); + + Entry::make()->id('origin')->locale('de')->collection('blog')->slug('origin') + ->data(['title' => 'Origin DE', 'body' => 'shared body'])->save(); + Entry::make()->id('loc')->locale('en')->collection('blog')->slug('loc')->origin('origin') + ->data(['title' => 'Localized EN'])->save(); + + $this->deleteSite('de'); + + $this->assertNull(Entry::find('origin')); + + $loc = Entry::find('loc'); + $this->assertNotNull($loc); + $this->assertNull($loc->origin()); + $this->assertEquals('Localized EN', $loc->value('title')); + $this->assertEquals('shared body', $loc->value('body')); + } + + #[Test] + public function it_can_delete_the_default_site() + { + tap(Collection::make('blog')->sites(['en', 'fr', 'de']))->save(); + Entry::make()->id('fr-1')->locale('fr')->collection('blog')->slug('fr-1')->data(['title' => 'FR 1'])->save(); + Entry::make()->id('en-1')->locale('en')->collection('blog')->slug('en-1')->data(['title' => 'EN 1'])->save(); + + $tags = tap(Taxonomy::make('tags')->sites(['en', 'fr', 'de']))->save(); + $alpha = Term::make()->taxonomy('tags')->slug('alpha'); + $alpha->dataForLocale('en', ['title' => 'Alpha EN']); + $alpha->dataForLocale('de', ['title' => 'Alpha DE']); + $alpha->save(); + + $this->deleteSite('en'); + + $this->assertEquals('fr', Site::default()->handle()); + $this->assertEquals(0, Entry::query()->where('site', 'en')->count()); + $this->assertNotNull(Entry::find('fr-1')); + $this->assertEquals(['fr', 'de'], Collection::findByHandle('blog')->sites()->all()); + + // The term's root locale (en) was removed, so a surviving localization is + // promoted to the new default locale to keep the term file valid. + $alpha = Term::find('tags::alpha')?->term(); + $this->assertNotNull($alpha); + $this->assertEquals('Alpha DE', $alpha->dataForLocale('de')->get('title')); + $this->assertEquals('Alpha DE', $alpha->dataForLocale('fr')->get('title')); + } +}