From 3d341a2790a27d57933943c4acd92bf22c57f9d9 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 17:01:20 +0000 Subject: [PATCH 1/4] Build entry URLs without Antlers for simple routes, and snapshot dirty state lazily Statamic builds every entry's URL by running its route through Antlers with a new parser each time. For routes that are only placeholders, FastUrlBuilder puts the values in itself and lets Statamic's builder do the rest (slugify, tidy) with a parser that has nothing to do. Anything else is left to Statamic. FlatpackEntry no longer works out its dirty-state snapshot on every read. It keeps a copy of the entry as read and derives the snapshot when getOriginal(), isDirty() or serialisation asks. The parity fixtures gain two collections whose routes can't take the fast path. --- CHANGELOG.md | 7 ++ src/FlatpackEntry.php | 54 ++++++++++++++++ src/Routing/FastUrlBuilder.php | 109 ++++++++++++++++++++++++++++++++ src/Routing/PlainTextParser.php | 57 +++++++++++++++++ src/ServiceProvider.php | 1 + tests/parity/generate.php | 16 +++++ 6 files changed, 244 insertions(+) create mode 100644 src/Routing/FastUrlBuilder.php create mode 100644 src/Routing/PlainTextParser.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b515fd..7ff7d2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Changed + +- **Faster builds, with less memory.** Statamic builds each entry's URL by running its route through Antlers with a new parser every time. Routes that are only placeholders (`/books/{slug}`, `{parent_uri}/{slug}`) now have their values put in directly; anything else still goes through Antlers. On the demo's 9,000-entry nested page builder site the build went from 21.5 s to 15.5 s and its memory from 356 MB to 285 MB. +- **Entries are cheaper to read.** Statamic snapshots an entry's state every time one is read, so it can later say what changed. Flatpack now works that snapshot out only when something asks for it. Reading 9,000 entries through a query went from 81 to 32 microseconds each. + ## 1.1.0 (2026-11-10) ### Changed diff --git a/src/FlatpackEntry.php b/src/FlatpackEntry.php index 007e446..9c55589 100644 --- a/src/FlatpackEntry.php +++ b/src/FlatpackEntry.php @@ -11,9 +11,13 @@ * bodies during indexing; both are routed around the placeholders here: * - routeData(): a route template only sees the lazy fields it names * - dirty-state snapshots keep placeholders instead of loading bodies + * The dirty-state snapshot is also only worked out when something asks for it. */ class FlatpackEntry extends Entry { + /** This entry as it was when last read or saved, until its dirty-state snapshot is needed. */ + private ?self $asRead = null; + public function data($data = null) { if (func_num_args() === 0) { @@ -44,6 +48,56 @@ public function getCurrentDirtyStateAttributes(): array return $this->withSwappedData(fn ($d) => collect($d->rawItems()), fn () => parent::getCurrentDirtyStateAttributes()); } + /** + * Statamic snapshots an entry's state every time one is read from the Stache, so it can + * later say what changed. Most entries are never asked. Keeping a copy of the entry as it + * was, and working the snapshot out from that copy only when something asks, costs a + * shallow clone per read instead. + */ + public function syncOriginal(): static + { + $this->asRead = null; + $asRead = clone $this; + + // Dates can be changed in place, so the copy needs its own. + if ($asRead->date instanceof \DateTimeInterface) { + $asRead->date = clone $asRead->date; + } + + $this->asRead = $asRead; + + return $this; + } + + public function getOriginal($key = null, $fallback = null) + { + $this->resolveOriginal(); + + return parent::getOriginal($key, $fallback); + } + + public function isDirty($attributes = null): bool + { + $this->resolveOriginal(); + + return parent::isDirty($attributes); + } + + private function resolveOriginal(): void + { + if ($asRead = $this->asRead) { + $this->asRead = null; + $this->original = $asRead->getCurrentDirtyStateAttributes(); + } + } + + public function __sleep() + { + $this->resolveOriginal(); + + return array_values(array_diff(parent::__sleep(), ['asRead'])); + } + /** Run $fn with this entry's (and its origins') unloaded LazyData temporarily replaced. */ private function withSwappedData(callable $swap, callable $fn) { diff --git a/src/Routing/FastUrlBuilder.php b/src/Routing/FastUrlBuilder.php new file mode 100644 index 0000000..ddd73e0 --- /dev/null +++ b/src/Routing/FastUrlBuilder.php @@ -0,0 +1,109 @@ + 1, 'false' => 1, 'null' => 1, 'void' => 1, 'if' => 1, 'else' => 1, 'elseif' => 1, 'unless' => 1, 'elseunless' => 1, + 'endif' => 1, 'endunless' => 1, 'and' => 1, 'or' => 1, 'xor' => 1, 'not' => 1, 'switch' => 1, 'once' => 1, 'endonce' => 1, + 'noparse' => 1, 'endnoparse' => 1, 'stop' => 1, 'recursive' => 1, 'new' => 1, 'bwa' => 1, 'bwo' => 1, 'bxor' => 1, 'bnot' => 1, 'bsl' => 1, 'bsr' => 1, + ]; + + private static ?PlainTextParser $plain = null; + + private static ?object $noData = null; + + public function build($route) + { + $pattern = is_array($route) ? ($route[$this->content->locale()] ?? null) : $route; + + if (! is_string($pattern) || str_contains($pattern, '{{')) { + return parent::build($route); + } + + $data = null; + $simple = true; + + // The same placeholders Statamic turns into Antlers variables. + $filled = preg_replace_callback('/{\s*([a-zA-Z0-9_\-]+)\s*}/', function ($match) use (&$data, &$simple) { + $name = $match[1]; + + if (! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $name) || isset(self::RESERVED[strtolower($name)])) { + $simple = false; + + return ''; + } + + $data ??= array_merge($this->content->routeData(), $this->merged ?? []); + $value = $data[$name] ?? null; + + if (is_int($value)) { + return (string) $value; + } + + if (is_string($value) && strpbrk($value, '{}') === false) { + return $value; + } + + $simple = false; + + return ''; + }, $pattern); + + if (! $simple || $filled === null || strpbrk($filled, '{}') !== false) { + return parent::build($route); + } + + // Statamic's builder does the rest. It has no values left to look up, so it is given + // content with no route data rather than working the entry's out a second time. + $content = $this->content; + $merged = $this->merged ?? []; + $thrown = null; + + $this->content = self::$noData ??= new class + { + public function routeData() + { + return []; + } + }; + $this->merged = []; + + try { + // usingParser() doesn't put the usual parser back if the callback throws, so nothing is thrown through it. + $url = Antlers::usingParser(self::$plain ??= new PlainTextParser, function () use ($filled, &$thrown) { + try { + return parent::build($filled); + } catch (\Throwable $e) { + $thrown = $e; + + return null; + } + }); + } finally { + $this->content = $content; + $this->merged = $merged; + } + + if ($thrown) { + throw $thrown; + } + + return $url; + } +} diff --git a/src/Routing/PlainTextParser.php b/src/Routing/PlainTextParser.php new file mode 100644 index 0000000..5e0613d --- /dev/null +++ b/src/Routing/PlainTextParser.php @@ -0,0 +1,57 @@ +app->booting(function ($app) { $app->bind(\Statamic\Contracts\Entries\Entry::class, FlatpackEntry::class); $app->bind(\Statamic\Stache\Traverser::class, Stache\FastTraverser::class); + $app->bind(\Statamic\Contracts\Routing\UrlBuilder::class, Routing\FastUrlBuilder::class); $app->singleton(\Statamic\Yaml\Yaml::class, fn () => new LeanYaml(new Yaml\SymfonyFast)); Facade::clearResolvedInstance(\Statamic\Yaml\Yaml::class); }); diff --git a/tests/parity/generate.php b/tests/parity/generate.php index 820a922..cd95995 100644 --- a/tests/parity/generate.php +++ b/tests/parity/generate.php @@ -123,6 +123,22 @@ } } +// ---- notes and guides: routes that aren't only plain placeholders, so URLs still come from Antlers +$write("$c/collections/notes.yaml", $yaml(['title' => 'Notes', 'sites' => ['en'], 'route' => '/notes/{{ slug }}-{{ title | length }}'])); +$write("$c/collections/guides.yaml", $yaml(['title' => 'Guides', 'sites' => ['en'], 'route' => '/guides/{topics}/{level}/{slug}'])); +for ($i = 0; $i < 6; $i++) { + $slug = strtolower(str_replace(' ', '-', $title(2))).'-'.$i; + $write("$c/collections/notes/en/$slug.md", "---\n".$yaml(['id' => $uuid("note-$i"), 'title' => $title(3)])."---\n"); + // topics is a list and level is sometimes missing or has braces in it: none of those can be put in directly + $guide = ['id' => $uuid("guide-$i"), 'title' => $title(3), 'topics' => ['alpha', 'beta']]; + if ($i % 3 === 1) { + $guide['level'] = 'intro'; + } elseif ($i % 3 === 2) { + $guide['level'] = 'a{b}c'; + } + $write("$c/collections/guides/en/$slug.md", "---\n".$yaml($guide)."---\n"); +} + // ---- team: single site, structured without root, orderable $write("$c/collections/team.yaml", $yaml(['title' => 'Team', 'sites' => ['en'], 'route' => '/team/{slug}', 'structure' => ['max_depth' => 1]])); $team = []; From 969099fb03385daad0e60e415831e5791bf965c9 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 17:16:29 +0000 Subject: [PATCH 2/4] Leave more of Antlers' own words to Antlers in routes A placeholder named merge, pluck, where, take, skip, orderby, groupby or arr is read by Antlers as part of its language even when the entry has a value of that name, so those go to Statamic's builder too. --- src/Routing/FastUrlBuilder.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Routing/FastUrlBuilder.php b/src/Routing/FastUrlBuilder.php index ddd73e0..9524ddb 100644 --- a/src/Routing/FastUrlBuilder.php +++ b/src/Routing/FastUrlBuilder.php @@ -22,6 +22,7 @@ class FastUrlBuilder extends UrlBuilder 'true' => 1, 'false' => 1, 'null' => 1, 'void' => 1, 'if' => 1, 'else' => 1, 'elseif' => 1, 'unless' => 1, 'elseunless' => 1, 'endif' => 1, 'endunless' => 1, 'and' => 1, 'or' => 1, 'xor' => 1, 'not' => 1, 'switch' => 1, 'once' => 1, 'endonce' => 1, 'noparse' => 1, 'endnoparse' => 1, 'stop' => 1, 'recursive' => 1, 'new' => 1, 'bwa' => 1, 'bwo' => 1, 'bxor' => 1, 'bnot' => 1, 'bsl' => 1, 'bsr' => 1, + 'list' => 1, 'as' => 1, 'arr' => 1, 'merge' => 1, 'pluck' => 1, 'where' => 1, 'take' => 1, 'skip' => 1, 'orderby' => 1, 'groupby' => 1, ]; private static ?PlainTextParser $plain = null; From 3bd1d23815a7d1b8db924d93434dc5b90f7762ed Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 17:30:19 +0000 Subject: [PATCH 3/4] Keep a translated entry's original origin, check change tracking in the parity suite, fix the 5.11 install The lazy dirty-state snapshot is taken from a copy of the entry, but Statamic remembers origins by entry id, so the copy saw the entry's current origin. The copy is now shown the origin it was read with while the snapshot is worked out. Older Statamic versions don't give a clone its own data, so the copy gets one. The parity suite compares what entries report as their original values and dirty state after being changed in memory (origin changed or removed, data, slug, date moved in place, serialised, cloned). bin/parity now lets Composer update every package when installing a Statamic version: the skeleton pins laravel/pail to a release that needs Laravel 12, which Statamic 5.11 can't use. --- bin/parity | 8 +++-- src/FlatpackEntry.php | 35 +++++++++++++++++++-- tests/parity/dirty.php | 71 ++++++++++++++++++++++++++++++++++++++++++ tests/parity/run.sh | 7 +++++ 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 tests/parity/dirty.php diff --git a/bin/parity b/bin/parity index 2d96968..3269ea8 100755 --- a/bin/parity +++ b/bin/parity @@ -22,7 +22,10 @@ if [[ -z "${KEEP:-}" || ! -d "$APP/vendor" ]]; then # Older Statamic releases have published security advisories, which Composer refuses to # install by default. This throwaway app only exists to test against them. "${COMPOSER[@]}" config policy.advisories.block false 2>/dev/null || "${COMPOSER[@]}" config audit.block-insecure false - "${COMPOSER[@]}" require "statamic/cms:$VERSION" "1994/flatpack-statamic:@dev" "laravel/framework:^10.0|^11.0|^12.0|^13.0" -W --no-interaction -q + # Everything is updated, not only these three: the skeleton pins tools (laravel/pail, for one) + # to versions that need a newer Laravel than an older Statamic allows. + "${COMPOSER[@]}" require "statamic/cms:$VERSION" "1994/flatpack-statamic:@dev" "laravel/framework:^10.0|^11.0|^12.0|^13.0" --no-update --no-interaction -q + "${COMPOSER[@]}" update -W --no-interaction -q "$PHP" artisan key:generate -q for kv in STATAMIC_PRO_ENABLED=true STATAMIC_STACHE_WATCHER=false CACHE_STORE=file CACHE_DRIVER=file QUEUE_CONNECTION=sync APP_ENV=local; do k="${kv%%=*}"; grep -q "^$k=" .env && sed -i.bak "s|^$k=.*|$kv|" .env || echo "$kv" >> .env @@ -36,7 +39,8 @@ sed -i.bak "s/'multisite' => false/'multisite' => true/" "$APP/config/statamic/s # KEEP=1 with a different version: switch the existing app to it instead of starting again. cd "$APP" if [[ "$("$PHP" -r 'require "vendor/autoload.php"; echo ltrim(Composer\InstalledVersions::getPrettyVersion("statamic/cms"), "v");')" != "${VERSION#v}" ]] && [[ "$VERSION" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - "${COMPOSER[@]}" require "statamic/cms:$VERSION" "laravel/framework:^10.0|^11.0|^12.0|^13.0" -W --no-interaction -q + "${COMPOSER[@]}" require "statamic/cms:$VERSION" "laravel/framework:^10.0|^11.0|^12.0|^13.0" --no-update --no-interaction -q + "${COMPOSER[@]}" update -W --no-interaction -q fi # The fast YAML parser against this version's Symfony YAML. diff --git a/src/FlatpackEntry.php b/src/FlatpackEntry.php index 9c55589..1c9fa57 100644 --- a/src/FlatpackEntry.php +++ b/src/FlatpackEntry.php @@ -5,6 +5,7 @@ use NineteenNinetyFour\Flatpack\Lazy\LazyBody; use NineteenNinetyFour\Flatpack\Lazy\LazyData; use Statamic\Entries\Entry; +use Statamic\Facades\Blink; /** * Statamic's Entry with lazy heavy fields. Two core paths read every value and would load all @@ -59,6 +60,11 @@ public function syncOriginal(): static $this->asRead = null; $asRead = clone $this; + // Older Statamic versions don't give a cloned entry its own data. + if ($asRead->data === $this->data) { + $asRead->data = clone $this->data; + } + // Dates can be changed in place, so the copy needs its own. if ($asRead->date instanceof \DateTimeInterface) { $asRead->date = clone $asRead->date; @@ -85,9 +91,34 @@ public function isDirty($attributes = null): bool private function resolveOriginal(): void { - if ($asRead = $this->asRead) { - $this->asRead = null; + if (! $asRead = $this->asRead) { + return; + } + + $this->asRead = null; + + if (! $asRead->origin) { $this->original = $asRead->getCurrentDirtyStateAttributes(); + + return; + } + + // Statamic remembers an entry's origin by the entry's id, so the copy would be given + // this entry's origin as it is now. The copy has to see the origin it had when it was + // read, and what's remembered has to be put back afterwards. + $key = $asRead->getOriginBlinkKey(); + $remembered = Blink::get($key); + + if ($origin = $asRead->getOriginByString($asRead->origin)) { + Blink::put($key, $origin); + } else { + Blink::forget($key); + } + + try { + $this->original = $asRead->getCurrentDirtyStateAttributes(); + } finally { + $remembered ? Blink::put($key, $remembered) : Blink::forget($key); } } diff --git a/tests/parity/dirty.php b/tests/parity/dirty.php new file mode 100644 index 0000000..ba850e9 --- /dev/null +++ b/tests/parity/dirty.php @@ -0,0 +1,71 @@ + + +use Statamic\Facades\Entry; + +[$_, $appDir, $out] = $argv; +require "$appDir/vendor/autoload.php"; +$app = require "$appDir/bootstrap/app.php"; +$app->make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); + +$state = fn ($e, array $keys) => [ + 'dirty' => $e->isDirty(), + 'dirty_keys' => array_values(array_filter($keys, fn ($k) => $e->isDirty($k))), + 'original' => array_map(fn ($k) => $e->getOriginal($k), array_combine($keys, $keys)), +]; +$keys = ['origin', 'slug', 'date', 'published', 'title', 'locale', 'collection']; + +// Each case starts from a freshly read entry. Statamic remembers origins per request, and the +// unsaved changes made here would otherwise be remembered into the next case. +$fresh = function ($id) { + Statamic\Facades\Blink::flush(); + + return Entry::find($id); +}; +$result = []; + +$translations = Entry::query()->where('collection', 'blog')->where('site', 'fr')->get()->filter(fn ($e) => $e->hasOrigin())->sortBy->id()->values(); +$posts = Entry::query()->where('collection', 'blog')->where('site', 'en')->get()->sortBy->id()->values(); + +foreach ($translations->take(3) as $i => $translation) { + $id = $translation->id(); + $other = $posts->first(fn ($e) => $e->id() !== $translation->origin()->id()); + + $e = $fresh($id); + $result["$i untouched"] = $state($e, $keys); + + $e = $fresh($id); + $e->origin($other); + $result["$i origin changed"] = $state($e, $keys) + ['origin_now' => $e->origin()->id()]; + + $e = $fresh($id); + $e->origin(null); + $result["$i origin removed"] = $state($e, $keys) + ['origin_now' => $e->origin()?->id()]; + + $e = $fresh($id); + $e->set('title', 'Changed in memory')->slug('changed-in-memory')->published(! $e->published()); + $result["$i data, slug and published changed"] = $state($e, $keys); +} + +foreach ($posts->take(3) as $i => $post) { + $e = $fresh($post->id()); + $e->date()?->addDays(3); + $result["post $i date moved in place"] = $state($e, $keys); + + $e = $fresh($post->id()); + $e->data()->put('title', 'Put straight into the data'); + $result["post $i data changed through the collection"] = $state($e, $keys); + + $e = unserialize(serialize($fresh($post->id()))); + $e->set('title', 'After a round trip'); + $result["post $i serialised and back"] = $state($e, $keys); + + $e = clone $fresh($post->id()); + $e->slug('cloned'); + $result["post $i cloned"] = $state($e, $keys); +} + +file_put_contents($out, json_encode($result, JSON_PRETTY_PRINT)); +echo count($result), " dirty-state answers\n"; diff --git a/tests/parity/run.sh b/tests/parity/run.sh index 69d5ca1..f35f397 100755 --- a/tests/parity/run.sh +++ b/tests/parity/run.sh @@ -19,6 +19,13 @@ reset "$O"; reset "$C"; build dump cold; same "cold read" cold dump warm; same "warm read" warm +oracle "$HERE/dirty.php" "$O" "$OUT/o-dirty.json" >/dev/null && flatpack "$HERE/dirty.php" "$C" "$OUT/c-dirty.json" >/dev/null +check "change tracking: same original values and dirty state" python3 -c " +import json,sys +a=json.load(open(sys.argv[1])); b=json.load(open(sys.argv[2])) +[print(' ', k, a[k], '->', b.get(k)) for k in a if a[k]!=b.get(k)] +sys.exit(0 if a==b and len(a)>10 else 1)" "$OUT/o-dirty.json" "$OUT/c-dirty.json" + oracle "$HERE/mutate.php" "$O" "$OUT/o-mutate.json" >/dev/null flatpack "$HERE/mutate.php" "$C" "$OUT/c-mutate.json" >/dev/null check "saves: same steps and outcomes" python3 -c " From 4b8bfc05c9e4eae87a90ab9f7ffa65c4a20b8776 Mon Sep 17 00:00:00 2001 From: Daniel Jones Date: Tue, 10 Nov 2026 18:42:24 +0000 Subject: [PATCH 4/4] Keep telling Antlers which tags exist when a URL is built, and move the changelog entries out of 1.1.1 --- CHANGELOG.md | 9 +++++++-- src/Routing/FastUrlBuilder.php | 6 ++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a226c9f..63230f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Changed + +- **Faster builds, with less memory.** Statamic builds each entry's URL by running its route through Antlers with a new parser every time. Routes that are only placeholders (`/books/{slug}`, `{parent_uri}/{slug}`) now have their values put in directly; anything else still goes through Antlers. On the demo's 9,000-entry nested page builder site the build went from 21.5 s to 15.5 s and its memory from 356 MB to 285 MB. +- **Entries are cheaper to read.** Statamic snapshots an entry's state every time one is read, so it can later say what changed. Flatpack now works that snapshot out only when something asks for it. Reading 9,000 entries through a query went from 81 to 32 microseconds each. + ## 1.1.1 (2026-11-10) ### Fixed @@ -9,8 +16,6 @@ ### Changed -- **Faster builds, with less memory.** Statamic builds each entry's URL by running its route through Antlers with a new parser every time. Routes that are only placeholders (`/books/{slug}`, `{parent_uri}/{slug}`) now have their values put in directly; anything else still goes through Antlers. On the demo's 9,000-entry nested page builder site the build went from 21.5 s to 15.5 s and its memory from 356 MB to 285 MB. -- **Entries are cheaper to read.** Statamic snapshots an entry's state every time one is read, so it can later say what changed. Flatpack now works that snapshot out only when something asks for it. Reading 9,000 entries through a query went from 81 to 32 microseconds each. - **Statamic's own fast YAML parser is used where it has one.** Flatpack's copy is only used on Statamic versions without it. ## 1.1.0 (2026-11-10) diff --git a/src/Routing/FastUrlBuilder.php b/src/Routing/FastUrlBuilder.php index 9524ddb..5ab1055 100644 --- a/src/Routing/FastUrlBuilder.php +++ b/src/Routing/FastUrlBuilder.php @@ -4,6 +4,7 @@ use Statamic\Facades\Antlers; use Statamic\Routing\UrlBuilder; +use Statamic\View\Antlers\Language\Runtime\EnvironmentDetails; /** * Statamic builds every entry's URL by running its route through Antlers, with a new parser @@ -70,6 +71,11 @@ public function build($route) return parent::build($route); } + // Creating an Antlers parser also records which tags and modifiers exist at that moment, + // and a tag registered after the view engine was created is only seen once that happens + // again. Statamic's builder did it for every URL, so it is still done here. + app(EnvironmentDetails::class); + // Statamic's builder does the rest. It has no values left to look up, so it is given // content with no route data rather than working the entry's out a second time. $content = $this->content;