PHPackages                             generoi/sage-cachetags - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Caching](/categories/caching)
4. /
5. generoi/sage-cachetags

ActiveLibrary[Caching](/categories/caching)

generoi/sage-cachetags
======================

v2.5.3(1w ago)932.0k↓35.3%1[1 issues](https://github.com/generoi/sage-cachetags/issues)[1 PRs](https://github.com/generoi/sage-cachetags/pulls)MITPHPPHP &gt;=8.2CI passing

Since Jul 29Pushed 1w ago4 watchersCompare

[ Source](https://github.com/generoi/sage-cachetags)[ Packagist](https://packagist.org/packages/generoi/sage-cachetags)[ Docs](https://github.com/generoi/sage-cachetags)[ RSS](/packages/generoi-sage-cachetags/feed)WikiDiscussions master Synced 2d ago

READMEChangelog (10)Dependencies (4)Versions (21)Used By (0)

sage-cachetags
==============

[](#sage-cachetags)

A sage package for tracking what data rendered pages rely on using Cache Tags (inspired by [Drupal's Cache Tags](https://www.drupal.org/docs/drupal-apis/cache-api/cache-tags)).

Example
-------

[](#example)

Front page displays the page content as well as 3 recipe previews. The cache tags might be:

- `post:1` for the front page
- `post:232`, `post:233`, `post:234` for the 3 recipe previews
- `term:123`, `term:124` for a recipe category shown in the recipe previews
- `post:10` for 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
------------

[](#installation)

### Composer

[](#composer)

```
composer require generoi/sage-cachetags
```

### Plugin

[](#plugin)

Download the zip, install like a regular plugin, then follow the [standalone installation](#standalone-without-acorn) instructions below.

### With Acorn (Sage theme)

[](#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)

[](#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
------------

[](#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

[](#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

[](#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

[](#super-cache)

Integration exists if you add the `SuperCacheInvalidator` invalidator in the `config/cachetags.php` file.

### Kinsta

[](#kinsta)

Two invalidators, differing in how Kinsta resolves the purge:

- **`KinstaGroupCacheInvalidator`** (recommended) purges by `group|` — 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 returns `200` on *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.
- **`KinstaCacheInvalidator`** purges by `single|` — 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](#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)

Cloudflare Pro plan supports [HTTP header purging](https://blog.cloudflare.com/introducing-a-powerful-way-to-purge-cache-on-cloudflare-purge-by-cache-tag/) 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

[](#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
--------------------

[](#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 relevant `archive:` / `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`), so `public=false` content 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

[](#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

[](#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

[](#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 coarse `archive:{type}:any` / `taxonomy:{tax}:any` form, 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.)

Front-end tagging
-----------------

[](#front-end-tagging)

With the `Core` action enabled, rendered pages are tagged automatically from the template (single/page, taxonomy, author, post-type/date/search archives, attachments) and from core blocks (queries, terms, authors, comments, calendar/archives, site title/tagline/logo, etc.). Classic-theme `wp_nav_menu()`output is tagged with its `menu:{id}` so menu edits purge the pages showing it.

Site-identity blocks (`core/site-title`, `core/site-tagline`, `core/site-logo`) are tagged with an `option:{name}` tag and purged when that option changes. Adjust which options are tracked with the `cachetags/options` filter:

```
add_filter('cachetags/options', fn (array $options) => [...$options, 'my_option']);
```

Options not bound to a specific block are usually better handled with a full cache flush than by tagging every page that might render them.

### Zero-config auto-tagging

[](#zero-config-auto-tagging)

For themes that render content through custom `WP_Query` loops (related posts, curated lists) rather than the blocks `Core` understands, enable the opt-in `AutoTag` action to tag every queried post and fetched term automatically:

```
use Genero\Sage\CacheTags\Actions\AutoTag;
use Genero\Sage\CacheTags\Actions\Core;

return [
    'action' => [
        Core::class,
        AutoTag::class,
    ],
];
```

It hooks `posts_pre_query` (tagging each returned post, plus an `archive:{type}`for collection queries) and `get_the_terms` (tagging each term). `posts_pre_query`is used rather than `the_posts` because `get_posts()`/`get_children()`/ `get_pages()` force `suppress_filters=true` and so never fire `the_posts` — the pre-query hook fires for every `WP_Query` regardless, so a plain `foreach (get_posts(...) as $post)` loop is covered too. Raw `$wpdb` queries are not (no query object to observe) — tag those explicitly. Page archives are excluded by default — adjust with the `cachetags/autotag-excluded-archive-types`filter. The header-size collapse keeps the broader tag set bounded.

Cacheability
------------

[](#cacheability)

Some responses must never be stored in a shared cache. `Util::isCacheableRequest()`returns `false` for previews and any request showing the admin bar (per-user chrome baked into the HTML), and — by default — for logged-in users. When a request is not cacheable the plugin skips tagging it and defines `DONOTCACHEPAGE`so page caches (WP Super Cache, Batcache, theme cache-control providers) don't store it; edge caches should consult `Util::isCacheableRequest()` from the theme/VCL since their TTL header is sent earlier.

Integrations hook the single `cachetags/cacheable` filter. Responses are vetoed at the default priority; opt-ins that re-enable logged-in users run earlier (priority ` current_user_can('edit_posts') ? $c : true, 5);
    ```

Nonces in cached pages
----------------------

[](#nonces-in-cached-pages)

A page cached for hours can ship a **stale nonce**. WordPress nonces are valid for 12–24h; once one ages out, the action it guards (a form submit, an AJAX "load more", an add-to-cart) starts failing for everyone served the cached page.

Two ways to handle a page that bakes a nonce into its HTML:

1. **Tag it `nonce`.** The page is then purged every 12 hours, before any embedded nonce can expire. The `Nonce` action runs that cron and is enabled by default, so you only need to add the tag where the nonce renders (the `Gravityform`action already does this for file-upload forms):

    ```
    // e.g. a product page that prints a WooCommerce Store API nonce for add-to-cart
    add_action('wp_footer', function () {
        if (function_exists('is_product') && is_product()) {
            \Genero\Sage\CacheTags\CacheTags::getInstance()?->add(['nonce']);
        }
    });
    ```

    Remove `Nonce::class` from the `action` config to opt out of the cron.
2. **Mark it non-cacheable** when the page also shows genuinely real-time data (e.g. live availability), where a 12h refresh isn't enough:

    ```
    add_filter('cachetags/cacheable', fn ($c) => is_page('booking') ? false : $c);
    ```

Note that modern WooCommerce (10.7+) refetches the Store API nonce client-side before a write, so the sitewide Store API nonce is no longer a staleness risk on its own — only nonces that are actually *used as rendered* need this treatment.

Traits for use with roots/sage
------------------------------

[](#traits-for-use-with-rootssage)

### Composers

[](#composers)

```
namespace App\View\Composers;

use Genero\Sage\CacheTags\Concerns\ComposerCacheTags;
use Genero\Sage\CacheTags\Tags\CoreTags;
use Roots\Acorn\View\Composer;
use Illuminate\View\View;

class ContentSingle extends Composer
{
    use ComposerCacheTags;

    protected static $views = [
        'partials.content-single',
    ];

    /**
     * @return array
     */
    public function with()
    {
        $post = get_post();

        return [
            'post' => $post,
            'date' => $this->date($post),
            'authors' => $this->authors($post),
            'excerpt' => $this->excerpt($post),
            'related' => $this->related($post),
            'categories' => $this->categories($post),
        ];
    }

    public function cacheTags(View $view): array
    {
        return [
            ...CoreTags::posts($view->post),
            ...CoreTags::terms($view->categories),
            ...CoreTags::query($this->related())
        ];
    }
}
```

### ACF Blocks

[](#acf-blocks)

```
namespace App\Blocks;

use Genero\Sage\CacheTags\Tags\CoreTags;
use Genero\Sage\CacheTags\Concerns\BlockCacheTags;

class ArticleList extends Block
{
    use BlockCacheTags;

    public $name = 'Article List';
    public $slug = 'article-list';

    public function cacheTags(): array
    {
        $query = $this->buildQuery();

        return [
            ...CoreTags::archive('post'),
            ...CoreTags::query($query),
        ];
    }
}
```

Integrations
------------

[](#integrations)

### WooCommerce, Polylang &amp; Gravity Forms (auto-enabled)

[](#woocommerce-polylang--gravity-forms-auto-enabled)

When WooCommerce, Polylang or Gravity Forms is active, its action is enabled automatically — you don't need to list it in `action`:

- **WooCommerce** keeps cart/checkout/account out of the shared cache and purges a product (plus its archives) on price/stock/status changes.
- **Polylang** makes archive tags language-specific (`archive:post:fi`) so a change in one language only purges that language's listings, and clears the right language archives on publish/unpublish/delete.
- **Gravity Forms** keeps prepopulated forms out of the shared cache and purges a form's pages when it changes. It tags file-upload form pages `nonce`; the always-on [`Nonce` action](#nonces-in-cached-pages) (not Gravity Forms) purges those before the nonce expires.

To manage the action list entirely yourself, turn detection off:

```
// config/cachetags.php
'auto-detect-actions' => false,
```

```
// or on the standalone bootstrap
(new Bootstrap)->autoDetectActions(false)->/* … */->bootstrap();
```

### Flushing all pages, or a whole language

[](#flushing-all-pages-or-a-whole-language)

Every cacheable page and REST response carries a base `page` tag, so a single purge clears all WordPress-served pages at once — static assets (images/CSS/JS), which never carry it, stay cached:

```
wp cachetags clear page
```

Rename it or turn it off with `'base-tag' => null` (config) / `->baseTag(null)`(bootstrap).

When Polylang is active, every page is also tagged with its language, so you can clear all content in one language:

```
wp cachetags clear lang:fi
```

### The `Site` action

[](#the-site-action)

On multisite where one edge (e.g. a single Fastly service) fronts the whole network, enable the `Site` action. It prefixes every tag with the site id (`site:5:post:123`) so a purge on one site never clears same-id content on another, and tags each page with `site:{id}` for a per-site flush-all (`wp cachetags clear site:5`). On a single site you don't need it — the base `page` tag above already gives flush-all without the per-tag prefixing.

Note the `Site` prefix also applies to the base tag, so with `Site` active the flush-all key is per-site `site:5:page` rather than the bare `page`.

### Multisite tables

[](#multisite-tables)

Each site has its own `cache_tags` table, provisioned on activation and when a new subsite is created. Run `wp cachetags database` to (re)scaffold every site — useful after activating on a large network where the activation request can't finish provisioning all of them.

CLI
---

[](#cli)

**With Acorn:**

```
# Flush the entire cache
wp acorn cachetags:flush

# Clear specific tags
wp acorn cachetags:clear post:1 term:5

# Scaffold database table (all sites on multisite)
wp acorn cachetags:database

# Migrate the table to the latest schema (drop + recreate + flush the cache)
wp acorn cachetags:database --rebuild

# Inspect the store: row/tag/url counts and the widest-fan-out tags
wp acorn cachetags:status
# …or the tags a given URL is stored under
wp acorn cachetags:status --url=https://example.com/article/

# Garbage-collect store rows not re-rendered within the age (see note below)
wp acorn cachetags:prune --older-than=30d
```

**Standalone:**

```
# Flush the entire cache
wp cachetags flush

# Clear specific tags
wp cachetags clear post:1 term:5

# Scaffold database table (all sites on multisite)
wp cachetags database

# Migrate the table to the latest schema (drop + recreate + flush the cache)
wp cachetags database --rebuild

# Inspect the store
wp cachetags status
wp cachetags status --url=https://example.com/article/

# Garbage-collect store rows not re-rendered within the age (see note below)
wp cachetags prune --older-than=30d
```

`status` answers "what's bloating the store / why was this purged so widely" — a tag with a high URL count purges that many pages on a single change. It requires a store that supports inspection (the default `WordpressDbStore` does).

`prune` garbage-collects store rows whose URL hasn't been rendered within the given age (`12h`/`30d`/`4w`) — query-string, bot and campaign-link variants that otherwise accumulate forever, especially on query-bypass edges. A row's age is *last seen* (refreshed on each render, at most once a day to avoid write churn), so actively-served pages are never pruned.

This **runs daily by default** (the `prune-older-than` config, default `30d`) — the manual command above just forces a run. **The age must exceed your edge cache's max TTL**: pruning a URL still cached at the edge leaves an object you can no longer purge by tag (on Kinsta/Fastly the max TTL is ~30d, so raise it if your edge holds objects that long). Disable GC with `'prune-older-than' => null`. Only a prunable store (the default `WordpressDbStore`) is affected — `TransientStore`no-ops.

The store is a rebuildable cache, so `--rebuild` migrates an existing (even million-row) table to the latest schema by dropping and recreating it — avoiding a slow, locking `ALTER` — and flushes the edge so nothing is left stale while the store refills. Run it in a low-traffic window; the cold cache warms as pages re-render.

API
---

[](#api)

### Accessing CacheTags instance

[](#accessing-cachetags-instance)

**With Acorn:**

```
use Genero\Sage\CacheTags\CacheTags;

// Get instance from container
$cacheTags = app(CacheTags::class);
```

**Standalone:**

```
use Genero\Sage\CacheTags\CacheTags;

// Get the singleton instance
$cacheTags = CacheTags::getInstance();
```

### Building tags with `Tag`

[](#building-tags-with-tag)

Tags are `Tag` value objects — fluent to build, serialized to their string form (`post:5`, `archive:post:any`, `site:5:term:9`) only at the edge (header, store, purge). `add()` and `clear()` accept Tags, plain strings, and nested arrays interchangeably, so you rarely touch `Tag` directly — but it's there when you want type-safety or context.

```
use Genero\Sage\CacheTags\Tag;

Tag::post(5);                    // post:5
Tag::archive('product');         // archive:product
Tag::archive('product')->any();  // archive:product:any  (any product changing)
Tag::term(9)->full();            // term:9:full
Tag::option('blogname');         // option:blogname
Tag::of('my-type', $id);         // an arbitrary custom type
```

Context is two general, composable operations — `scope()` to namespace a tag and `qualify()` for a variant — so new dimensions (a multisite network, a tenant) need no new API:

```
Tag::post(5)->scope('site', 5);                       // site:5:post:5
Tag::post(5)->scope('network', 2)->scope('site', 5);  // network:2:site:5:post:5
Tag::archive('post')->qualify($lang);                 // archive:post:fi
```

The builder classes (`CoreTags`, `WooCommerceTags`, `SiteTags`, `PolylangTags`, `GravityformTags`) return `Tag[]`; pass them — and any plain strings — straight to `add()`/`clear()`. Both take tags as individual arguments or arrays, nested freely:

```
$cacheTags->add(Tag::nonce());                 // a single tag
$cacheTags->add('post:5', Tag::term(9));       // several arguments
$cacheTags->add([
    Tag::archive('product'),
    ...CoreTags::posts($ids),                  // CoreTags returns Tag[]
    'my:custom:tag',                           // plain strings still work
]);
```

### Create a custom tag

[](#create-a-custom-tag)

The nicest way is to look at the code of this repo and create a custom `Action`, but the logic is really nothing more than:

**With Acorn:**

```
use Genero\Sage\CacheTags\CacheTags;
use Genero\Sage\CacheTags\Tag;

// Tag content (a bare string, or Tag::of('custom-tag') / Tag::of('thing', $id))
app(CacheTags::class)->add(['custom-tag']);

// Clear it whenever you want
\add_action('custom/update', fn() => app(CacheTags::class)->clear(['custom-tag']));
```

**Standalone:**

```
use Genero\Sage\CacheTags\CacheTags;

// Tag content
CacheTags::getInstance()?->add(['custom-tag']);

// Clear it whenever you want
\add_action('custom/update', fn() => CacheTags::getInstance()?->clear(['custom-tag']));
```

###  Health Score

56

—

FairBetter than 97% of packages

Maintenance96

Actively maintained with recent releases

Popularity35

Limited adoption so far

Community13

Small or concentrated contributor base

Maturity66

Established project with proven stability

 Bus Factor1

Top contributor holds 97.8% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~75 days

Recently: every ~23 days

Total

15

Last Release

10d ago

Major Versions

v1.3.0 → v2.0.02026-02-11

PHP version history (2 changes)v1.0.0PHP &gt;=7.4

v2.0.0PHP &gt;=8.2

### Community

Maintainers

![](https://avatars.githubusercontent.com/u/302736?v=4)[oxyc](/maintainers/oxyc)[@oxyc](https://github.com/oxyc)

---

Top Contributors

[![oxyc](https://avatars.githubusercontent.com/u/302736?v=4)](https://github.com/oxyc "oxyc (135 commits)")[![thunderdw](https://avatars.githubusercontent.com/u/85177135?v=4)](https://github.com/thunderdw "thunderdw (2 commits)")[![toffebjorkskog](https://avatars.githubusercontent.com/u/1155260?v=4)](https://github.com/toffebjorkskog "toffebjorkskog (1 commits)")

###  Code Quality

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/generoi-sage-cachetags/health.svg)

```
[![Health](https://phpackages.com/badges/generoi-sage-cachetags/health.svg)](https://phpackages.com/packages/generoi-sage-cachetags)
```

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
