From 22b568e512e7138f3711ac097fde3a16e6a05dee Mon Sep 17 00:00:00 2001 From: Jochem de Groot Date: Tue, 27 Oct 2026 16:12:09 +0100 Subject: [PATCH 1/4] Return existing localization instead of creating a duplicate --- .../CP/Collections/LocalizeEntryController.php | 17 ++++++++++++++++- tests/Feature/Entries/LocalizeEntryTest.php | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php index c830ad8c658..8e3309a9e59 100644 --- a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php +++ b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Collections; use Illuminate\Http\Request; +use Statamic\Facades\Entry; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -14,7 +15,21 @@ public function __invoke(Request $request, $collection, $entry) $request->validate(['site' => 'required']); - $localized = $entry->makeLocalization($site = $request->site); + $site = $request->site; + + $existing = Entry::query() + ->where('origin', $entry->id()) + ->where('site', $site) + ->first(); + + if ($existing) { + return [ + 'handle' => $site, + 'url' => $existing->editUrl(), + ]; + } + + $localized = $entry->makeLocalization($site); $localized->store(['user' => User::fromUser($request->user())]); diff --git a/tests/Feature/Entries/LocalizeEntryTest.php b/tests/Feature/Entries/LocalizeEntryTest.php index 3734a14340b..4907944b066 100644 --- a/tests/Feature/Entries/LocalizeEntryTest.php +++ b/tests/Feature/Entries/LocalizeEntryTest.php @@ -51,6 +51,21 @@ public function it_localizes_an_entry() $this->assertCount(0, $entry->in('fr')->revisions()); } + #[Test] + public function it_returns_the_existing_localization_instead_of_creating_another() + { + $entry = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->id('en-test')->slug('test')->create(); + $existing = EntryFactory::collection('blog')->id('fr-test')->locale('fr')->origin('en-test')->slug('test')->create(); + + $this + ->actingAs($this->user()) + ->localize($entry, ['site' => 'fr']) + ->assertOk() + ->assertJson(['handle' => 'fr', 'url' => $existing->editUrl()]); + + $this->assertCount(1, \Statamic\Facades\Entry::query()->where('origin', 'en-test')->where('site', 'fr')->get()); + } + #[Test] public function site_is_required() { From 76d65d3c03c540507069bd50f2fc850de89df8f6 Mon Sep 17 00:00:00 2001 From: Jochem de Groot Date: Tue, 27 Oct 2026 17:10:28 +0100 Subject: [PATCH 2/4] Lock localization to stop concurrent requests creating duplicates --- .../Collections/LocalizeEntryController.php | 37 +++++++++++-------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php index 8e3309a9e59..9cba41c0557 100644 --- a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php +++ b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Collections; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Statamic\Facades\Entry; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -17,25 +18,29 @@ public function __invoke(Request $request, $collection, $entry) $site = $request->site; - $existing = Entry::query() - ->where('origin', $entry->id()) - ->where('site', $site) - ->first(); + // The lock stops concurrent requests from both finding nothing and + // both creating a localization. + return Cache::lock("localize-entry:{$entry->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { + $existing = Entry::query() + ->where('origin', $entry->id()) + ->where('site', $site) + ->first(); - if ($existing) { - return [ - 'handle' => $site, - 'url' => $existing->editUrl(), - ]; - } + if ($existing) { + return [ + 'handle' => $site, + 'url' => $existing->editUrl(), + ]; + } - $localized = $entry->makeLocalization($site); + $localized = $entry->makeLocalization($site); - $localized->store(['user' => User::fromUser($request->user())]); + $localized->store(['user' => User::fromUser($request->user())]); - return [ - 'handle' => $site, - 'url' => $localized->editUrl(), - ]; + return [ + 'handle' => $site, + 'url' => $localized->editUrl(), + ]; + }); } } From 48097f6725e71da2b809c42e9a2608c83643984a Mon Sep 17 00:00:00 2001 From: Jochem de Groot Date: Wed, 28 Oct 2026 13:17:37 +0100 Subject: [PATCH 3/4] Look up existing localization through the whole origin chain --- .../Collections/LocalizeEntryController.php | 14 ++++++------- tests/Feature/Entries/LocalizeEntryTest.php | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php index 9cba41c0557..4e5254deddb 100644 --- a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php +++ b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php @@ -4,7 +4,6 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; -use Statamic\Facades\Entry; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -18,13 +17,12 @@ public function __invoke(Request $request, $collection, $entry) $site = $request->site; - // The lock stops concurrent requests from both finding nothing and - // both creating a localization. - return Cache::lock("localize-entry:{$entry->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { - $existing = Entry::query() - ->where('origin', $entry->id()) - ->where('site', $site) - ->first(); + // The lock is keyed on the root entry, so concurrent requests from any + // localization in the origin chain wait for each other. + return Cache::lock("localize-entry:{$entry->root()->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { + // Look through the whole origin chain, since the origin of a + // localization can be any existing localization. + $existing = $entry->in($site); if ($existing) { return [ diff --git a/tests/Feature/Entries/LocalizeEntryTest.php b/tests/Feature/Entries/LocalizeEntryTest.php index 4907944b066..71fdf1161b1 100644 --- a/tests/Feature/Entries/LocalizeEntryTest.php +++ b/tests/Feature/Entries/LocalizeEntryTest.php @@ -66,6 +66,25 @@ public function it_returns_the_existing_localization_instead_of_creating_another $this->assertCount(1, \Statamic\Facades\Entry::query()->where('origin', 'en-test')->where('site', 'fr')->get()); } + #[Test] + public function it_returns_an_existing_localization_from_elsewhere_in_the_origin_chain() + { + $this->setSites([ + 'en' => ['url' => 'http://localhost/', 'locale' => 'en'], + 'de' => ['url' => 'http://localhost/de/', 'locale' => 'de'], + 'fr' => ['url' => 'http://localhost/fr/', 'locale' => 'fr'], + ]); + + $en = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->id('en-test')->slug('test')->create(); + $de = EntryFactory::collection('blog')->id('de-test')->locale('de')->origin('en-test')->slug('test')->create(); + + $first = $this->actingAs($this->user())->localize($de, ['site' => 'fr'])->assertOk(); + $second = $this->actingAs($this->user())->localize($en, ['site' => 'fr'])->assertOk(); + + $this->assertSame($first->json(), $second->json()); + $this->assertCount(1, \Statamic\Facades\Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()); + } + #[Test] public function site_is_required() { @@ -270,6 +289,7 @@ private function user() 'access cp', 'access en site', 'access fr site', + 'access de site', 'view blog entries', 'edit blog entries', 'view pages entries', From e89bf71c527b1539b87efe26e2404684839d46e3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Thu, 29 Oct 2026 09:48:11 +1100 Subject: [PATCH 4/4] Fall back to no lock when the cache store doesn't support locks Co-Authored-By: Claude Opus 5.5 --- .../Collections/LocalizeEntryController.php | 11 ++- tests/Feature/Entries/LocalizeEntryTest.php | 81 +++++++++++++++++++ 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php index 4e5254deddb..cc84afced5f 100644 --- a/src/Http/Controllers/CP/Collections/LocalizeEntryController.php +++ b/src/Http/Controllers/CP/Collections/LocalizeEntryController.php @@ -2,6 +2,8 @@ namespace Statamic\Http\Controllers\CP\Collections; +use Illuminate\Cache\NoLock; +use Illuminate\Contracts\Cache\LockProvider; use Illuminate\Http\Request; use Illuminate\Support\Facades\Cache; use Statamic\Facades\User; @@ -19,7 +21,7 @@ public function __invoke(Request $request, $collection, $entry) // The lock is keyed on the root entry, so concurrent requests from any // localization in the origin chain wait for each other. - return Cache::lock("localize-entry:{$entry->root()->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { + return $this->lock("localize-entry:{$entry->root()->id()}:{$site}", 10)->block(5, function () use ($request, $entry, $site) { // Look through the whole origin chain, since the origin of a // localization can be any existing localization. $existing = $entry->in($site); @@ -41,4 +43,11 @@ public function __invoke(Request $request, $collection, $entry) ]; }); } + + private function lock(string $key, int $seconds) + { + return Cache::getStore() instanceof LockProvider + ? Cache::lock($key, $seconds) + : new NoLock($key, $seconds); + } } diff --git a/tests/Feature/Entries/LocalizeEntryTest.php b/tests/Feature/Entries/LocalizeEntryTest.php index 71fdf1161b1..0da783c6e87 100644 --- a/tests/Feature/Entries/LocalizeEntryTest.php +++ b/tests/Feature/Entries/LocalizeEntryTest.php @@ -3,6 +3,9 @@ namespace Tests\Feature\Entries; use Facades\Tests\Factories\EntryFactory; +use Illuminate\Contracts\Cache\LockProvider; +use Illuminate\Contracts\Cache\Store; +use Illuminate\Support\Facades\Cache; use PHPUnit\Framework\Attributes\Test; use Statamic\Facades\Collection; use Statamic\Facades\User; @@ -85,6 +88,84 @@ public function it_returns_an_existing_localization_from_elsewhere_in_the_origin $this->assertCount(1, \Statamic\Facades\Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()); } + #[Test] + public function it_localizes_an_entry_when_the_cache_store_does_not_support_locks() + { + Cache::extend('nolock', fn ($app) => Cache::repository(new class(Cache::store('file')->getStore()) implements Store + { + public function __construct(private Store $store) + { + } + + public function get($key) + { + return $this->store->get($key); + } + + public function many(array $keys) + { + return $this->store->many($keys); + } + + public function put($key, $value, $seconds) + { + return $this->store->put($key, $value, $seconds); + } + + public function putMany(array $values, $seconds) + { + return $this->store->putMany($values, $seconds); + } + + public function increment($key, $value = 1) + { + return $this->store->increment($key, $value); + } + + public function decrement($key, $value = 1) + { + return $this->store->decrement($key, $value); + } + + public function forever($key, $value) + { + return $this->store->forever($key, $value); + } + + public function touch($key, $seconds) + { + return $this->store->touch($key, $seconds); + } + + public function forget($key) + { + return $this->store->forget($key); + } + + public function flush() + { + return $this->store->flush(); + } + + public function getPrefix() + { + return $this->store->getPrefix(); + } + })); + + config(['cache.stores.nolock' => ['driver' => 'nolock'], 'cache.default' => 'nolock']); + $this->assertNotInstanceOf(LockProvider::class, Cache::getStore()); + + $entry = EntryFactory::collection(tap(Collection::make('blog')->revisionsEnabled(false))->save())->slug('test')->create(); + + $this + ->actingAs($this->user()) + ->localize($entry, ['site' => 'fr']) + ->assertOk(); + + $this->assertNotNull($entry->fresh()->in('fr')); + } + #[Test] public function site_is_required() {