PHPackages                             pattonwebz/wp-stale-cache - 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. pattonwebz/wp-stale-cache

ActiveLibrary[Caching](/categories/caching)

pattonwebz/wp-stale-cache
=========================

Stale-while-revalidate caching for WordPress using Options API and WP-Cron

v0.1.1(3mo ago)00MITPHP ^7.4

Since Apr 9Compare

[ Source](https://github.com/pattonwebz/wp-stale-cache)[ Packagist](https://packagist.org/packages/pattonwebz/wp-stale-cache)[ RSS](/packages/pattonwebz-wp-stale-cache/feed)WikiDiscussions Synced 3w ago

READMEChangelog (2)Dependencies (9)Versions (3)Used By (0)

wp-stale-cache
==============

[](#wp-stale-cache)

> Stale-while-revalidate caching for WordPress using the Options API and WP-Cron.

What it does
------------

[](#what-it-does)

Instead of letting your cache expire and forcing a slow synchronous regeneration on every miss, `wp-stale-cache` keeps serving the last known value while quietly refreshing it in the background.

Three states:

StateConditionBehaviour**Fresh**`now < expires_at`Return cached value immediately.**Stale**`expires_at ≤ now < expires_at + stale_offset`Return old value instantly; schedule a background WP-Cron refresh.**Expired**`now ≥ expires_at + stale_offset`Regenerate synchronously, store, return fresh value.Why Options, not Transients?
----------------------------

[](#why-options-not-transients)

Most WordPress SWR cache libraries — including [`ryanhellyer/stale-cache`](https://github.com/ryanhellyer/stale-cache) and [`humanmade/hm-swr-cache`](https://github.com/humanmade/hm-swr-cache) — store their data in transients. That works for many use cases, but it comes with a silent failure mode: **transients can be evicted**.

When WordPress is running with an external object cache (Memcached, Redis, or any `WP_Object_Cache` drop-in), transients are stored in that layer rather than the database. Under memory pressure, the object cache backend is free to evict entries with no notice. Your cached data disappears. WordPress does not throw an error, log a warning, or return a meaningful signal — it just returns `false` from `get_transient()`. Your code regenerates synchronously, or worse, silently misses.

That is the expected behaviour of transients, they are transient after all.

**WordPress options are different.** `update_option()` writes to the `wp_options` database table unconditionally. A Redis flush, a Memcached restart, or a full object cache wipe does not remove an option. It survives because it lives in the database, not in a volatile memory layer.

`wp-stale-cache` is a **persistent cache** by design. That is the point. If you need a cache entry to survive a Redis flush, a Memcached restart, or a server reboot, use the default `StaleCache` class — it uses the Options API.

If you are comfortable with eviction risk — for example, you want object cache layer support and your data is cheap to regenerate — `TransientStaleCache` is available as an opt-in alternative (see [Using the Transient Backend](#using-the-transient-backend) below). But it is not the default, and it does not reflect this package's design intent.

Requirements
------------

[](#requirements)

- PHP **7.4+**
- WordPress **6.0+**
- No additional PHP extensions beyond `ext-json` (already required)

Installation
------------

[](#installation)

```
composer require pattonwebz/wp-stale-cache
```

> WordPress itself is not a Composer package, so it is not listed as a dependency. Ensure WordPress is loaded before using this library.

Quick start
-----------

[](#quick-start)

### 1. Register the cron handler

[](#1-register-the-cron-handler)

Call this once during plugin or theme initialisation (e.g. `functions.php`):

```
use Pattonwebz\WpStaleCache\CronHandler;

add_action('init', function () {
    CronHandler::register();
});
```

### 2. Cache a value

[](#2-cache-a-value)

```
use Pattonwebz\WpStaleCache\StaleCache;

$cache = new StaleCache(); // default prefix: _wpsc_

$posts = $cache->get(
    'recent_posts',
    [MyDataService::class, 'fetchRecentPosts'],
    3600,       // Fresh for 1 hour
    300         // Serve stale for 5 extra minutes while refreshing in background
);
```

The `generator` parameter must be serializable for the stale-cache path (see [Generator Callable Constraints](#generator-callable-constraints-serialization-for-wp-cron) below). Use named functions or static array callables, not closures.

### 3. Invalidate on change

[](#3-invalidate-on-change)

```
// Delete a single entry (e.g. when a post is saved)
add_action('save_post', function () use ($cache) {
    $cache->forget('recent_posts');
});
```

### 4. Flush by prefix

[](#4-flush-by-prefix)

```
// Flush every option this instance manages
$cache->flush();

// Flush options matching a custom prefix
$cache->flush('_wpsc_products_');
```

### 5. Check state without loading value

[](#5-check-state-without-loading-value)

```
$state = $cache->getState('recent_posts');
// Returns: 'fresh' | 'stale' | 'expired' | 'missing'
```

Generator Callable Constraints (Serialization for WP-Cron)
----------------------------------------------------------

[](#generator-callable-constraints-serialization-for-wp-cron)

When cache is **fresh** or **expired**, the generator runs immediately on the current request — any callable works fine (closures, named functions, static methods, instance methods).

When cache is **stale**, the generator is passed to `wp_schedule_single_event()` for background refresh. WordPress serializes this callable into `wp_options` for deferred execution on a future HTTP request. Only serializable callables survive this round-trip:

- ✅ **Safe:** `'my_function'` (named function string)
- ✅ **Safe:** `[ClassName::class, 'staticMethod']` (static array callable)
- ✅ **Safe:** `[$object, 'method']` (object method array, *if the object implements `__sleep`*)
- ❌ **Unsafe:** Closures — they cannot be serialized and will log a warning instead of scheduling a background refresh

**Recommended approach:** Always use static array callables (`[ClassName::class, 'method']`) for generators to guarantee correct behaviour on all cache paths. If you only ever use this package for immediate (non-stale) cache, closures would work — but that is risky because you cannot always predict which path (fresh/stale/expired) will fire on a given request.

Custom prefix
-------------

[](#custom-prefix)

Pass a prefix string to the constructor to namespace your cache entries:

```
$cache = new StaleCache( '_mysite_cache_' );
```

Storage
-------

[](#storage)

Each cache key creates **two rows** in `wp_options`:

Option nameContains`_wpsc_{key}`The cached value`_wpsc_{key}_meta``['expires_at' => int, 'stale_offset' => int]`Both options are stored with `autoload = false` to avoid loading them on every page.

Tradeoffs
---------

[](#tradeoffs)

The two-option design provides persistence and stale-while-revalidate guarantees, but comes with a cost: **every `get()`, `set()`, and `remove()` operation requires two database lookups or writes** — one for the value, one for the metadata.

This makes `wp-stale-cache` **best suited for longer-lived cached items** — hours or days — where the overhead of two DB operations is negligible compared to the benefit of persistent stale-while-revalidate behaviour and surviving object cache evictions.

**It is not ideal for:**

- **Very short TTLs** (seconds or minutes) — the two DB operations per hit dominate the cost.
- **High-frequency cache operations** — where transients or the object cache layer would be more performant.

For those use cases, consider using WordPress transients directly or `TransientStaleCache` (see [Using the Transient Backend](#using-the-transient-backend) below).

Not suitable for
----------------

[](#not-suitable-for)

- Real-time data (stock prices, live scores) — stale data may be served for up to `stale_offset` seconds.
- Multi-server setups without a shared database — simultaneous regeneration across servers is possible in the expired state (by design; see ADR-005 in `ARCHITECTURE.md`).

Using the Transient Backend
---------------------------

[](#using-the-transient-backend)

`TransientStaleCache` provides the same public API as `StaleCache` but stores data using `set_transient` / `get_transient` / `delete_transient` instead of the Options API. Use it if you specifically want your cache to live in the object cache layer — for example, when you are fine with the data being regenerated on eviction and you want the performance characteristics of Memcached or Redis. The trade-off is clear: **entries may be silently evicted** by the object cache backend under memory pressure. Do not use this class for anything that must survive a Redis flush or a Memcached restart.

```
use Pattonwebz\WpStaleCache\TransientStaleCache;

$cache = new TransientStaleCache( '_wpsc_' ); // same constructor, same API

$posts = $cache->get(
    'recent_posts',
    [MyDataService::class, 'fetchRecentPosts'],
    3600,
    300,
);

$cache->forget('recent_posts');
```

> **Note:** `TransientStaleCache` does not implement a `flush()` method. WordPress provides no native way to query transients by prefix without a direct database query, and that is intentionally deferred to a future version.

Optional Logging
----------------

[](#optional-logging)

`wp-stale-cache` has zero required dependencies beyond PSR-3 interfaces. For logging cache events and background refresh lifecycle, install the optional PSR-3 logger:

```
composer require pattonwebz/psr3-logger
```

Then inject it:

```
use PattonWebz\Psr3Logger\Logger;
use Pattonwebz\WpStaleCache\StaleCache;

$logger = new Logger();
$cache  = new StaleCache( '_myplugin_' );
$cache->set_logger( $logger );
```

For background refresh logging, wire the logger into `CronHandler` too:

```
use PattonWebz\Psr3Logger\Logger;
use Pattonwebz\WpStaleCache\CronHandler;

CronHandler::set_logger( new Logger() );
```

Any PSR-3 compatible logger works — not just `pattonwebz/psr3-logger`. If no logger is injected, all logging is silently suppressed.

Examples
--------

[](#examples)

The examples below use PHP 7.4-compatible positional arguments. **Important:** The generator callable must be serializable (named function, static method, or object method with `__sleep`) because stale-cache entries are refreshed via WP-Cron, which serializes the callable for deferred execution. See [Generator Callable Constraints](#generator-callable-constraints-serialization-for-wp-cron) above for details.

---

### 1. Basic usage

[](#1-basic-usage)

Register the cron handler once during plugin or theme boot, then call `get()` with a generator callable, a TTL (fresh window), and a stale offset (background-refresh window).

```
use Pattonwebz\WpStaleCache\CronHandler;
use Pattonwebz\WpStaleCache\StaleCache;

// Register once — e.g. in the plugin bootstrap or functions.php.
add_action( 'init', function () {
    CronHandler::register();
} );

$cache = new StaleCache(); // default prefix: _wpsc_

// Fresh for 1 hour; serve stale for 5 minutes while WP-Cron refreshes.
$recent_posts = $cache->get(
    'recent_posts',
    [ MyDataService::class, 'get_recent_posts' ], // array callable — serialisable for cron
    3600, // ttl: fresh window in seconds
    300   // stale_offset: extra seconds to serve stale before forcing a sync regeneration
);
```

---

### 2. Custom prefix — namespace per plugin or theme

[](#2-custom-prefix--namespace-per-plugin-or-theme)

Pass a unique prefix string so your keys never collide with another plugin's or the default `_wpsc_` namespace.

```
use Pattonwebz\WpStaleCache\StaleCache;

// All keys are stored as _myplugin_{key} and _myplugin_{key}_meta in wp_options.
$cache = new StaleCache( '_myplugin_' );

$menu_items = $cache->get(
    'primary_nav',
    [ MyMenuHelper::class, 'build_primary_nav' ],
    1800, // fresh for 30 minutes
    120   // serve stale for 2 minutes
);
```

You can create multiple instances with different prefixes in the same project to keep caches logically separated:

```
$products_cache = new StaleCache( '_myshop_products_' );
$settings_cache = new StaleCache( '_myshop_settings_' );
```

---

### 3. Explicit cache busting — `forget()` and `flush()`

[](#3-explicit-cache-busting--forget-and-flush)

Use `forget()` to invalidate a single key and `flush()` to wipe all keys under a prefix.

```
use Pattonwebz\WpStaleCache\StaleCache;

$cache = new StaleCache( '_myplugin_' );

// Invalidate a single entry whenever the underlying data changes.
// Both the value option and its _meta companion are deleted.
add_action( 'save_post', function ( $post_id ) use ( $cache ) {
    if ( wp_is_post_revision( $post_id ) ) {
        return;
    }
    $cache->forget( 'recent_posts' );
} );

// Flush every option managed by this cache instance.
// Useful during plugin deactivation or after a bulk import.
add_action( 'my_plugin_data_import_complete', function () use ( $cache ) {
    $cache->flush(); // flushes all _myplugin_* options
} );

// Flush a different prefix without a separate instance.
// Handy when you need to clear one logical group from a shared context.
add_action( 'switch_theme', function () {
    $cache = new StaleCache( '_mytheme_' );
    $cache->flush( '_mytheme_nav_' ); // clears only nav-related keys
} );
```

You can also inspect whether a key is already stale before deciding to bust it:

```
$state = $cache->get_state( 'recent_posts' ); // 'fresh' | 'stale' | 'expired' | 'missing'

if ( 'fresh' !== $state ) {
    $cache->forget( 'recent_posts' );
}
```

---

### 4. Transient backend — `TransientStaleCache` as a drop-in alternative

[](#4-transient-backend--transientstalecache-as-a-drop-in-alternative)

`TransientStaleCache` exposes the same `get()`, `forget()`, and `get_state()` methods as `StaleCache`. Swap the class name to store data in transients instead of `wp_options`. The trade-off: entries may be silently evicted by the object cache under memory pressure.

```
use Pattonwebz\WpStaleCache\TransientStaleCache;

$cache = new TransientStaleCache( '_myplugin_' );

// Identical call signature to StaleCache::get().
$feed_items = $cache->get(
    'rss_feed',
    [ MyFeedReader::class, 'fetch_items' ],
    900, // fresh for 15 minutes
    180  // serve stale for 3 more minutes
);

// Invalidate a single key.
$cache->forget( 'rss_feed' );

// Note: TransientStaleCache does not implement flush().
// WordPress provides no native API to query transients by prefix,
// so bulk deletion is deferred to a future version.
```

Choose `TransientStaleCache` when:

- Your data is cheap to regenerate on eviction.
- You want the performance characteristics of Memcached or Redis.
- You do **not** need the entry to survive a Redis flush or Memcached restart.

---

### 5. Real-world scenario — caching an external API response

[](#5-real-world-scenario--caching-an-external-api-response)

The following example caches a remote weather API call for 1 hour and serves stale data for 5 minutes while WP-Cron regenerates it silently in the background. The generator is a named static method so it can be serialised for cron scheduling.

```
