From 6a70871b2063dada8adc27998b70f3f69406f134 Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 17 Jul 2026 17:21:45 +0100 Subject: [PATCH 1/6] make dictionary route public --- .../components/fieldtypes/DictionaryFieldtype.test.js | 2 +- routes/cp.php | 2 -- routes/web.php | 3 +++ src/Fieldtypes/Dictionary.php | 2 +- .../{CP/Fieldtypes => }/DictionaryFieldtypeController.php | 7 +++---- tests/Fieldtypes/DictionaryTest.php | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) rename src/Http/Controllers/{CP/Fieldtypes => }/DictionaryFieldtypeController.php (86%) diff --git a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js index 74fdc0364e0..a303c9bbaac 100644 --- a/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js +++ b/resources/js/tests/components/fieldtypes/DictionaryFieldtype.test.js @@ -14,7 +14,7 @@ function mountFieldtype({ value, maxItems, selectedOptions, fetchedOptions, shal handle: 'country', value, config: { max_items: maxItems }, - meta: { url: '/cp/fieldtypes/dictionaries/partial_countries', selectedOptions }, + meta: { url: '/!/fieldtypes/dictionaries/partial_countries', selectedOptions }, }, global: { mocks: { diff --git a/routes/cp.php b/routes/cp.php index 2227c479a98..2626fc0f7f2 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -57,7 +57,6 @@ use Statamic\Http\Controllers\CP\Fields\FieldsetController; use Statamic\Http\Controllers\CP\Fields\FieldtypesController; use Statamic\Http\Controllers\CP\Fields\MetaController; -use Statamic\Http\Controllers\CP\Fieldtypes\DictionaryFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\FilesFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\IconFieldtypeController; use Statamic\Http\Controllers\CP\Fieldtypes\MarkdownFieldtypeController; @@ -385,7 +384,6 @@ Route::get('relationship/filters', [RelationshipFieldtypeController::class, 'filters'])->name('relationship.filters'); Route::post('markdown', [MarkdownFieldtypeController::class, 'preview'])->name('markdown.preview'); Route::post('files/upload', [FilesFieldtypeController::class, 'upload'])->name('files.upload'); - Route::get('dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); Route::post('icons', IconFieldtypeController::class)->name('icon-fieldtype'); Route::post('replicator/set', ReplicatorSetController::class)->name('replicator-fieldtype.set'); }); diff --git a/routes/web.php b/routes/web.php index a87bccc7eb8..fed982d55fb 100755 --- a/routes/web.php +++ b/routes/web.php @@ -8,6 +8,7 @@ use Statamic\Facades\TwoFactor; use Statamic\Http\Controllers\ActivateAccountController; use Statamic\Http\Controllers\Auth\ElevatedSessionController; +use Statamic\Http\Controllers\DictionaryFieldtypeController; use Statamic\Http\Controllers\ForgotPasswordController; use Statamic\Http\Controllers\FormController; use Statamic\Http\Controllers\FrontendController; @@ -41,6 +42,8 @@ Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]); Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store'); + Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); + Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () { Route::get('logout', [LoginController::class, 'logout'])->name('logout'); diff --git a/src/Fieldtypes/Dictionary.php b/src/Fieldtypes/Dictionary.php index e63edc2abb7..fd1358d015f 100644 --- a/src/Fieldtypes/Dictionary.php +++ b/src/Fieldtypes/Dictionary.php @@ -76,7 +76,7 @@ protected function configFieldItems(): array public function preload(): array { return [ - 'url' => cp_route('dictionary-fieldtype', $this->dictionary()->handle()), + 'url' => route('statamic.dictionary-fieldtype', $this->dictionary()->handle()), 'selectedOptions' => $this->getItemData($this->field->value()), ]; } diff --git a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php similarity index 86% rename from src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php rename to src/Http/Controllers/DictionaryFieldtypeController.php index e4e0a53603f..bc1222a321e 100644 --- a/src/Http/Controllers/CP/Fieldtypes/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -1,17 +1,16 @@ fieldtype($request)->dictionary()->options($request->search); + $options = $dictionary->dictionary()->options($request->search); // Return an ordered list of key/value pairs rather than a value-keyed object. // When the values are integers, the browser would re-sort the object's keys ascending, diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index c0ce886afda..4253b18a9d9 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -91,7 +91,7 @@ public function it_returns_preload_data() $preload = $fieldtype->preload(); $this->assertArraySubset([ - 'url' => 'http://localhost/cp/fieldtypes/dictionaries/countries', + 'url' => 'http://localhost/!/fieldtypes/dictionaries/countries', 'selectedOptions' => [ ['value' => 'USA', 'label' => 'πŸ‡ΊπŸ‡Έ United States', 'invalid' => false], ['value' => 'AUS', 'label' => 'πŸ‡¦πŸ‡Ί Australia', 'invalid' => false], From 1cd889c0544830d5ad46c10b1f29a8e6245e699e Mon Sep 17 00:00:00 2001 From: Duncan McClean Date: Fri, 17 Jul 2026 17:38:48 +0100 Subject: [PATCH 2/6] add authorization --- src/Dictionaries/Countries.php | 5 ++ src/Dictionaries/Currencies.php | 5 ++ src/Dictionaries/Dictionary.php | 5 ++ src/Dictionaries/Languages.php | 5 ++ src/Dictionaries/Locales.php | 5 ++ src/Dictionaries/Timezones.php | 5 ++ .../DictionaryFieldtypeController.php | 10 +++- tests/Fieldtypes/DictionaryTest.php | 58 +++++++++++++++++++ 8 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/Dictionaries/Countries.php b/src/Dictionaries/Countries.php index 12c71168797..293c3e081bf 100644 --- a/src/Dictionaries/Countries.php +++ b/src/Dictionaries/Countries.php @@ -343,4 +343,9 @@ protected function getItems(): array ['name' => __('statamic::dictionary-countries.names.ZWE'), 'iso3' => 'ZWE', 'iso2' => 'ZW', 'region' => $this->regions['africa'], 'subregion' => $this->subregions['eastern_africa'], 'emoji' => 'πŸ‡ΏπŸ‡Ό'], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Currencies.php b/src/Dictionaries/Currencies.php index 83fb6ea3734..56b4019685a 100644 --- a/src/Dictionaries/Currencies.php +++ b/src/Dictionaries/Currencies.php @@ -173,4 +173,9 @@ protected function getItems(): array ['code' => 'ZWG', 'name' => __('statamic::dictionary-currencies.ZWG'), 'symbol' => '$', 'decimals' => 2], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Dictionary.php b/src/Dictionaries/Dictionary.php index 269b00d04ff..6ffb40f064c 100644 --- a/src/Dictionaries/Dictionary.php +++ b/src/Dictionaries/Dictionary.php @@ -81,4 +81,9 @@ public function keywords(): array { return $this->keywords; } + + public function allowsPublicAccess(): bool + { + return false; + } } diff --git a/src/Dictionaries/Languages.php b/src/Dictionaries/Languages.php index c9f354ed57f..6fc955bc5cf 100644 --- a/src/Dictionaries/Languages.php +++ b/src/Dictionaries/Languages.php @@ -109,4 +109,9 @@ protected function getItems(): array ['code' => 'gl', 'name' => __('statamic::dictionary-languages.gl')], ]; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Locales.php b/src/Dictionaries/Locales.php index 70a73f2cfc1..ce334335d0d 100644 --- a/src/Dictionaries/Locales.php +++ b/src/Dictionaries/Locales.php @@ -43,4 +43,9 @@ private function buildLocalesCommand(): array return ['locale', '-a']; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Dictionaries/Timezones.php b/src/Dictionaries/Timezones.php index d333633b546..13d50fd8faa 100644 --- a/src/Dictionaries/Timezones.php +++ b/src/Dictionaries/Timezones.php @@ -31,4 +31,9 @@ private function getOffset(string $tz): string return stripos($offsetInSecs, '-') === false ? "+{$hoursAndSec}" : "-{$hoursAndSec}"; } + + public function allowsPublicAccess(): bool + { + return true; + } } diff --git a/src/Http/Controllers/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php index bc1222a321e..1a7f8183f2a 100644 --- a/src/Http/Controllers/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -4,13 +4,21 @@ use Facades\Statamic\Fields\FieldtypeRepository as Fieldtype; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Gate; +use Statamic\Exceptions\ForbiddenHttpException; use Statamic\Fields\Field; class DictionaryFieldtypeController extends Controller { public function __invoke(Request $request, string $dictionary) { - $options = $dictionary->dictionary()->options($request->search); + $dictionary = $this->fieldtype($request)->dictionary(); + + if (Gate::denies('access cp') && ! $dictionary->allowsPublicAccess()) { + throw new ForbiddenHttpException; + } + + $options = $dictionary->options($request->search); // Return an ordered list of key/value pairs rather than a value-keyed object. // When the values are integers, the browser would re-sort the object's keys ascending, diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index 4253b18a9d9..a8a07eefc93 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -6,14 +6,19 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Statamic\Dictionaries\Countries; +use Statamic\Dictionaries\Dictionary; use Statamic\Dictionaries\Item; use Statamic\Exceptions\DictionaryNotFoundException; use Statamic\Exceptions\UndefinedDictionaryException; +use Statamic\Facades\User; use Statamic\Fields\Field; +use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; class DictionaryTest extends TestCase { + use PreventSavingStacheItemsToDisk; + #[Test] #[DataProvider('dictionaryConfigProvider')] public function it_gets_the_dictionary($dictionaryConfig, $expectedConfig) @@ -103,6 +108,44 @@ public function it_returns_preload_data() ], $preload); } + #[Test] + public function a_guest_can_request_options_from_a_dictionary_that_allows_public_access() + { + $this + ->getJson($this->optionsUrl('countries')) + ->assertOk() + ->assertJsonStructure(['data' => [['key', 'value']]]); + } + + #[Test] + public function a_guest_cannot_request_options_from_a_dictionary_that_does_not_allow_public_access() + { + RestrictedDictionary::register(); + + $this + ->getJson($this->optionsUrl('restricted')) + ->assertForbidden(); + } + + #[Test] + public function a_cp_user_can_request_options_from_a_dictionary_that_does_not_allow_public_access() + { + RestrictedDictionary::register(); + + $this + ->actingAs(User::make()->makeSuper()) + ->getJson($this->optionsUrl('restricted')) + ->assertOk() + ->assertJsonStructure(['data' => [['key', 'value']]]); + } + + private function optionsUrl(string $dictionary): string + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => $dictionary])); + + return route('statamic.dictionary-fieldtype', $dictionary).'?config='.$config; + } + #[Test] public function it_augments_a_single_option() { @@ -280,3 +323,18 @@ public function it_returns_extra_renderable_field_data() ], $extraRenderableFieldData); } } + +class RestrictedDictionary extends Dictionary +{ + protected static $handle = 'restricted'; + + public function options(?string $search = null): array + { + return ['foo' => 'Foo', 'bar' => 'Bar']; + } + + public function get(string $key): ?Item + { + return new Item($key, $this->options()[$key], []); + } +} From cfef485be4cea92e43948d2d9c11e08f8cc17ef4 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 20 Jul 2026 14:23:10 -0400 Subject: [PATCH 3/6] Cache the locales dictionary's computed items Process::run(['locale', '-a']) was being executed on every request since the dictionary options route was made public. Installed locales never change at runtime, so cache the result. --- src/Dictionaries/Locales.php | 25 +++++++++++++---------- tests/Dictionaries/LocalesTest.php | 32 ++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) create mode 100644 tests/Dictionaries/LocalesTest.php diff --git a/src/Dictionaries/Locales.php b/src/Dictionaries/Locales.php index ce334335d0d..acec621da3e 100644 --- a/src/Dictionaries/Locales.php +++ b/src/Dictionaries/Locales.php @@ -3,6 +3,7 @@ namespace Statamic\Dictionaries; use Facades\Statamic\Console\Processes\Process; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Str; class Locales extends BasicDictionary @@ -17,17 +18,19 @@ protected function getItemLabel(array $item): string protected function getItems(): array { - $output = Process::run($this->buildLocalesCommand()); - - return collect(explode(PHP_EOL, $output)) - ->filter(fn ($locale) => mb_check_encoding($locale, 'UTF-8')) - ->map(fn ($locale) => Str::before($locale, '.')) - ->reject(fn ($locale) => in_array($locale, ['C', 'POSIX'])) - ->filter() - ->sort() - ->values() - ->map(fn ($locale) => ['name' => $locale]) - ->all(); + return Cache::rememberForever('statamic.dictionaries.locales', function () { + $output = Process::run($this->buildLocalesCommand()); + + return collect(explode(PHP_EOL, $output)) + ->filter(fn ($locale) => mb_check_encoding($locale, 'UTF-8')) + ->map(fn ($locale) => Str::before($locale, '.')) + ->reject(fn ($locale) => in_array($locale, ['C', 'POSIX'])) + ->filter() + ->sort() + ->values() + ->map(fn ($locale) => ['name' => $locale]) + ->all(); + }); } private function buildLocalesCommand(): array diff --git a/tests/Dictionaries/LocalesTest.php b/tests/Dictionaries/LocalesTest.php new file mode 100644 index 00000000000..0e7fa5b0f14 --- /dev/null +++ b/tests/Dictionaries/LocalesTest.php @@ -0,0 +1,32 @@ +once()->andReturn("en_US.UTF-8\nfr_FR.UTF-8\nC\nPOSIX"); + + $expected = ['en_US' => 'en_US', 'fr_FR' => 'fr_FR']; + + $this->assertEquals($expected, (new Locales)->options()); + $this->assertEquals($expected, (new Locales)->options()); + } + + #[Test] + public function cached_items_can_still_be_searched() + { + Process::shouldReceive('run')->once()->andReturn("en_US.UTF-8\nfr_FR.UTF-8"); + + (new Locales)->options(); + + $this->assertEquals(['fr_FR' => 'fr_FR'], (new Locales)->options('fr')); + } +} From e9c079e4aec2842fc95ce41a3d82218b0d39d346 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 20 Jul 2026 14:23:12 -0400 Subject: [PATCH 4/6] Throttle the public dictionary fieldtype options route The route was made public without a rate limiter, unlike the other public routes in this group. Add a statamic.dictionaries limiter following the existing pattern. --- routes/web.php | 2 +- src/Providers/AuthServiceProvider.php | 4 ++++ tests/Feature/RateLimitingTest.php | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/routes/web.php b/routes/web.php index fed982d55fb..8fc9882f3a5 100755 --- a/routes/web.php +++ b/routes/web.php @@ -42,7 +42,7 @@ Route::get('protect/password', [PasswordProtectController::class, 'show'])->name('protect.password.show')->middleware([HandleInertiaRequests::class]); Route::post('protect/password', [PasswordProtectController::class, 'store'])->name('protect.password.store'); - Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)->name('dictionary-fieldtype'); + Route::get('fieldtypes/dictionaries/{dictionary}', DictionaryFieldtypeController::class)->middleware('throttle:statamic.dictionaries')->name('dictionary-fieldtype'); Route::group(['prefix' => 'auth', 'middleware' => [AuthGuard::class]], function () { Route::get('logout', [LoginController::class, 'logout'])->name('logout'); diff --git a/src/Providers/AuthServiceProvider.php b/src/Providers/AuthServiceProvider.php index 961b130ac08..0b14ccd176d 100755 --- a/src/Providers/AuthServiceProvider.php +++ b/src/Providers/AuthServiceProvider.php @@ -203,6 +203,10 @@ public function boot() : Limit::perMinute(10)->by('submission:'.$request->ip()); }); + RateLimiter::for('statamic.dictionaries', function (Request $request) { + return Limit::perMinute(60)->by($request->ip()); + }); + RateLimiter::for('two-factor', function (Request $request) { return Limit::perMinute(5)->by($request->session()->get('login.id')); }); diff --git a/tests/Feature/RateLimitingTest.php b/tests/Feature/RateLimitingTest.php index efffeb9eda4..45403774513 100644 --- a/tests/Feature/RateLimitingTest.php +++ b/tests/Feature/RateLimitingTest.php @@ -219,6 +219,29 @@ public function passkeys_bucket_is_independent_from_auth_bucket() $this->post('/!/auth/passkeys/auth')->assertNotRateLimited(); } + #[Test] + public function dictionary_fieldtype_endpoint_is_rate_limited() + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'countries'])); + $url = route('statamic.dictionary-fieldtype', 'countries').'?config='.$config; + + collect(range(1, 60))->each(fn () => $this->getJson($url)->assertNotRateLimited()); + $this->getJson($url)->assertRateLimited(); + } + + #[Test] + public function dictionary_fieldtype_rate_limiter_can_be_overridden() + { + RateLimiter::for('statamic.dictionaries', fn ($request) => Limit::perMinute(2)->by($request->ip())); + + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'countries'])); + $url = route('statamic.dictionary-fieldtype', 'countries').'?config='.$config; + + $this->getJson($url)->assertNotRateLimited(); + $this->getJson($url)->assertNotRateLimited(); + $this->getJson($url)->assertRateLimited(); + } + #[Test] public function passkeys_rate_limiter_can_be_overridden() { From a125e524d4e35f23445816b24bb6a5bbb9b727a3 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 20 Jul 2026 14:30:44 -0400 Subject: [PATCH 5/6] Hardcode the dictionary fieldtype in the public options controller --- .../DictionaryFieldtypeController.php | 17 +++++++++++++--- tests/Fieldtypes/DictionaryTest.php | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/Http/Controllers/DictionaryFieldtypeController.php b/src/Http/Controllers/DictionaryFieldtypeController.php index 1a7f8183f2a..a54ee4ade84 100644 --- a/src/Http/Controllers/DictionaryFieldtypeController.php +++ b/src/Http/Controllers/DictionaryFieldtypeController.php @@ -2,17 +2,24 @@ namespace Statamic\Http\Controllers; -use Facades\Statamic\Fields\FieldtypeRepository as Fieldtype; use Illuminate\Http\Request; use Illuminate\Support\Facades\Gate; +use Statamic\Exceptions\DictionaryNotFoundException; use Statamic\Exceptions\ForbiddenHttpException; +use Statamic\Exceptions\NotFoundHttpException; +use Statamic\Exceptions\UndefinedDictionaryException; use Statamic\Fields\Field; +use Statamic\Fieldtypes\Dictionary; class DictionaryFieldtypeController extends Controller { public function __invoke(Request $request, string $dictionary) { - $dictionary = $this->fieldtype($request)->dictionary(); + try { + $dictionary = $this->fieldtype($request)->dictionary(); + } catch (UndefinedDictionaryException|DictionaryNotFoundException $e) { + throw new NotFoundHttpException; + } if (Gate::denies('access cp') && ! $dictionary->allowsPublicAccess()) { throw new ForbiddenHttpException; @@ -35,7 +42,11 @@ protected function fieldtype($request) { $config = $this->getConfig($request); - return Fieldtype::find($config['type'])->setField( + if (! is_array($config)) { + throw new NotFoundHttpException; + } + + return (new Dictionary)->setField( new Field('relationship', $config) ); } diff --git a/tests/Fieldtypes/DictionaryTest.php b/tests/Fieldtypes/DictionaryTest.php index a8a07eefc93..ba8351fdcc4 100644 --- a/tests/Fieldtypes/DictionaryTest.php +++ b/tests/Fieldtypes/DictionaryTest.php @@ -139,6 +139,26 @@ public function a_cp_user_can_request_options_from_a_dictionary_that_does_not_al ->assertJsonStructure(['data' => [['key', 'value']]]); } + #[Test] + public function a_request_with_a_missing_dictionary_is_rejected() + { + $config = base64_encode(json_encode(['type' => 'dictionary'])); + + $this + ->getJson(route('statamic.dictionary-fieldtype', 'countries').'?config='.$config) + ->assertNotFound(); + } + + #[Test] + public function a_request_with_an_unknown_dictionary_is_rejected() + { + $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => 'not_a_real_dictionary'])); + + $this + ->getJson(route('statamic.dictionary-fieldtype', 'countries').'?config='.$config) + ->assertNotFound(); + } + private function optionsUrl(string $dictionary): string { $config = base64_encode(json_encode(['type' => 'dictionary', 'dictionary' => $dictionary])); From a9315d961cceac2a735ca9894bbe2a30038cc450 Mon Sep 17 00:00:00 2001 From: Jason Varga Date: Mon, 20 Jul 2026 15:18:00 -0400 Subject: [PATCH 6/6] fix test --- tests/Dictionaries/LocalesTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Dictionaries/LocalesTest.php b/tests/Dictionaries/LocalesTest.php index 0e7fa5b0f14..3facd384a6a 100644 --- a/tests/Dictionaries/LocalesTest.php +++ b/tests/Dictionaries/LocalesTest.php @@ -12,7 +12,7 @@ class LocalesTest extends TestCase #[Test] public function it_only_runs_the_locale_process_once_and_caches_the_result() { - Process::shouldReceive('run')->once()->andReturn("en_US.UTF-8\nfr_FR.UTF-8\nC\nPOSIX"); + Process::shouldReceive('run')->once()->andReturn(implode(PHP_EOL, ['en_US.UTF-8', 'fr_FR.UTF-8', 'C', 'POSIX'])); $expected = ['en_US' => 'en_US', 'fr_FR' => 'fr_FR']; @@ -23,7 +23,7 @@ public function it_only_runs_the_locale_process_once_and_caches_the_result() #[Test] public function cached_items_can_still_be_searched() { - Process::shouldReceive('run')->once()->andReturn("en_US.UTF-8\nfr_FR.UTF-8"); + Process::shouldReceive('run')->once()->andReturn(implode(PHP_EOL, ['en_US.UTF-8', 'fr_FR.UTF-8'])); (new Locales)->options();