From d2cfbf068997afa4c73f2212ea76349bd1a22194 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Fri, 6 Nov 2026 20:28:23 +0000 Subject: [PATCH 1/2] Skip repeated work when reading augmented entries and values A handful of small fast paths on the hottest reads while rendering: - A Value for a select, radio, button group, toggle, text or textarea field keeps its augmented value instead of augmenting again on every read. - Entry::id(), locale() and blueprint() return directly when called as getters, without building a FluentGetterSetter each time. - FluentGetterSetter reads properties through one closure per class instead of binding a new one on every read. - Site::absoluteUrl() keeps the absolute URL of a site with a relative URL for the request, keyed on the request's root URL and the trailing slash setting. - The augmented keys of entries and pages are merged, deduplicated and sorted as arrays rather than through a chain of collections. - The translator only checks whether it's a CP route for validation keys. --- src/Entries/AugmentedEntry.php | 16 +++++--- src/Entries/Entry.php | 12 ++++++ src/Extensions/Translation/Translator.php | 3 +- src/Fields/Value.php | 31 +++++++++++++- src/Sites/Site.php | 29 +++++++++++++- src/Structures/AugmentedPage.php | 25 +++++++----- src/Support/FluentGetterSetter.php | 9 ++++- tests/Fields/ValueTest.php | 49 +++++++++++++++++++++++ tests/Sites/SiteTest.php | 21 ++++++++++ 9 files changed, 174 insertions(+), 21 deletions(-) diff --git a/src/Entries/AugmentedEntry.php b/src/Entries/AugmentedEntry.php index ffeafaeab91..65eaa0d72a9 100644 --- a/src/Entries/AugmentedEntry.php +++ b/src/Entries/AugmentedEntry.php @@ -16,11 +16,17 @@ public function keys() return $this->cachedKeys; } - return $this->cachedKeys = $this->data->keys() - ->merge($this->data->supplements()->keys()) - ->merge($this->commonKeys()) - ->merge($this->blueprintFields()->keys()) - ->unique()->sort()->values()->all(); + $keys = array_merge( + collect($this->data->keys())->all(), + array_keys(collect($this->data->supplements())->all()), + $this->commonKeys(), + array_keys($this->blueprintFields()->all()), + ); + + $keys = array_unique($keys, SORT_REGULAR); + asort($keys, SORT_REGULAR); + + return $this->cachedKeys = array_values($keys); } private function commonKeys() diff --git a/src/Entries/Entry.php b/src/Entries/Entry.php index 44fbe5c51c4..a3df52042c3 100644 --- a/src/Entries/Entry.php +++ b/src/Entries/Entry.php @@ -97,6 +97,10 @@ public function __clone() public function id($id = null) { + if (func_num_args() === 0) { + return $this->id; + } + return $this->fluentlyGetOrSet('id')->args(func_get_args()); } @@ -113,6 +117,10 @@ public function getBulkAugmentationReferenceKey(): ?string public function locale($locale = null) { + if (func_num_args() === 0) { + return $this->locale ?? Site::default()->handle(); + } + return $this ->fluentlyGetOrSet('locale') ->setter(function ($locale) { @@ -187,6 +195,10 @@ public function blueprint($blueprint = null) { $key = 'entry-'.($this->id() ?? 'obj-'.spl_object_id($this)).'-blueprint'; + if (func_num_args() === 0 && ($cached = Blink::get($key)) !== null) { + return $cached; + } + return $this ->fluentlyGetOrSet('blueprint') ->getter(function ($blueprint) use ($key) { diff --git a/src/Extensions/Translation/Translator.php b/src/Extensions/Translation/Translator.php index 9ab90afaf4e..10958f8691e 100644 --- a/src/Extensions/Translation/Translator.php +++ b/src/Extensions/Translation/Translator.php @@ -7,7 +7,6 @@ use Illuminate\Support\Arr; use Illuminate\Translation\Translator as BaseTranslator; use Statamic\Statamic; -use Statamic\Support\Str; class Translator extends BaseTranslator { @@ -21,7 +20,7 @@ public function __construct(Filesystem $files, Loader $loader, $locale) public function parseKey($key) { - if (Statamic::isCpRoute() && Str::startsWith($key, 'validation.')) { + if (is_string($key) && str_starts_with($key, 'validation.') && Statamic::isCpRoute()) { $key = 'statamic::'.$key; } diff --git a/src/Fields/Value.php b/src/Fields/Value.php index 6a93a8b3cb5..b04ac2a0edc 100644 --- a/src/Fields/Value.php +++ b/src/Fields/Value.php @@ -10,6 +10,7 @@ use Statamic\Contracts\Data\Augmentable; use Statamic\Contracts\View\Antlers\Parser; use Statamic\Facades\Compare; +use Statamic\Fieldtypes; use Statamic\Support\Str; use Statamic\View\Antlers\Language\Parser\DocumentTransformer; use Statamic\View\Cascade; @@ -26,6 +27,21 @@ class Value implements ArrayAccess, IteratorAggregate, JsonSerializable protected $fieldtype; protected $augmentable; protected $shallow = false; + private $augmentedFor; + private $augmented; + + /** + * Fieldtypes whose augmented value only depends on the raw value and the + * field's config, so it can be kept rather than worked out on every read. + */ + private const REMEMBERS_AUGMENTED_VALUE = [ + Fieldtypes\ButtonGroup::class => true, + Fieldtypes\Radio::class => true, + Fieldtypes\Select::class => true, + Fieldtypes\Text::class => true, + Fieldtypes\Textarea::class => true, + Fieldtypes\Toggle::class => true, + ]; public function __construct($value, $handle = null, $fieldtype = null, $augmentable = null, $shallow = false) { @@ -84,9 +100,22 @@ public function value() if ($raw === null) { $raw = $this->fieldtype->field()?->defaultValue() ?? null; + + return $this->getAugmentedValue($raw); + } + + if ($this->augmentedFor === $this->fieldtype) { + return $this->augmented; } - return $this->getAugmentedValue($raw); + $value = $this->getAugmentedValue($raw); + + if (isset(self::REMEMBERS_AUGMENTED_VALUE[get_class($this->fieldtype)])) { + $this->augmentedFor = $this->fieldtype; + $this->augmented = $value; + } + + return $value; } private function getAugmentedValue($raw) diff --git a/src/Sites/Site.php b/src/Sites/Site.php index 8fff921a541..a4039342059 100644 --- a/src/Sites/Site.php +++ b/src/Sites/Site.php @@ -14,6 +14,7 @@ use Statamic\View\Antlers\Language\Runtime\RuntimeParser; use Statamic\View\Antlers\Language\Utilities\StringUtilities; use Statamic\View\Cascade; +use WeakMap; class Site implements Augmentable, QueryableValue { @@ -24,6 +25,12 @@ class Site implements Augmentable, QueryableValue protected $rawConfig; protected $isDefault; + /** + * The absolute URLs of sites with relative URLs, kept per request and keyed + * on everything that goes into them. + */ + private static ?WeakMap $absoluteUrls = null; + public function __construct($handle, $config, $isDefault = false) { $this->handle = $handle; @@ -79,7 +86,27 @@ public function attribute($key, $default = null) public function absoluteUrl() { - return URL::makeAbsolute($this->url()); + $configUrl = $this->config['url'] ?? null; + + // Only a relative site URL depends on the request. + if (! is_string($configUrl) || $configUrl === '' || preg_match('#^(//|[a-z][a-z0-9+.\-]*:)#i', $configUrl)) { + return URL::makeAbsolute($this->url()); + } + + $request = app('request'); + $key = $configUrl."\0".(URL::isEnforcingTrailingSlashes() ? '1' : '0')."\0".url()->to('/')."\0".$request->getScriptName(); + + self::$absoluteUrls ??= new WeakMap; + $memo = self::$absoluteUrls[$request] ?? []; + + if (isset($memo[$key])) { + return $memo[$key]; + } + + $memo[$key] = $absolute = URL::makeAbsolute($this->url()); + self::$absoluteUrls[$request] = $memo; + + return $absolute; } public function relativePath($url) diff --git a/src/Structures/AugmentedPage.php b/src/Structures/AugmentedPage.php index 7af0f8c06b7..ffecc849ed7 100644 --- a/src/Structures/AugmentedPage.php +++ b/src/Structures/AugmentedPage.php @@ -30,19 +30,22 @@ public function keys() return $this->cachedKeys; } - $keys = collect($this->hasEntry - ? parent::keys() - : ['title', 'url', 'uri', 'permalink', 'id']); - - $keys = $keys - ->merge($this->page->data()->keys()) - ->merge($this->page->supplements()->keys()) - ->merge($this->blueprintFields()->keys()) - ->merge(['entry_id']); + $keys = array_merge( + collect($this->hasEntry ? parent::keys() : ['title', 'url', 'uri', 'permalink', 'id'])->all(), + array_keys($this->page->data()->all()), + array_keys(collect($this->page->supplements())->all()), + array_keys($this->blueprintFields()->all()), + ['entry_id'], + ); + + if (Statamic::isApiRoute()) { + $keys = $this->apiKeys($keys)->all(); + } - $keys = Statamic::isApiRoute() ? $this->apiKeys($keys) : $keys; + $keys = array_unique($keys, SORT_REGULAR); + asort($keys, SORT_REGULAR); - return $this->cachedKeys = $keys->unique()->sort()->values()->all(); + return $this->cachedKeys = array_values($keys); } private function apiKeys($keys) diff --git a/src/Support/FluentGetterSetter.php b/src/Support/FluentGetterSetter.php index 0812f42acb7..d79c626c772 100644 --- a/src/Support/FluentGetterSetter.php +++ b/src/Support/FluentGetterSetter.php @@ -12,6 +12,8 @@ class FluentGetterSetter protected $setter; protected $afterSetter; + private static array $readers = []; + /** * Instantiate fluent getter/setter helper. * @@ -132,7 +134,12 @@ protected function runSetterLogic($value) private function getProperty($object, $property) { - return (fn () => $this->{$property})->call($object); + $class = get_class($object); + + // One property reader per class, rather than a new bound closure on every read. + $reader = self::$readers[$class] ??= Closure::bind(static fn ($object, $property) => $object->{$property}, null, $class); + + return $reader($object, $property); } private function setProperty($object, $property, $value) diff --git a/tests/Fields/ValueTest.php b/tests/Fields/ValueTest.php index 407ba9ff5d8..be809cc5fe5 100644 --- a/tests/Fields/ValueTest.php +++ b/tests/Fields/ValueTest.php @@ -450,4 +450,53 @@ public function augmentedArrayData() 'title' => 'Title for '.$this->id, ]; } + + #[Test] + public function it_remembers_the_augmented_value_of_fieldtypes_that_only_depend_on_the_raw_value() + { + $fieldtype = new class extends \Statamic\Fieldtypes\Text + { + public $augmented = 0; + + public function augment($value) + { + $this->augmented++; + + return strtoupper($value); + } + }; + + // A subclass could do anything when augmenting, so it isn't remembered. + $value = new Value('foo', 'test', $fieldtype); + $this->assertEquals('FOO', $value->value()); + $this->assertEquals('FOO', $value->value()); + $this->assertSame(2, $fieldtype->augmented); + + $select = (new \Statamic\Fieldtypes\Select)->setField(new Field('test', ['type' => 'select', 'options' => ['a' => 'Alfa']])); + $value = new Value('a', 'test', $select); + $this->assertSame($value->value(), $value->value()); + $this->assertEquals('Alfa', $value->value()->label()); + + $value->setFieldtype((new \Statamic\Fieldtypes\Select)->setField(new Field('test', ['type' => 'select', 'options' => ['a' => 'Changed']]))); + $this->assertEquals('Changed', $value->value()->label()); + } + + #[Test] + public function it_augments_other_fieldtypes_on_every_read() + { + $fieldtype = new class extends Fieldtype + { + public $augmented = 0; + + public function augment($value) + { + return $value.' '.++$this->augmented; + } + }; + + $value = new Value('foo', 'test', $fieldtype); + + $this->assertEquals('foo 1', $value->value()); + $this->assertEquals('foo 2', $value->value()); + } } diff --git a/tests/Sites/SiteTest.php b/tests/Sites/SiteTest.php index 68528eef6c1..be64094591b 100644 --- a/tests/Sites/SiteTest.php +++ b/tests/Sites/SiteTest.php @@ -182,6 +182,27 @@ public function gets_absolute_url() ); } + #[Test] + public function the_absolute_url_of_a_relative_site_follows_the_request() + { + $site = new Site('en', ['url' => '/fr/']); + + $this->assertEquals('http://absolute-url-resolved-from-request.com/fr', $site->absoluteUrl()); + + $this->get('https://another-host.com/something'); + $this->assertEquals('https://another-host.com/fr', $site->absoluteUrl()); + + url()->forceRootUrl('https://forced-root.com'); + $this->assertEquals('https://forced-root.com/fr', $site->absoluteUrl()); + + url()->forceRootUrl(null); + $this->assertEquals('https://another-host.com/fr', $site->absoluteUrl()); + + \Statamic\Facades\URL::enforceTrailingSlashes(); + $this->assertEquals('https://another-host.com/fr/', $site->absoluteUrl()); + \Statamic\Facades\URL::enforceTrailingSlashes(false); + } + #[Test] public function gets_path() { From e843bde66915fe224761c3269a24aeab32659f73 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Wed, 11 Nov 2026 03:20:16 +0000 Subject: [PATCH 2/2] Move two Value tests into the test class They were declared on the DummyAugmentable helper below it, so PHPUnit never ran them. --- tests/Fields/ValueTest.php | 50 +++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/tests/Fields/ValueTest.php b/tests/Fields/ValueTest.php index be809cc5fe5..ef63cc1a04c 100644 --- a/tests/Fields/ValueTest.php +++ b/tests/Fields/ValueTest.php @@ -425,31 +425,6 @@ public function shouldParseAntlersFromRawString(): bool $this->assertEquals('augmented_value', (string) $value); } -} - -class DummyAugmentable implements \Statamic\Contracts\Data\Augmentable -{ - use \Statamic\Data\HasAugmentedData; - - private $id; - - public function __construct($id) - { - $this->id = $id; - } - - public function blueprint() - { - return Blueprint::make(); - } - - public function augmentedArrayData() - { - return [ - 'id' => $this->id, - 'title' => 'Title for '.$this->id, - ]; - } #[Test] public function it_remembers_the_augmented_value_of_fieldtypes_that_only_depend_on_the_raw_value() @@ -500,3 +475,28 @@ public function augment($value) $this->assertEquals('foo 2', $value->value()); } } + +class DummyAugmentable implements \Statamic\Contracts\Data\Augmentable +{ + use \Statamic\Data\HasAugmentedData; + + private $id; + + public function __construct($id) + { + $this->id = $id; + } + + public function blueprint() + { + return Blueprint::make(); + } + + public function augmentedArrayData() + { + return [ + 'id' => $this->id, + 'title' => 'Title for '.$this->id, + ]; + } +}