Statamic Static Cache Too Big? How to Fix Query String Bloat (2026 Guide)
Static caching is what lets a flat file CMS sit comfortably in front of serious traffic. It also has a failure mode that creeps up on you slowly: entries take longer and longer to save, the control panel starts hitting its memory limit, and the disk fills with hundreds of thousands of near identical HTML files.
Usually that traces straight back to your advertising.
Statamic caches /pricing and /pricing?gclid=EAIaIQobCh... as two different pages, because as far as the cacher is concerned they are two different URLs. Turn on a Google Ads campaign and every single click arrives with a unique click ID attached, so one page becomes thousands of cache entries in a week.
I ran into this properly on ROH Wheels, a Statamic site serving over 20 million requests a month behind heavy ad spend. Stripping advertising query strings out of the cache key was one of the changes that made the caching layer stable there. D3 Creative wrote up the same problem after a client's static cache reached 2.3GB and started crashing saves, which is worth reading as a second data point.
This post covers what Statamic is storing, how to audit your own cache before you change anything, the two config options that fix it, and the trap that catches anyone running full measure caching.
What Statamic Actually Stores
There are two static caching strategies, and the difference matters for the fix.
Half measure uses the application driver. Statamic renders the page, stores the finished HTML in your cache store, and serves it back on the next request from a lightweight middleware. PHP still boots, but nothing else runs.
Full measure uses the file driver. Statamic writes the finished HTML to a real file under public/static/, and your nginx or Apache rewrite rules serve that file directly. PHP never boots at all.
Both strategies also maintain a single index of every URL that has been cached. It lives in your cache store under one key, static-cache:{md5 of your domain}.urls, and it is a plain PHP array of hash to path.
That index is where the pain comes from. In Statamic's AbstractCacher, every time a page is cached the whole array is read out, one entry is added, and the whole thing is written back with forever(). Every time you save an entry, the invalidation rules run, and a wildcard rule like /* loads that entire array and walks it.
Disk space is the least of it. The real cost is a single serialised array being read and rewritten on every cache write and every content save. At 1,000 URLs nobody notices. At 30,000 URLs, most of which are the same 400 pages wearing different tracking parameters, control panel saves start touching the memory limit and eventually fail outright.
Audit Before You Change Anything
Do not guess at which parameters are hurting you. Go and look, because the answer is usually specific to whatever marketing has been running.
On half measure, ask the cacher directly:
1php artisan tinker 1$cacher = app(\Statamic\StaticCaching\StaticCacheManager::class)->driver(); 2$urls = $cacher->getUrls(); 3 4// How many cache entries in total 5$urls->count(); 6 7// How many are clean URLs versus query string variations 8$urls->groupBy(fn ($url) => str_contains($url, '?') ? 'with query' : 'clean')->map->count(); 9 10// The 20 most common query parameters, by how many entries each one created11$urls->filter(fn ($url) => str_contains($url, '?'))12 ->flatMap(function ($url) {13 parse_str(parse_url($url, PHP_URL_QUERY), $params);14 15 return array_keys($params);16 })17 ->countBy()18 ->sortDesc()19 ->take(20);On full measure, the same audit is a shell one liner, because the query string is baked into each filename as slug_query.html:
1# Total files and total size2find public/static -name '*.html' | wc -l3du -sh public/static4 5# The 20 most common query parameters6find public/static -name '*_?*.html' | awk -F/ '{print $NF}' \7 | sed -e 's/^[^_]*_//' -e 's/\.html$//' \8 | tr '&' '\n' | cut -d= -f1 | sort | uniq -c | sort -rn | head -20The shape of the result is nearly always the same: a small number of real pages, and a very long tail of click IDs. If your clean URL count is roughly your page count and your total is 20 times higher, you have found your problem.
Check Your Statamic Version First
Both of the options below arrived in Statamic 5.25.0 on 10 September 2024, in a pull request titled "Prevent query parameters bloating the static cache". They are unchanged in Statamic 6. On 5.24.0 or older they do not exist, so you can add them to your config and nothing whatsoever will happen.
There is a second version trap that catches more people. The keys were added to Statamic's published config stub at the same time, but publishing a config file is a one time copy. If your project's config/statamic/static_caching.php was published before September 2024 and never refreshed, and most were, you will find ignore_query_strings sitting there on its own with no sign the other two exist. The feature exists in the code, your config file just predates it. Statamic reads both keys with an empty array default, so they work perfectly well the moment you type them in by hand.
That is the main reason this setting stays so obscure. People read their own config to find out what is available, and it is missing from the copy on their disk.
The Fix: disallowed_query_strings
Everything happens in config/statamic/static_caching.php. First, confirm this is set to false, because it is the switch that makes the other two options work at all:
1'ignore_query_strings' => false,Then list the parameters that should never form part of a cache key:
1'disallowed_query_strings' => [ 2 // Google Ads and Analytics 3 'gclid', 'gclsrc', 'dclid', 'gbraid', 'wbraid', 4 'gad_source', 'gad_campaignid', '_gl', '_ga', 5 6 // Google Merchant Centre and free product listings 7 'srsltid', 8 9 // UTM10 'utm_source', 'utm_medium', 'utm_campaign',11 'utm_term', 'utm_content', 'utm_id',12 13 // Meta, Microsoft, TikTok, LinkedIn, Reddit, Pinterest, Snapchat, Yandex14 'fbclid', 'igshid', 'msclkid', 'ttclid', 'twclid',15 'li_fat_id', 'rdt_cid', 'epik', 'ScCid', 'yclid',16 17 // Email platforms18 'mc_cid', 'mc_eid', '_hsenc', '_hsmi', 'mkt_tok', '_ke', 'vero_id',19],Now /pricing?gclid=abc and /pricing resolve to the same cache entry, and the thousandth ad click costs you nothing extra.
Three of those need explaining. srsltid is appended by Google to Merchant Centre listing clicks and, since August 2024, to organic results for merchants too, so any ecommerce site is getting it whether it advertises or not. gad_source and gad_campaignid are newer Google Ads parameters that started appearing on final URLs well after most people wrote their exclusion lists. gbraid and wbraid are the iOS replacements for gclid, so if you only exclude gclid you are still leaking on iPhone traffic.
The Better Fix for Most Sites: allowed_query_strings
The trouble with a blocklist is that you are permanently behind. Every ad platform ships a new click ID eventually, and you find out about it when your cache is already fat.
An allowlist turns it around: you state which parameters your site renders differently for, and everything else gets stripped.
1'allowed_query_strings' => [2 'page', // pagination3 'category', // index filtering4 'sort',5 'q', // search6],Anything not on that list is dropped from the cache key, whether it is gclid, next year's click ID, or ?wp-admin=1 from a vulnerability scanner probing for WordPress. The scanner case matters more than it sounds. One that walks your forms with a few hundred junk parameters will happily mint a few hundred cache entries on a blocklist setup, and no blocklist written in advance can stop it.
The two options work together, and Statamic applies the allowlist first. Using both gives you a tight allowlist plus a documented list of things you know are tracking noise.
The one thing to be careful about. If you allowlist and forget a parameter your templates read, visitors get the unfiltered cached page and nothing errors. Nobody reports it either, because the page looks fine, it is just quietly ignoring their filter. Before you ship an allowlist, grep your templates and any Livewire components for every parameter you read off the request, and make sure each one is on the list. Pagination and search are the two people forget.
No, This Does Not Break Your Attribution
This is the first question every marketing person asks, and the answer is no.
These settings only change the key Statamic looks the page up under. The URL in the address bar is untouched, so GA4, GTM and every ad platform tag read window.location and see the full parameter list exactly as before. Google Ads still writes its gclid cookie. Server side tagging still receives the whole request. Nothing downstream can tell the difference.
The one real limitation is that the cached HTML is now shared across every parameter variation of that URL, so you cannot render something different server side based on a tracking parameter. If you need that, wrap the region in <statamic:nocache> so it renders fresh on every request while the rest of the page stays cached.
The Trap: Neither Option Works on Full Measure
This one is easy to miss, because it gets one line in the docs: allowed_query_strings and disallowed_query_strings only apply to half measure caching.
You can confirm it in the source. AbstractCacher::getUrl() does the filtering, but FileCacher overrides getUrl() entirely and rebuilds the URL from the raw query string with no filtering at all. Set disallowed_query_strings on a full measure site and nothing changes.
There is a structural reason for it. On full measure, a cache hit never reaches PHP. Nginx is looking for a file at /static${uri}_$args.html, so the filename has to match the raw query string byte for byte or the whole strategy falls apart. Filtering in PHP alone would just desynchronise the two.
You have three options.
1. If no page on your site depends on a query string, turn them off entirely. This is the clean answer for a brochure site with no pagination, filtering or search:
1'ignore_query_strings' => true,Then simplify your rewrite rules to match, or nginx will keep looking for filenames that no longer exist:
1location @static {2 try_files /static${uri}_.html $uri $uri/ /index.php?$args;3}2. Switch to half measure. PHP boots on every request, which is more expensive than serving a file from disk, but you get the filtering, and with Cloudflare or another CDN in front absorbing the repeat traffic the difference is usually smaller than people expect.
3. Teach the file cacher to filter, then teach nginx to match. If you want full measure and filtering, you need both halves. Statamic's cache manager supports custom drivers, so extend FileCacher and override the one method:
1namespace App\StaticCaching; 2 3use Illuminate\Http\Request; 4use Statamic\StaticCaching\Cachers\FileCacher; 5 6class FilteredFileCacher extends FileCacher 7{ 8 public function getUrl(Request $request): string 9 {10 $url = parent::getUrl($request);11 12 if ($this->isExcluded($url) || ! $disallowed = $this->config('disallowed_query_strings')) {13 return $url;14 }15 16 $parts = parse_url($url);17 18 if (! isset($parts['query'])) {19 return $url;20 }21 22 parse_str($parts['query'], $query);23 24 $query = array_diff_key($query, array_flip($disallowed));25 26 $url = $parts['scheme'].'://'.$parts['host'].($parts['path'] ?? '/');27 28 return $query ? $url.'?'.http_build_query($query, '', '&', PHP_QUERY_RFC3986) : $url;29 }30}Register it as a driver in a service provider:
1use App\StaticCaching\FilteredFileCacher; 2use Statamic\Facades\StaticCache; 3use Statamic\StaticCaching\Cachers\Writer; 4 5StaticCache::extend('filtered_file', function ($app, $config) { 6 return new FilteredFileCacher( 7 new Writer($config['permissions'] ?? []), 8 $this->cacheStore(), 9 $config,10 );11});Statamic binds that closure to the cache manager, so $this->cacheStore() inside it gives you the same store Statamic would have used anyway, including the custom one covered further down.
Then point your strategy at it in config/statamic/static_caching.php:
1'full' => [2 'driver' => 'filtered_file',3 'path' => public_path('static'),4 // ...the rest of your existing full measure config5],With that in place, /blog?page=2&utm_source=x&gclid=abc writes to blog_page=2.html and /blog?gclid=abc&srsltid=zzz writes to blog_.html, which are exactly the filenames nginx already knows how to look for.
The second half is getting nginx to ask for the filtered name. Nginx cannot subtract individual parameters without Lua, but it can recognise a query string made up entirely of tracking parameters, which is the overwhelming majority of the damage. An ad click almost always lands on /page?gclid=... and nothing else:
1map $args $static_args {2 default $args;3 "~^(?:(?:utm_[a-z]+|gclid|gclsrc|dclid|gbraid|wbraid|gad_source|gad_campaignid|srsltid|fbclid|msclkid|ttclid|li_fat_id|igshid|epik|yclid|_gl|mc_cid|mc_eid)=[^&]*)(?:&(?:utm_[a-z]+|gclid|gclsrc|dclid|gbraid|wbraid|gad_source|gad_campaignid|srsltid|fbclid|msclkid|ttclid|li_fat_id|igshid|epik|yclid|_gl|mc_cid|mc_eid)=[^&]*)*$" "";4}5 6location @static {7 try_files /static${uri}_$static_args.html $uri $uri/ /index.php?$args;8}Requests that mix a real parameter with a tracking one still miss at nginx and fall through to PHP. That is fine: PHP finds the filtered file already on disk and serves it, and does not write a new one. You will see Statamic's debug level "your server rewrite rules have not been set up correctly" message when that happens, which is noise you can ignore, and it is not logged in production at the usual log level.
Three Other Things That Bloat the Same Index
Query strings do the most damage, but a few other things pile into the same index.
Wildcard invalidation rules. In the invalidation section of the config, a rule of /* means every save loads the entire URL index and walks it. Reserve /* for things that render into the static HTML of every page, such as site settings and navigation globals. If a taxonomy only appears inside a <statamic:nocache> region, it does not need to invalidate anything, and if it appears on three templates, list those URLs instead.
The nocache driver. If you use <statamic:nocache> heavily, every cached page also stores its dynamic regions and their variables. The default is to keep those in your cache store. On a large site, move them to the database:
1'nocache' => 'database',1php please nocache:migration2php artisan migrate --forceSharing a cache store with your application. This one is barely documented anywhere: if you define a cache store named static_cache, Statamic uses it for static caching automatically, no other configuration required. It checks for cache.stores.static_cache and switches to it if it exists.
1// config/cache.php2'stores' => [3 'static_cache' => [4 'driver' => 'redis',5 'connection' => 'static_cache',6 ],7],That gets a large, frequently rewritten array out of the same keyspace as your sessions and application cache, and it lets php please static:clear flush the whole store in one go instead of deleting entries key by key. If you point it at Redis, give it its own database number and be careful with the eviction policy, because if Redis evicts the URL index key your invalidation silently stops working.
Confirm It Worked
Clear the cache and let it rebuild:
1php please static:clear2php please static:warmThen run the same audit you ran at the start. What you want to see is a total that is close to your actual page count plus your legitimate pagination and filter combinations, and a parameter breakdown with no click IDs in it.
A quick manual check is worth doing too. Load a page, load it again with ?gclid=test123 on the end, and confirm the audit count did not go up by one. If it did, work through the three silent no-ops in order: ignore_query_strings is still set to true, you are running Statamic 5.24.0 or older, or you are on full measure and hit the trap above. None of the three will tell you anything is wrong.
Where to Start
If you run Statamic with static caching and any paid advertising at all, spend 5 minutes on the audit today. It costs nothing to look, and the answer is either "fine" or "twenty thousand copies of the same page".
Set an allowlist rather than a blocklist if you can defend the list, keep a blocklist alongside it for documentation, and check which strategy you are on before you assume the config did anything.
And if nobody is watching cache size, memory usage and save times on your site, that is the kind of thing a website maintenance plan should be picking up long before the control panel starts throwing memory errors.
Syntax highlighting by Torchlight