Install
The author publishes release zips, so WP-CLI can install straight from GitHub:
wp plugin install https://github.com/generoi/sage-cachetags/releases/download/v2.5.3/sage-cachetags.zipReadme
sage-cachetags
A sage package for tracking what data rendered pages rely on using Cache Tags (inspired by Drupal's Cache Tags).
Example
Front page displays the page content as well as 3 recipe previews. The cache tags might be:
post:1for the front pagepost:232,post:233,post:234for the 3 recipe previewsterm:123,term:124for a recipe category shown in the recipe previewspost:10for a product name featured in one of the 3 recipes.
This set of tags will be gathered while rendering the page and then stored in the database and optionally added as a HTTP header.
When any of the posts or terms are updated, page caches and reverse proxies know that the front page cache should be cleared.
Installation
Composer
composer require generoi/sage-cachetags
Plugin
Download the zip, install like a regular plugin, then follow the standalone installation instructions below.
With Acorn (Sage theme)
Start by publishing the config/cachetags.php configuration file using Acorn:
wp acorn vendor:publish --provider="Genero\Sage\CacheTags\CacheTagsServiceProvider"
Edit it to your liking and if you're using the database store, scaffold the required database table:
wp acorn cachetags:database
Standalone (without Acorn)
For WordPress sites without Acorn, use the Bootstrap class in your theme's functions.php or a mu-plugin. The Bootstrap class provides a fluent interface for configuration:
use Genero\Sage\CacheTags\Bootstrap;
use Genero\Sage\CacheTags\Actions\Core;
use Genero\Sage\CacheTags\Actions\HttpHeader;
use Genero\Sage\CacheTags\Invalidators\SuperCacheInvalidator;
use Genero\Sage\CacheTags\Stores\WordpressDbStore;
// Bootstrap CacheTags using fluent interface
(new Bootstrap())
->store(WordpressDbStore::class)
->invalidators([SuperCacheInvalidator::class])
->actions([Core::class, HttpHeader::class])
->debug(defined('WP_DEBUG') && WP_DEBUG)
->httpHeader('Cache-Tag')
->bootstrap();
If you're using the database store, scaffold the required database table using WP-CLI:
wp cachetags database
Schema changes are migrated automatically on the next admin request after an
update (the table version is tracked in the cachetags_db_version option). On
headless or multisite setups, run wp cachetags database to apply migrations
across all sites.
Invalidators
Currently it supports Kinsta Page Cache, WP Super Cache, SiteGround Optimizer, WP Rocket and Fastly. You can use multiple invalidators if you eg use Fastly in front of Kinsta and want to invalidate both.
Coarse tags and bulk purges. A coarse tag like archive:post can resolve to
many stored URLs (every page that lists posts), which URL-based invalidators must
handle without firing thousands of individual purges, since those providers
effectively rate-limit purges. They differ in how:
- SiteGround (and similar) escalate to a full cache flush past a tunable
threshold (
cachetags/siteground-bulk-purge-threshold) — over-purging is safe when we can't know the exact URL set, though on a busy editorial site a single publish can flush the whole cache. - Kinsta instead routes bulk purges to its throttled endpoint, which coalesces
them server-side — no full flush. The
KinstaGroupCacheInvalidator(prefix purges) cuts the request count further; it's the recommended Kinsta setup.
Fastly is unaffected — it purges by Surrogate-Key, so URL count is
irrelevant, which makes it the best fit for high-frequency editorial sites.
Stored URL and query strings
Front-end pages are stored under the actual requested URL (including its query
string), so a URL-based purge matches the variant a page cache keyed on. A default
set of tracking/volatile params (utm_*, gclid/fbclid/dclid/…, _wpnonce,
_) is stripped and the rest sorted; keys longer than the varchar(191) column
fall back to the path.
On a query-bypass edge — Fastly (purges by Surrogate-Key, ignores the URL)
or Kinsta (query-string URLs bypass the cache entirely) — those query-string rows
are never cached and so never need purging; they just accumulate in the store
(one row per visited ?… combination, including bot/scanner params). A
query-bypass site with heavy parameterised traffic can keep the store lean by
storing the path only:
add_filter('cachetags/store-query-string', '__return_false');
To match a URL-keyed edge that does cache query strings (SiteGround, or Kinsta configured to cache GET params) the strip list must equal that edge's — and that's site-specific (our own Fastly VCLs strip anywhere from 5 to 16 params), so align it per site:
add_filter('cachetags/url-ignored-params', fn ($p) => [...$p, 'campaign_id', 'tduid']);
Comprehensive query-param normalization is better done at the edge (CDN/VCL) than replicated here.
SiteGround Optimizer
Integration exists if you add the SiteGroundCacheInvalidator invalidator in the config/cachetags.php file.
When more than 50 URLs need purging, the invalidator performs a full cache flush instead of purging each URL individually. This avoids overwhelming SiteGround's cache API with thousands of synchronous requests. The threshold is configurable:
// Change the threshold (default: 50)
add_filter('cachetags/siteground-bulk-purge-threshold', fn () => 100);
// Always flush (never purge individual URLs)
add_filter('cachetags/siteground-bulk-purge-threshold', fn () => 0);
Super Cache
Integration exists if you add the SuperCacheInvalidator invalidator in the config/cachetags.php file.
Kinsta
Two invalidators, differing in how Kinsta resolves the purge:
KinstaGroupCacheInvalidator(recommended) purges bygroup|— a prefix wildcard that clears a path together with everything beneath it: its pagination (/shop/page/2/) and its query-string variants (/shop/?orderby=…) in one request. It disables query-string storage (cachetags/store-query-string) since the bare path is enough, keeping the store lean. This is the right choice for a standard Kinsta setup, where query-string URLs bypass the cache anyway. Collapsing many URLs into a single prefix purge also keeps purge volume low — Kinsta dispatches purges to its edge (Cloudflare) asynchronously and rate- limits/coalesces them server-side, and the localhost endpoint returns200on accept (downstream throttling is invisible to the request), so fewer, coarser purges are the most effective way to stay under those limits. Bulk purges are additionally routed to Kinsta's throttled endpoint rather than a full flush.KinstaCacheInvalidatorpurges bysingle|— the exact URL only. Use this if you've configured Kinsta to cache query-string URLs and need each variant purged by its full stored URL (see Stored URL and query strings).
Add one of them to the invalidator list in config/cachetags.php. The site
root (/) is always purged exactly, so a group purge never flushes the whole
site.
Cloudflare
Cloudflare Pro plan supports HTTP header purging but an invalidor doesn't exist at the moment. If you're up for it, take a look at the Fastly one as an example.
Fastly
There's both a FastlySoftCacheInvalidator and a FastlyCacheInvalidator (hard) cache invalidator for Fastly (Varnish) proxy cache. Using this set up you do not need a persistent store since Fastly works with HTTP headers. Example config/cachetags.php
$isProduction = in_array(parse_url(WP_HOME, PHP_URL_HOST), [
'www.example.com',
]);
return [
'http-header' => 'Surrogate-Key',
'store' => CacheTagStore::class,
'invalidator' => array_filter([
$isProduction ? FastlySoftCacheInvalidator::class : null,
]),
'action' => [
Core::class,
HttpHeader::class,
],
];
REST API integration
For headless/decoupled setups where pages are served from the WordPress REST
API, enable the RestApi action to tag REST read responses so a frontend or
CDN can purge them by cache tag:
use Genero\Sage\CacheTags\Actions\Core;
use Genero\Sage\CacheTags\Actions\HttpHeader;
use Genero\Sage\CacheTags\Actions\RestApi;
return [
'http-header' => 'Cache-Tag',
'action' => [
Core::class,
HttpHeader::class,
RestApi::class,
],
];
Keep Core enabled alongside it: block-derived tags from content.rendered
are still collected through Core's render_block hook during the REST request.
What gets tagged:
- Single resources (
/wp/v2/posts/123,/wp/v2/categories/5,/wp/v2/users/2,/wp/v2/comments/9) — the object itself, plus a post's related terms, author, featured media and parent. - Collections (
/wp/v2/posts) — each item plus the relevantarchive:/taxonomy:listing tag. The listing tag is added even for empty/filtered collections, so they refresh when their membership changes. - Search (
/wp/v2/search) — each matched post/term. - Headless post types — public types plus any non-builtin post type/taxonomy
exposed to REST (
show_in_rest), sopublic=falsecontent types are covered.
Only responses that may be publicly cached are tagged: requests are skipped when
they are authenticated, use context=edit, carry a password, or are not
GET/HEAD. The edge must strip the Cache-Tag header before it reaches
clients.
Each response is stored under its canonical URL with sort-normalized query
parameters, so variants that produce a different response — pagination/filters
(?page=2, ?categories=5), context, and the server params that shape the
body (_embed, _fields, _envelope, _locale) — get distinct, CDN-matching
store keys and are purged separately. Parameters the route doesn't register (and
aren't response-shaping) are dropped so arbitrary client params can't fork the
key. Only the random per-request params _wpnonce and _ are stripped
unconditionally — any cache entry keyed on them is never reused, so collapsing
them can't cause staleness.
Custom routes
The RestApi action only knows about core wp/v2 objects. A custom public
route that serves its own cacheable response (sets its own
Cache-Control: public, s-maxage=…, e.g. my-plugin/v1/people) is cached at
the edge but never purged unless it declares the cache tags its data depends on.
Do it the same way the front end does — add the tags while building the response,
from the CacheTags instance (app(CacheTags::class) with Acorn, or
CacheTags::getInstance() standalone). With RestApi/HttpHeader enabled they're
emitted and stored on rest_post_dispatch:
public function handle(WP_REST_Request $request): WP_REST_Response
{
$people = $this->search($request);
CacheTags::getInstance()?->add([
'archive:person',
...array_map(fn ($p) => "post:{$p->id}", $people),
]);
return rest_ensure_response($people);
}
If the endpoint manages its own Cache-Control and you want full control, set
the header yourself (and save() the URL for url-based purge):
$cacheTags->add($tags);
$cacheTags->save($request->get_route());
$response->header('Cache-Tag', implode(' ', $tags));
Purge them from a small custom Action that hooks the relevant
transition_post_status / meta / term events and calls $cacheTags->clear([...]),
mirroring Core. (For a third-party route you can't edit, the cachetags/rest-tags
filter below is the fallback.)
Filters
// Tag bespoke REST routes that don't map to a core object.
add_filter('cachetags/rest-tags', function (array $tags, WP_REST_Request $request) {
return $request->get_route() === '/my-plugin/v1/feed'
? [...$tags, 'archive:post']
: $tags;
}, 10, 2);
// Add or trim the related dependencies tagged for a post response.
// The matched WP_REST_Request is also passed as a third argument.
add_filter('cachetags/rest-related-tags', function (array $tags, WP_Post $post) {
return $tags;
}, 10, 2);
// Change which query parameters are ignored when building the store URL.
add_filter('cachetags/rest-url-ignored-params', fn (array $params) => [...$params, 'preview']);
Header size limits
Cache providers cap the tag header — Fastly's Surrogate-Key allows 1024 bytes
per key and 16384 bytes total, and silently drops the offending key and every
key after it once a limit is reached, which would leave content stale. To stay
safe (for both front-end pages and REST responses):
- Tags that aren't valid single header tokens — containing whitespace/control characters, or longer than the store column (191 bytes) — are dropped.
- When the combined header would exceed the byte budget, the per-object
post:/term:tags are collapsed to their coarsearchive:{type}:any/taxonomy:{tax}:anyform, which is purged on any change to that post type or taxonomy. This over-purges rather than dropping tags.
// Tag header byte budget before collapsing to coarse tags (default 16384,
// Fastly's Surrogate-Key total). Tune it for a provider with a different limit.
add_filter('cachetags/max-header-bytes', fn () => 8192);
(The single-tag length cap — 191, the varchar(191) store column — and the
header-token validation pattern are fixed, not filterable: they're tied to the
schema and to header safety.)
Read the full README on GitHub →
Releases
| Tag | Published | Asset | Downloads |
|---|---|---|---|
| v2.5.3 | Jun 23, 2026 | sage-cachetags.zip | 1 |
| v2.5.2 | Jun 23, 2026 | sage-cachetags.zip | 0 |
| v2.4.0 | Mar 24, 2026 | sage-cachetags.zip | 0 |
| v2.3.2 | Feb 26, 2026 | sage-cachetags.zip | 0 |
| v2.3.1 | Feb 26, 2026 | sage-cachetags.zip | 0 |
| v2.3.0 | Feb 17, 2026 | sage-cachetags.zip | 1 |
| v2.2.0 | Feb 11, 2026 | sage-cachetags.zip | 4 |
| v2.1.0 | Feb 11, 2026 | — | — |
| v2.0.0 | Feb 11, 2026 | — | — |
| v1.3.0 | Sep 30, 2025 | — | — |
| v1.2.0 | Jul 24, 2024 | — | — |
| v1.1.0 | Jul 15, 2024 | — | — |
| v1.0.0 | Jul 29, 2023 | — | — |