Skip to content

Cache filter URLs with more than one value - #167

Merged
titouanmathis merged 3 commits into
mainfrom
feat/cacheable-query-filters
Aug 28, 2026
Merged

Cache filter URLs with more than one value#167
titouanmathis merged 3 commits into
mainfrom
feat/cacheable-query-filters

Conversation

@titouanmathis

@titouanmathis titouanmathis commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Closes the gap .planning/research_facets.md (#166) identified as the blocker: the page cache keyed query args already, but not the shape a filter actually emits.

What was broken

cacheQueryArgs works and is proven end to end — ?lang=fr is served from nginx today. But QueryKey::VALUE_CHARACTER_CLASS had no comma, and a bracketed name can never match the keyed-name pattern. So both spellings of a multi-value filter bypassed:

URL Before After
?genre=rock,jazz bypass HIT, from nginx
?genre[]=rock&genre[]=jazz bypass HIT, from the drop-in, same file
?s=chaise bypass HIT when s is keyed with a pattern

?genre=rock,jazz is the format docs/guide/query-filters.md documents, so the framework's own filter URLs were the one shape its own page cache refused to store.

How the bracketed form is handled

nginx cannot read it — a variable name may not hold brackets, and there is no $arg_genre[]. Rather than teach it to guess, it declines: a bracketed name fails knownQueryPattern(), so the request goes to PHP, and the drop-in joins the members and serves the file the comma form wrote. A couple of milliseconds slower, same bytes, no second file.

What never happens is nginx reading $arg_genre, finding it empty and serving the unfiltered page to someone who asked for a filtered one. That property — the two readers never compute keys that disagree — is what every rule in QueryKey exists for, and it is unchanged.

Members are joined in request order and never sorted. Sorting is the obvious fix and the wrong one: nginx cannot sort, so a sorted key is one only PHP could compute. Two orders are two files holding the same HTML — wasted disk, which is the cheap half of the trade.

A member may not contain a comma. ?genre[]=rock,jazz asks for one term whose slug has a comma; ?genre=rock,jazz asks for two. Joining the first would key it where the second lives, so it bypasses.

Values instead of patterns

cacheQueryArgs: [
    'page',                                     // any value the charset allows
    'lang' => ['fr', 'en'],                     // only these two
    'posts_per_page' => [12, 24, 48],
    'genre' => '^[a-z0-9-]+(?:,[a-z0-9-]+)*$',  // a pattern, when a list will not do
],

A project states what it knows and the pattern is compiled from it. Values are quoted, so 1.5 matches 1.5 and not 165. An empty list matches nothing — "these values are allowed" with none named is a bypass, not a free pass.

The page cache config and the query filters config stay independent: neither reads the other, and there is no require of one config file inside another. Naming a filter for the cache is its own step, in the file that owns caching. The cost is stated in both guides — a filter added later is a bypass until it is named here.

Search, by parameter

s bypasses unless it is keyed, and keying it means giving the pattern that bounds it. No new flag — it is the mechanism that already existed.

Verification

  • 2155 tests across all five suites, exit code 0 (checked, not read off the summary). composer lint clean of anything new; composer analyse reports no issues.
  • New unit coverage for both spellings, the mixed spelling, the comma-in-member collision, an empty member, %5B%5D, derivation and precedence, and search.
  • packages/starter/tests/smoke/NginxKeyedQueryTest.php gains the two cases that matter, because only a real nginx can settle a claim about two implementations of one algorithm: the comma form must be HIT/nginx, and the bracketed form must be HIT/php out of the same single file.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZusb9DU8coizq3CDgHEqi

The page cache keyed query args already, but its value charset had no comma and
a bracketed name could never be one. So `?genre=rock,jazz` — the format
query-filters.md documents — and `?genre[]=rock&genre[]=jazz` both bypassed, and
the framework's own filter URLs were the one shape its own cache refused.

Let a comma through the floor, and join the bracketed form in request order to
the key the comma form produces. nginx keeps the fast path for the comma form
and declines the bracketed one, where declining means passing it to PHP: the
drop-in serves the same file a couple of milliseconds later. Neither reader ever
computes a key the other disagrees with, which stays the property that matters.

Derive the keyed args from QueryFiltersConfig, so a declared filter cannot be an
undeclared cache bypass, and take the pattern from its allowlist so the two
agree on what a valid value is. Search joins the same mechanism: naming `s` in
cacheQueryArgs is the opt-in, and the pattern is what bounds the key space.

Co-authored-by: Claude <claude@anthropic.com>
@codecov

codecov Bot commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 85.66%. Comparing base (276215c) to head (dd53e71).

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #167      +/-   ##
============================================
+ Coverage     85.55%   85.66%   +0.10%     
- Complexity     2510     2535      +25     
============================================
  Files           194      194              
  Lines          6938     6988      +50     
============================================
+ Hits           5936     5986      +50     
  Misses         1002     1002              
Flag Coverage Δ
unittests 85.66% <100.00%> (+0.10%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

titouanmathis and others added 2 commits August 28, 2026 11:50
The page cache read QueryFiltersConfig to derive its keyed args, which put a
`require` of one config file inside another and tied two concerns that have no
reason to know each other.

Drop it. cacheQueryArgs takes a list of allowed values instead of a pattern, so
a project states what it knows — the page sizes, the languages — and the pattern
is compiled from that. Naming a filter for the cache is now its own step, in the
file that owns caching.

Co-authored-by: Claude <claude@anthropic.com>
The filename pattern spelled the value charset a second time, so adding the
comma for multi-value filters left it refusing `index__genre=rock,jazz&`. Every
filtered request bypassed, with a reason that said `path` — a message about the
URL, for a filename this cache would not write.

Only the end-to-end suite caught it: the unit tests covered QueryKey and never
CacheKey. Derive the pattern from the charset so the two cannot drift again, and
cover the filename at the level the gap was in.

Co-authored-by: Claude <claude@anthropic.com>
@titouanmathis
titouanmathis merged commit 66ead81 into main Aug 28, 2026
12 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.

1 participant