The previous lesson built a cache keyed on a hash of the prompt. That solves one problem and creates another: if the source content changes but you keep asking the same question about it, in this case “summarize this post,” your hash key doesn’t change either, since the prompt text you send is built from the post’s title and content at generation time. Re-hash the new content and you get a new key, which means the old cached response for the previous version just sits there, valid by its own TTL, wrong for what the post actually says now. This lesson is about tying invalidation to the moment content actually changes, not just letting a TTL eventually catch up to reality.
The caching wrapper and hash-based keying from the previous lesson. This lesson adds an invalidation layer on top of it rather than replacing the caching mechanism itself.
Step 1: TTL alone silently serves stale output after an edit
A cache with a one-hour TTL feels safe until you notice what it actually guarantees: content edited at minute one is served through its old, cached AI summary until minute sixty, no matter how wrong that summary has become. For a typo fix, that’s harmless. For a correction to a factual claim, a price, or a safety instruction, an hour of a wrong AI-generated summary sitting on a live page is a real problem, not a cosmetic one.
Treat a TTL as “how long we’re willing to be wrong for at most,” never as “how fresh this is.” If a piece of content can change and that change matters, invalidate on the actual change event. Reserve the TTL for content that ages on its own even when nothing is edited, covered in Step 4.
Step 2: Track which cache keys belong to which post
save_post fires with a post ID, but the cache key from the previous lesson is a hash
with no post ID embedded in it. To invalidate on save, you need a second, small mapping
from post ID to the cache keys generated from it:
// File: my-plugin/class-ai-cache.php
function my_plugin_remember_ai_cache_key_for_post( int $post_id, string $cache_key ): void {
$known_keys = get_post_meta( $post_id, '_ai_cache_keys', true );
$known_keys = is_array( $known_keys ) ? $known_keys : array();
$known_keys[] = $cache_key;
update_post_meta( $post_id, '_ai_cache_keys', array_unique( $known_keys ) );
}
Call this alongside my_plugin_set_cached_ai_response() from the previous lesson,
every time you generate and cache an AI response that’s derived from a specific post’s
content:
// File: my-plugin/class-post-summarizer.php
function my_plugin_get_post_summary( int $post_id ) {
$post = get_post( $post_id );
$prompt = 'Summarize in two sentences: ' . $post->post_title . "\n\n" . $post->post_content;
$system = 'You are a concise editorial summarizer.';
$key = my_plugin_ai_cache_key( $prompt, $system );
$cached = my_plugin_get_cached_ai_response( $key );
if ( null !== $cached ) {
return $cached;
}
$result = wp_ai_client_prompt( $prompt )
->using_system_instruction( $system )
->generate_text();
if ( is_wp_error( $result ) ) {
return $result;
}
my_plugin_set_cached_ai_response( $key, $result, DAY_IN_SECONDS );
my_plugin_remember_ai_cache_key_for_post( $post_id, $key );
return $result;
}
Step 3: Clear the known keys when the post actually changes
save_post fires on every save, including autosaves and revisions, so guard it, then
clear every cache key this post has ever produced:
// File: my-plugin.php
add_action( 'save_post', function ( int $post_id, WP_Post $post ) {
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
return;
}
$known_keys = get_post_meta( $post_id, '_ai_cache_keys', true );
if ( ! is_array( $known_keys ) ) {
return;
}
foreach ( $known_keys as $cache_key ) {
delete_transient( $cache_key );
}
delete_post_meta( $post_id, '_ai_cache_keys' );
}, 10, 2 );
The next call to my_plugin_get_post_summary() after a save finds nothing cached,
regenerates against the edited content, and rebuilds the key mapping from scratch. The
one-hash-per-version property from the previous lesson means you never serve one
version’s summary for another version, this hook just makes sure the old version’s
cached entry doesn’t linger unused in storage after it’s no longer reachable.
On a site with a real persistent object cache backend, storing all of a post’s AI
responses under one cache group (for example 'ai-summaries-post-' . $post_id) and
calling wp_cache_flush_group() on save_post avoids maintaining the key-list meta
field entirely. This requires a backend that supports group flushing; without one, the
key-tracking approach above works on plain transients everywhere.
Step 4: TTL is still the right tool for content that ages without being edited
Not everything that goes stale gets edited first. A summary of “current pricing” built from a page nobody has touched in months can still be wrong if pricing changed elsewhere and this page just wasn’t updated. For content like this, a TTL is a deliberate decision, not a fallback:
// File: my-plugin/class-post-summarizer.php
$ttl = has_term( 'evergreen', 'category', $post_id ) ? WEEK_IN_SECONDS : DAY_IN_SECONDS;
my_plugin_set_cached_ai_response( $key, $result, $ttl );
Shorter TTLs for content categories that tend to drift out of date without a
corresponding edit, longer TTLs for content that’s genuinely stable once written. The
save_post invalidation from Step 3 and a TTL are not competing strategies, they cover
two different kinds of staleness: content that changed and content that just aged.
Test it
Generate and cache a summary, edit the post, and confirm the cache is gone before the TTL would have expired it on its own:
wp-env run cli wp eval '
$post_id = 123;
echo "Before edit: " . my_plugin_get_post_summary( $post_id ) . "\n";
wp_update_post( array( "ID" => $post_id, "post_content" => "Completely different content." ) );
$known = get_post_meta( $post_id, "_ai_cache_keys", true );
var_dump( $known ); // should be empty/false right after save_post ran
echo "After edit: " . my_plugin_get_post_summary( $post_id ) . "\n";
'
The “after edit” summary should reflect the new content, confirming the save hook cleared the stale entry rather than waiting out its TTL.
Recap
A TTL alone bounds how long a stale AI response can live, it doesn’t prevent serving it
during that window. Track which cache keys a piece of content has produced, clear them
on save_post (guarding against autosaves and revisions), and keep TTL-based expiry as
a separate, deliberate tool for content that ages without being edited. Together, these
two mechanisms cover both ways AI-generated content actually goes stale.
Resources & further reading
- Transients API, developer.wordpress.org
- save_post action reference, developer.wordpress.org
- WP_Object_Cache class reference, developer.wordpress.org