Skip to content

[6.x] Look term associations up instead of reading through all of them - #15846

Merged
jasonvarga merged 3 commits into
statamic:6.xfrom
DanielDarrenJones:perf/6x-term-association-lookups
Nov 11, 2026
Merged

jasonvarga merged 3 commits into
statamic:6.xfrom
DanielDarrenJones:perf/6x-term-association-lookups

Conversation

@DanielDarrenJones

@DanielDarrenJones DanielDarrenJones commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

Each taxonomy's associations index holds one row for every term an entry uses: 2,000 entries with five terms each is 10,000 rows. Three things read through all of them every time they're asked:

  • TermQueryBuilder::filterUsagesWithinCollections(), for any term query limited to a collection ({{ taxonomy:tags }} on a page that belongs to a collection, Term::query()->where('collection', ...)).
  • TermRepository::entriesCount(), once for each term whose count is shown.
  • whereTaxonomy() and whereTaxonomyIn() on entry queries.

A listing page with a category list, a tag cloud and a filtered collection tag does this a dozen times or more, and each pass costs more as the site grows.

Associations now groups its rows by slug, by value and by collection the first time one of those is asked for, and looks them up after that:

  • forSlug($slug) and forSlugs($slugs): the rows for a term, or for several, in the order they're stored.
  • forValue($value): the rows whose stored value matches.
  • slugsInCollections($collections): the terms used by entries in those collections.

The three callers above use these. What's stored in the cache is unchanged, so there's nothing to rebuild.

Keeping the grouping right

The grouping is made from the index's items and remembered alongside the array it was made from. Before it's used, that array is compared with the items the index holds now (===, which PHP answers immediately when it's the same array). If they differ, however that came about (push(), forgetEntry(), load(), update(), clear()), it's grouped again. So saving or deleting an entry is seen by the next lookup.

One difference in matching

The old code used Collection::where() and whereIn(), which compare loosely, so two numeric-looking slugs such as 010 and 10 matched each other. The lookups match the exact string.

Benchmark

Each page requested with and without this change, one PHP process per request with a warm Stache, run alternately for 3 rounds of 6 (Apple M1 Pro, PHP 8.5, 6.x at 5b60554). The site is a generated one with two taxonomies on its articles: 12 categories and 80 tags. The bench is at https://github.com/1994limited/statamic-bench.

2,000 articles (2,700 entries in all):

Page Before After
Tag page 494.1 ms 348.2 ms −29.5%
Category page 585.0 ms 417.1 ms −28.7%
Article listing 598.8 ms 434.2 ms −27.5%
Article listing, page 3 588.9 ms 434.3 ms −26.3%
Home 520.3 ms 398.4 ms −23.4%
Page builder page 578.0 ms 461.1 ms −20.2%
Article 440.6 ms 398.3 ms −9.6% (within noise)
Product listing (no term queries) 344.4 ms 344.2 ms no change
Simple page (no term queries) 228.1 ms 232.2 ms no change

Peak memory on the six faster pages is 12 to 20% lower (54 MB to 43 MB on the listing), because the filtered copies of the index are no longer made.

200 articles: the article listing is 5% faster and the category page 4%; everything else is within noise. The saving grows with the number of entries, as the cost did.

The HTML of every page is byte for byte the same before and after, at both sizes.

Every taxonomy's associations index holds one row for each term an entry
uses. Finding a term's entries, counting them, or finding the terms a
collection uses read through every row each time, so a page that lists
terms or filters entries by one did that several times over, and the cost
grew with the number of entries.

The index now groups its rows by slug, by value and by collection the
first time one of those is asked for, and looks them up after that. The
grouping is made again whenever the rows are no longer the ones it was
made from, so saving or deleting an entry is seen straight away. What's
stored in the cache is unchanged.
It checks that saves are seen straight away. Deleting an entry doesn't
update a taxonomy's associations, which is how it already works.
@DanielDarrenJones
DanielDarrenJones marked this pull request as ready for review November 11, 2026 17:01
@jasonvarga
jasonvarga merged commit a08b02e into statamic:6.x Nov 11, 2026
61 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants