The Dashed Circle on the ‘I Had Potential’ Shirt Is the Whole Argument

The shirt reads I HAD POTENTIAL in white capitals. Beneath the words, a white curved ramp descends from an upper-left ledge to a lower-right flat ledge. At the top of the ramp, a dashed empty circle sits where a ball would rest. At the bottom, a solid white ball sits on the flat. The ramp is curved, not a straight diagonal. That curve is the first clue that this is not a generic slogan shirt with a physics sticker slapped on it.

The design is a diagram of a system that has already run. The dashed circle is the initial state: a ball at rest at the top of a potential well, marked as absent because the event has already happened. The solid ball is the final state: the same object, now at the bottom, having converted potential energy into motion and then into rest again. The words above the diagram are not a caption. They are the system’s own log line, written in the past tense because the transition is complete.

That is the useful argument of this shirt: it treats potential as a technical term with a precise meaning, then uses the diagram to show that the meaning has already been spent. The joke is not that the wearer is lazy. The joke is that the wearer is describing a closed system. The ball did what balls do. The ramp did what ramps do. The dashed circle is the only remaining evidence that anything else was ever possible.

What the Drawing Actually Shows

Look at the geometry. The dashed circle is not a ghost or a motion trail. It is an empty outline at the upper ledge, the same size as the solid ball below. In physics-diagram conventions, a dashed outline often marks a position that is no longer occupied or a boundary that is not a physical object. Here it reads as the starting position, vacated. The solid ball at the lower right is the current position, at rest on a flat section. The curved ramp connects them. The curve matters because a straight diagonal would read as a simple slide, a one-step drop. A curve implies acceleration, a changing slope, a path with its own dynamics. The ball did not fall; it rolled.

The words are set in plain capitals, no serif, no decoration. The diagram is white on the garment, matching the text. There is no arrow, no axis, no label other than the sentence itself. That minimalism is doing work: it presents the scene as a finished result rather than a problem to be solved. A diagram with arrows and labels would invite the viewer to calculate. This diagram invites the viewer to recognize. The calculation is over. The result is on the shirt.

The phrase I had potential is grammatically past tense. The diagram is grammatically present tense: the ball is there now. The tension between those two tenses is the joke. The potential is gone, but the ball remains. The shirt does not say the ball was wasted. It says the ball is where the ramp put it. That is a more precise claim than the usual sarcastic slogan, and it is why the design holds up under inspection.

Why the Double Meaning Matters

In everyday speech, potential means promise: the quality of being capable of more. In physics, potential energy is the energy an object has because of its position in a field. The shirt uses both meanings at once, and the diagram keeps them from collapsing into each other. The dashed circle is the everyday meaning: the shape of what could have been. The solid ball is the physics meaning: the object that actually moved. The ramp is the field. The words are the narrator’s summary.

This is not a new observation about the phrase. The phrase I had potential is a common meme construction, and the shirt’s retail title calls it a physics pun. But the drawing is what makes the pun specific. A shirt that only said I had potential would be a generic self-deprecation. A shirt that only showed a ball on a ramp would be a physics diagram. The combination works because the diagram supplies the evidence that the sentence withholds. The sentence says the potential is gone. The diagram shows where it went.

That is also why the shirt is not really about laziness. Laziness would be a ball that never left the top ledge. This ball left. The dashed circle is empty because the ball moved. The joke is not that the wearer failed to act. The joke is that the wearer is describing the outcome of having acted, with the flat tone of a system log. The potential was real. The ramp was real. The result is a ball at the bottom. The shirt is a post-mortem, not an excuse.

The Difference Between a Slogan and a Usable Promise

Technical readers spend a lot of time around promises: API contracts, SLAs, schema guarantees, deprecation notices. A slogan is a sentence that sounds like a promise but has no mechanism behind it. A usable promise names the conditions under which it holds. The I Had Potential shirt is funny because it is a slogan that has been converted into a diagram, and the diagram shows the mechanism. The promise was potential. The mechanism was a ramp. The outcome was a ball at rest. Nothing is missing except the part where the potential was supposed to turn into something else.

That is the work-culture reading, and it is worth stating plainly: the shirt is a boundary object. It can be worn as self-deprecation, as a comment on a project that shipped and then flattened, or as a quiet refusal to dress up a closed system as an open one. The diagram does not accuse anyone. It just shows the states. The dashed circle is the state that is no longer occupied. The solid ball is the state that is. The words are the transition log.

If you want the object itself, the shirt is the I Had Potential Funny Physics Pun Sarcastic Minimalist Quote from Quirky Cotton Collective. The design is the argument. The rest is garment.

Reading the Shirt as a State Machine

A state machine has states, transitions, and a current state. This shirt has two visible states: the dashed circle at the upper ledge and the solid ball at the lower flat. The transition is the curved ramp. The current state is the solid ball. The words I HAD POTENTIAL are the transition log, written after the fact. There is no arrow back up the ramp. The diagram is a one-way system, and the shirt is honest about it.

That honesty is what separates this design from a generic sarcastic tee. The shirt does not claim the ball could roll back up. It does not claim the ramp was unfair. It does not claim the dashed circle was a better ball. It shows a completed transition and lets the viewer supply the tone. The tone can be dry, resigned, or amused. The diagram does not care. It is a diagram.

For readers who spend their days tracing symptoms to root causes, that is a familiar posture. You look at the current state, you find the transition that produced it, and you stop arguing with the physics. The shirt is a small, wearable version of that posture. It is not a lesson. It is a diagram with a sentence on top, and the sentence is in the past tense because the transition is done.

Editorial note: This publication and Quirky Cotton Collective share an owner.

Why Your Updated YouTube URL Still Renders the Old Embed: Diagnosing the _oembed_* Hash Key in wp_postmeta and the Invalidation WordPress Never Runs

You changed the YouTube URL in the post. You saved. The editor showed the new URL. The front end still renders the old video’s iframe. The post content is correct; the rendered HTML is not. This is not a caching plugin problem, not a theme problem, and not a YouTube problem. It is a key-mismatch problem in wp_postmeta, and the invalidation routine that would fix it is documented as unused by core.

Symptom

The editor updates the URL. The post saves cleanly. The front end serves the previous embed’s HTML. If you inspect the rendered page, the iframe’s video ID is the old one. If you inspect the post content in the database, the URL is the new one. Both facts are true simultaneously because the rendered embed does not come from the post content — it comes from a post meta row keyed by a hash of the URL.

Before touching post meta, rule out page-level caching and CDN HTML caching. A stale rendered page and a stale _oembed_ row produce the same visual symptom and have completely different fixes. The retrieved sources for this article do not cover host or CDN cache layers, so treat that check as a prerequisite, not a conclusion. If a hard refresh with cache bypass still shows the old iframe, continue.

Trace

The relevant code path is WP_Embed::shortcode(), which is registered as the shortcode callback and also runs via autoembed() on the_content. The method derives a cache key from the URL and the shortcode attributes:

$key_suffix = md5( $url . serialize( $attr ) );
$cachekey = '_oembed_' . $key_suffix;
$cachekey_time = '_oembed_time_' . $key_suffix;

That is from the WP_Embed::shortcode() reference. When a post ID is available, the method reads the cached HTML and cache time from that post’s meta using those keys:

if ( $post_id ) {
    $cache = get_post_meta( $post_id, $cachekey, true );
    $cache_time = get_post_meta( $post_id, $cachekey_time, true );

The TTL defaults to one day and is filterable:

$ttl = apply_filters( 'oembed_ttl', DAY_IN_SECONDS, $url, $attr, $post_id );

The cached branch is entered when either $this->usecache is true or the cache is recent. If the cached value is the sentinel {{unknown}}, the method returns the URL as a link rather than an embed. If the cached value is non-empty HTML, it is passed through the embed_oembed_html filter and returned. The filter reference describes its first parameter as “the cached HTML result, stored in post meta.”

So the rendered iframe on the front end is not read from post_content. It is read from wp_postmeta under a key derived from the URL. Change the URL, and the derived key changes. The old key’s row is not overwritten; it is orphaned. The new key has no row yet, so the next render on the front end will fetch fresh HTML from the provider and write a new row — unless something prevents that fetch path from running, or unless the front end is serving a page cached before the save.

Root cause

The mental model “the cache is stale” is slightly wrong and leads to the wrong fix. The cache is not stale. It is keyed to a URL that no longer appears in the post. The old row is dead weight; the new URL has no row. The correct model is a key mismatch, not a stale value.

WordPress ships a method that would clean this up. WP_Embed::delete_oembed_caches() iterates the post’s meta keys and deletes every one beginning with _oembed_:

foreach ( $post_metas as $post_meta_key ) {
    if ( str_starts_with( $post_meta_key, '_oembed_' ) ) {
        delete_post_meta( $post_id, $post_meta_key );
    }
}

The reference page for that method states plainly: “Deletes all oEmbed caches. Unused by core as of 4.0.0.” The WP_Embed class listing repeats the same note. The method exists; core does not call it.

A 2012 wp-trac mailing-list message records a proposal to move oEmbed cache deletion to pre_post_update so cached metadata would be more likely to be used. That is a proposal recorded on a mailing list, not evidence that shipped core ever hooked delete_oembed_caches() to save_post. The documented fact is the one on the reference page: as of 4.0.0, core does not run it.

The practical consequence for a self-maintained install is that oEmbed meta is data the site owns and must occasionally groom, the same way it treats transients or orphaned post meta. WordPress will not reconcile it on save.

The TTL nuance that explains “sometimes it fixes itself”

Publishers often report that the embed eventually corrects itself. That is consistent with the code. The cached branch is gated on $this->usecache || $cached_recently. On a normal front-end render, usecache is true, so the cached HTML is served regardless of age. The TTL matters on paths that set usecache = false — notably WP_Embed::cache_oembed(), which the reference shows setting usecache to false while re-running the shortcode and autoembed over the post content, then restoring it to true.

That is why behavior differs between the front end and the admin re-cache path. The front end will happily serve a cached row indefinitely as long as the key matches. The admin path can force a fresh fetch. If the publisher’s symptom resolves after an admin save but not after a front-end reload, the difference is the usecache flag, not the TTL.

The {{unknown}} sentinel

Failures are cached too. When wp_oembed_get() returns false and there is no existing cache, WP_Embed::shortcode() writes the literal string {{unknown}} under the cache key. On subsequent renders, the cached branch sees that sentinel and returns the URL as a link via maybe_make_link(). A publisher who sees a bare link where an embed used to be may be looking at a cached failure from a transient provider outage, not a provider outage happening right now. The troubleshooting will be mis-timed unless the stored value is inspected.

Fix

The fix is a deliberate, narrowly scoped purge of the affected post’s _oembed_ meta, followed by a render that repopulates the new key. Do not run a blanket DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%' on a live publishing site. That forces every embedded URL across the site to be re-fetched on next render, which is a self-inflicted provider-rate and page-latency event. Scope the purge to the affected post and, where practical, the affected key.

Step 1: Identify the post and list its oEmbed keys

Using WP-CLI:

wp post meta list 1234 --keys='_oembed_*' --format=table

Or in PHP, using get_post_custom_keys() as the core method does:

$keys = get_post_custom_keys( 1234 );
foreach ( $keys as $key ) {
    if ( str_starts_with( $key, '_oembed_' ) ) {
        echo $key . "\n";
    }
}

You will see pairs: _oembed_<md5> and _oembed_time_<md5>. If the post has been edited several times, there may be several pairs, one per historical URL-and-attributes combination.

Step 2: Confirm which key corresponds to the current URL

Compute the hash the same way core does. The URL must match exactly what WP_Embed::shortcode() receives after the &amp; to & replacement, and the attributes must match the serialized form. For a bare URL with default attributes, a quick check in a WP-CLI eval is:

wp eval '
$url = "https://www.youtube.com/watch?v=NEWVIDEOID";
$attr = wp_parse_args( array(), wp_embed_defaults( $url ) );
echo "_oembed_" . md5( $url . serialize( $attr ) ) . "\n";
'

If the computed key is not present in the post’s meta, the new URL has no cache row yet and the next render will fetch fresh. If the computed key is present and its value is the old iframe HTML, the row is wrong for the current URL and should be removed. If the computed key is present and its value is {{unknown}}, the row is a cached failure and should be removed.

Step 3: Delete only the affected keys

wp post meta delete 1234 _oembed_<md5>
wp post meta delete 1234 _oembed_time_<md5>

Or, to remove every oEmbed row for that one post — the equivalent of running delete_oembed_caches() manually — use a scoped WP-CLI eval:

wp eval '
$post_id = 1234;
foreach ( get_post_custom_keys( $post_id ) as $key ) {
    if ( str_starts_with( $key, "_oembed_" ) ) {
        delete_post_meta( $post_id, $key );
    }
}
'

That is the same loop the core method uses. It is safe because it is scoped to one post ID. It is not a substitute for a site-wide purge, and it should not be.

Step 4: Let the next render repopulate

Load the post on the front end with any page cache bypassed. WP_Embed::shortcode() will find no cached row, call wp_oembed_get(), and write the new HTML and timestamp under the new key. If the provider returns false, the sentinel {{unknown}} will be written instead, and the front end will show a link. That is a provider-side failure, not a meta problem.

Why a save_post hook is the wrong reflex

The obvious-looking fix is to hook save_post and call delete_oembed_caches() on every save. That fires on unrelated edits — title changes, category changes, status transitions — and purges oEmbed rows that did not need purging. The save_post reference also warns that calling a function such as wp_update_post() inside a save_post callback creates an infinite loop unless the callback is unhooked first. A purge callback that itself triggers a save is a plausible way to compound the problem.

For a small publishing team, a targeted admin action or a WP-CLI command is a better fit than an automatic hook. The purge is rare, the scope is one post, and the operator can see what was deleted.

Verification

After the purge and a fresh render, confirm three things:

  1. The rendered iframe’s video ID matches the URL in the post content. Inspect the iframe src in DevTools. If it does not match, the front end is serving a page cached before the save, or a different code path is producing the embed.

  2. The stored meta value matches the rendered HTML. Query the row:

    SELECT meta_key, LEFT(meta_value, 120) AS value_preview
    FROM wp_postmeta
    WHERE post_id = 1234 AND meta_key LIKE '_oembed_%'
    ORDER BY meta_key;

    You should see one _oembed_<md5> row containing the new iframe HTML and one _oembed_time_<md5> row containing a recent Unix timestamp. If the value is {{unknown}}, the provider fetch failed and the front end will show a link.

  3. The timestamp is recent. A timestamp older than the edit means the row was not repopulated — either the render did not reach WP_Embed::shortcode(), or the page was served from cache.

If all three check out and the front end still shows the old video, the problem is above the oEmbed layer: page cache, object cache, CDN, or a theme/plugin filter on embed_oembed_html that is substituting HTML. The filter reference shows the filter receives the cached HTML, the URL, the attributes, and the post ID, so a filter that ignores the URL and returns a fixed string will produce exactly this symptom.

Diagnostic checklist

  • Confirm the rendered iframe’s video ID against the URL in post_content.
  • Confirm the stored _oembed_ value for the current URL’s hash.
  • Confirm the stored _oembed_time_ value is recent.
  • Confirm whether a page cache or CDN is serving HTML rendered before the save.
  • Confirm whether an embed_oembed_html filter is substituting HTML.
  • Confirm whether the stored value is {{unknown}}, which indicates a cached provider failure rather than a stale success.

FAQ

Why does the old embed come back after I delete the meta row?

If the front end is served from a page cache that was populated before the deletion, the cached HTML still contains the old iframe. The meta row is gone, but the rendered page is not. Bypass the page cache and re-render.

Why does the embed show as a plain link instead of a video?

The stored value is likely {{unknown}}, the sentinel WP_Embed::shortcode() writes when wp_oembed_get() returns false and there is no existing cache. Delete the key and let the next render retry the provider. If the provider is still failing, the sentinel will be written again.

Does the TTL eventually fix this on its own?

Not on a normal front-end render. The cached branch is entered when $this->usecache is true, which it is by default, regardless of the TTL. The TTL matters on paths that set usecache = false, such as WP_Embed::cache_oembed(). A front-end reload will keep serving the cached row as long as the key matches.

Can I just run DELETE FROM wp_postmeta WHERE meta_key LIKE '_oembed_%'?

You can, but on a live publishing site it forces every embedded URL across the site to be re-fetched on next render. That is a provider-rate and page-latency event you are inflicting on yourself. Scope the purge to the affected post.

Why doesn’t WordPress clean this up on save?

WP_Embed::delete_oembed_caches() exists and does the cleanup, but the reference page states it is “Unused by core as of 4.0.0.” Core does not call it on save. The oEmbed meta is data the site owns and must groom.

Is this specific to YouTube?

No. The mechanism is provider-agnostic. Any URL that routes through WP_Embed::shortcode() and caches under _oembed_<md5> is subject to the same key-mismatch behavior when the URL changes.

The Root Vegetable Ring on the ‘Let’s Root For Each Other’ Shirt Is a Diagram of a Promise

The artwork on the Let’s Root For Each Other And Watch Each Other Grow Garden shirt is a closed loop. The words LET’S ROOT FOR EACH OTHER AND WATCH EACH OTHER GROW wrap around four smiling root vegetables — carrot, radish, beet, and onion — each with its roots drawn down into visible soil. The sentence is a ring; the vegetables sit inside it. That layout is the argument, and it is worth reading closely because the sentence contains two different meanings of root and the drawing quietly enforces one of them.

Take the first clause. Root for is the idiom of the spectator: you cheer, you hope, you want someone else to succeed. It costs the rooter nothing except attention. Take the second clause. Root as a noun is the underground structure that anchors a plant and draws water and nutrients from the soil. The shirt uses both senses in one breath, and the pun works because the vegetables are literally rooted while the sentence asks us to figuratively root. The design does not resolve the pun; it stages it.

Now look at what the vegetables are doing. They are not cheering. They are not holding signs. They are smiling with their roots exposed in soil, which is to say they are shown in the condition the second clause describes: growing, anchored, drawing from the ground. The carrot, radish, beet, and onion are the objects of the rooting, not the rooters. The sentence surrounds them the way a fence surrounds a bed. The graphic makes the mutual-support claim visual by putting the supported things at the center and the supporting words at the perimeter.

That is a more careful design than a slogan shirt usually manages. Most mutual-support graphics put two figures side by side, or two hands, or an arrow between two boxes. This one puts four different root vegetables in a single frame and lets the shared soil do the work. The carrot is long and tapered; the radish is round and small; the beet is bulbous; the onion is layered. They are not the same plant. The design does not ask them to be. It asks them to be in the same dirt, under the same sentence, growing at whatever rate each one grows.

Here is where the shirt becomes useful as an object to think with, and where I want to be precise about what it does and does not claim. The phrase watch each other grow is not the same as make each other grow. Watching is a passive, attentive act. It implies presence and patience, not intervention. The shirt does not promise that rooting for someone will cause them to grow; it pairs the cheering with the observing. That distinction matters in any team setting where support gets confused with management. A colleague who roots for you is not the same as a colleague who prunes you. The artwork keeps the roots in the soil and the words outside the bed, which is a fairly exact picture of the difference.

The second thing the design gets right is that the growth is plural and uneven. Four vegetables, four shapes, one soil line. Nothing in the drawing suggests they will emerge at the same time or reach the same size. The sentence says each other, not together. Mutual support, in this reading, is not synchronized support. It is the agreement to keep watching while the carrot does carrot things and the onion does onion things. That is a more defensible promise than the usual team slogan, which tends to imply that everyone is on the same growth curve and that the curve is upward.

I would not push the metaphor further than the drawing supports. The shirt is not a diagram of a software system, a security model, or an engineering process, and I am not going to pretend the root vegetables are a load-balancing diagram. What it is is a compact visual statement about a specific kind of relationship: the one where you are close enough to notice someone’s progress and far enough away not to be directing it. The ring of text and the ring of soil are two boundaries around the same idea. The words are the outer boundary — the social claim. The soil is the inner boundary — the physical condition. The vegetables sit between them, which is where growth actually happens.

If you are the kind of reader who keeps a mental file of terse messages and boundary wording, this shirt belongs in it. The phrase let’s root for each other is an invitation, not an instruction. The phrase and watch each other grow is a commitment to attention, not a promise of outcome. The drawing adds the missing clause: in the same soil, at our own rates. That is the whole design, and it is enough. The lets root for each other shirt is a wearable version of a sentence that is easy to say and harder to mean, and the artwork is what makes the difference visible.

Editorial note: This publication and Quirky Cotton Collective share an owner.

What Actually Happens When wp_cache_flush() Clears the alloptions Key Mid-Request

The report usually arrives in the same shape: a request calls wp_cache_flush(), and immediately afterward the options table gets hammered. Query Monitor shows a cluster of SELECT option_name, option_value FROM wp_options WHERE autoload IN (...) rows, the alloptions cache miss counter climbs, and someone on the team concludes that wp_cache_flush() deleted the alloptions key mid-request and triggered a stampede.

That conclusion is half right and half wrong, and the wrong half matters because it sends you looking for a key deletion that core does not perform. Core’s flush is broader and blunter than a targeted delete. The stampede risk is real, but it lives in the repopulation path, not in a surgical removal of one key. This article traces the actual code path, separates default-install behavior from persistent drop-in behavior, and gives you instrumentation to confirm which failure shape you have before you change anything.

Symptom: a flush, then a burst of options queries

The observable pattern is consistent enough to describe without inventing an incident. A request runs wp_cache_flush() — often from a plugin’s settings save, a deploy hook, a cron job, or a poorly scoped init callback. Later in the same request, or in concurrent requests arriving during the flush window, code calls get_option() or alloptions(). The alloptions key is absent from the object cache, so wp_load_alloptions() runs its database query and repopulates the key.

In a default install, that repopulation is bounded to the request. The object cache documentation is explicit: by default the cache is non-persistent, and data stored in it resides in memory only for the duration of the request (WP_Object_Cache). A single request that flushes and then reloads alloptions pays one extra query. That is not a stampede. The interesting failure mode requires a persistent object cache drop-in, where the flush affects shared state across concurrent requests.

Trace: what wp_cache_flush() actually calls

The wrapper is thin. The developer reference for wp_cache_flush() shows the entire function:

function wp_cache_flush() {
    global $wp_object_cache;
    return $wp_object_cache->flush();
}

It delegates to the global object cache’s flush() method and returns its boolean result. In core, that method is equally blunt. The WP_Object_Cache::flush() reference shows:

public function flush() {
    $this->cache = array();
    return true;
}

Core does not delete the alloptions key. It resets the entire in-memory cache array to empty and returns true. Every key in every group is gone from the runtime cache, including alloptions, but not because of a targeted delete. The distinction is not pedantic: if you are looking for a code path that removes one key, you will not find it in core, and you will misdiagnose the problem.

The place where the story changes is the drop-in. The object cache documentation notes that the object cache can be replaced by other caching mechanisms via a file in wp-content, and that if that file exists, the core class is not included (WP_Object_Cache). A persistent drop-in supplies its own flush(). Whether that flush clears the entire backend, clears a namespace, or performs a targeted delete is a property of the drop-in, not of core. No drop-in source was retrieved for this article, so the correct statement is conditional: if your drop-in’s flush() deletes alloptions specifically, that behavior lives in the drop-in and must be read there. Core’s own flush() does not do it.

Trace: how wp_load_alloptions() repopulates the key

The repopulation path is where the cost accumulates. The wp_load_alloptions() reference shows the sequence. First, a filter can short-circuit the function entirely:

$alloptions = apply_filters( 'pre_wp_load_alloptions', null, $force_cache );
if ( is_array( $alloptions ) ) {
    return $alloptions;
}

If no filter returns an array, the function attempts a cache read:

$alloptions = wp_cache_get( 'alloptions', 'options', $force_cache );

On a miss, it queries the options table for autoloaded values, with a fallback to all options if that query returns nothing:

$alloptions_db = $wpdb->get_results( "SELECT option_name, option_value FROM $wpdb->options WHERE autoload IN ( '" . implode( "', '", esc_sql( wp_autoload_values_to_autoload() ) ) . "' )" );
if ( ! $alloptions_db ) {
    $alloptions_db = $wpdb->get_results( "SELECT option_name, option_value FROM $wpdb->options" );
}

After building the array, it applies pre_cache_alloptions and stores the result back into the cache:

wp_cache_add( 'alloptions', $alloptions, 'options' );

Finally, it applies the alloptions filter and returns the array (alloptions hook). The important detail for stampede analysis is the use of wp_cache_add(), not wp_cache_set(). add() only writes if the key does not already exist. Under concurrency, multiple requests can each miss, each run the query, and each attempt an add; only one add wins, but every request has already paid for the query. That is the shape of the stampede: repeated read work and repeated write attempts, not a single deletion event.

Root cause: concurrent misses during repopulation

Model the failure as a cache miss storm on a shared key. A persistent drop-in holds alloptions in shared storage. A flush removes it. Requests arriving before the first repopulation completes all miss. Each runs the options query. Each attempts wp_cache_add(). The database sees a burst of identical reads, and the cache backend sees a burst of competing adds.

This pattern is documented in an analogous case. A Trac mailing-list post about flush_rewrite_rules() describes what happens when rewrite_rules, which is stored in alloptions, is invalidated frequently: “you have that cache key being invalidated and set quite frequently because all the requests are touching it. That introduces both some fun alloptions race conditions in general, but also makes it possible that the object cache (Memcached specifically) can enter into a stampede condition” (wp-trac, August 2023). The same post describes the first stampede: “Each request during the time period will find rewrite_rules empty and attempt to generate and save the rules.”

That is evidence of the pattern, not of your specific incident. The post concerns flush_rewrite_rules(), not wp_cache_flush(), and it does not claim that core’s flush deletes alloptions. Use it to reason about the mechanism, then instrument your own install to confirm whether the mechanism is active.

One more core behavior belongs in the model. The object cache documentation warns that calling wp_cache_flush_group() blindly may cause the entire cache to be flushed if the backend does not support group flushing, and recommends checking wp_cache_supports( 'flush_group' ) first (WP_Object_Cache). A plugin that intends to clear one group and instead clears everything produces the same alloptions miss storm as an explicit full flush. That is a plausible root cause worth checking before you blame wp_cache_flush() itself.

Fix: instrument before you theorize

The fix depends on which failure shape you actually have. Do not start by replacing wp_cache_flush() with a targeted delete. Start by confirming what your drop-in’s flush() does and whether the alloptions key is being missed repeatedly.

1. Read the drop-in’s flush()

Open the object cache drop-in in wp-content and read its flush() method. Determine whether it clears the entire backend, clears a namespace, or deletes specific keys. If it deletes alloptions specifically, that is the code path to examine. If it clears everything, the alloptions miss is a side effect of a full flush, and the question becomes why a full flush is running during normal traffic.

2. Log flush calls with a backtrace

A flush that runs on every request or inside a loop is a different problem from a one-time deploy flush. Add temporary logging around the call site, or use a query-monitor-style backtrace capture, to record who calls wp_cache_flush() and how often. The wrapper itself is trivial; the caller is the interesting part.

3. Log alloptions misses

Hook the cache read path for the options group and record misses on the alloptions key. A temporary mu-plugin can wrap the drop-in’s get() or use the pre_wp_load_alloptions filter to observe calls without altering behavior. The goal is a count per request and a timestamp, so you can correlate misses with flush events.

4. Check the filters

pre_wp_load_alloptions can short-circuit the function by returning an array, and pre_cache_alloptions can alter the array before it is cached. A plugin that returns an incomplete array from pre_wp_load_alloptions can cause repeated reloads or inconsistent option values. Grep for both filter names in your plugin set before assuming the cache backend is at fault.

5. Check for blind group flushes

Search for wp_cache_flush_group() calls that are not guarded by wp_cache_supports( 'flush_group' ). On a backend without group-flush support, those calls degrade to a full flush and produce the same miss storm.

Verification: what a confirmed stampede looks like

After instrumentation, a confirmed alloptions stampede has a specific signature. The options query appears multiple times in a short window across concurrent requests, not once per request. The cache miss counter for the options group rises in step with flush events. The wp_cache_add() calls for alloptions compete, and the backend may show eviction or write contention if it is Memcached-like. If you see one extra query per request after a single flush, you have repopulation cost, not a stampede.

For a default install, the verification is simpler: confirm that the object cache is non-persistent and that the miss is bounded to the request. The documentation states that default cache data does not persist across page loads (WP_Object_Cache). If your install has no persistent drop-in, the cross-request stampede cannot occur, and the investigation should focus on why the flush is running at all.

Decision rule

Treat wp_cache_flush() as a blunt instrument whose real cost is repopulation work, not a single key deletion. Core’s flush() resets the entire in-memory cache array and returns true; it does not surgically remove alloptions. If your drop-in performs a targeted delete, that is the drop-in’s behavior and should be read and tested there. Prefer a targeted wp_cache_delete( 'alloptions', 'options' ) only after you have verified the drop-in’s semantics for that key and group and confirmed that the full flush is the actual source of the miss storm. Until then, instrument the flush call sites, the alloptions misses, and the filters, and let the trace tell you which failure shape you have.

FAQ

Does wp_cache_flush() delete the alloptions key?

Not in core. Core’s WP_Object_Cache::flush() sets the entire in-memory cache array to empty and returns true. The alloptions key disappears as part of that reset, but there is no targeted deletion. A persistent drop-in can implement flush() differently, so check the drop-in source.

Why does alloptions reload after a flush?

wp_load_alloptions() reads alloptions from the options cache group. After a flush, that read misses, so the function queries the options table for autoloaded values and repopulates the key with wp_cache_add(). That is expected behavior, and the cost is the query plus the add attempt.

Can a stampede happen without a persistent object cache?

Not across requests. The default object cache is non-persistent and lives only for the duration of the request. A flush and reload within one request costs one extra query. The cross-request stampede requires shared cache state, which means a persistent drop-in.

What should I check first?

Read the drop-in’s flush() method, then log who calls wp_cache_flush() and how often. Check for unguarded wp_cache_flush_group() calls and for pre_wp_load_alloptions filters that return arrays. Confirm the miss pattern before changing the flush strategy.

The Meh Shirt’s Typographic Loudness Is the Joke: A Systems Reading of a Dismissive Word

The native garment preview shows large bold white lowercase meh. lettering on a dark shirt. No animal illustration is present. That is the entire design. The period is there. The word is lowercase. The type is heavy enough to read across a room. The shirt is dark, so the white lettering is the only event on the surface.

I keep coming back to the mismatch. Meh is a word that performs disengagement. It is the verbal equivalent of a shrug, a token of low-stakes indifference. Yet the design sets it in the typographic register of a statement: large, bold, centered, punctuated. The shirt does not whisper meh. It announces it. That contradiction is the whole object, and it is worth reading carefully rather than treating as a generic gag.

If you want the object itself, it is the Meh Funny Sarcastic Saying Introvert Dry Humor shirt from Quirky Cotton Collective. The listing title stacks several retail descriptors — funny, sarcastic, introvert, dry humor — but the visual observation is narrower: large bold white lowercase meh. on a dark garment. The title is marketing prose. The artwork is the evidence. I am reading the artwork.

Editor’s note: This publication and Quirky Cotton Collective share an owner.

What the typography actually does

Lowercase meh matters. Set in caps, MEH. would read as a shout, a demand, a piece of aggressive branding. Lowercase softens the word into something closer to a mutter. The period, though, pulls in the other direction. A period is a full stop. It closes the sentence. It refuses continuation. So the design holds two signals at once: the word is casual, the punctuation is final.

Bold weight and large scale add a third signal. Bold type is used for emphasis, for warnings, for headlines, for things the reader is supposed to notice. A bold, large, lowercase meh. is therefore not indifferent at all. It is a carefully staged performance of indifference. The shirt is doing work to look like it is not doing work. That is a recognizable move in dry humor, and it is also a recognizable move in engineering culture, where the most emphatic statements are sometimes delivered in the flattest possible register.

I am not claiming the designer intended a semiotic treatise. I am claiming the visual facts support this reading: the scale is large, the weight is bold, the case is lowercase, the punctuation is a period, the ground is dark, the ink is white. Those are observations, not interpretations. The interpretation is that the combination produces a specific effect — loud understatement — and that effect is the design’s actual content.

Why the missing animal matters

The observation explicitly notes that no animal illustration is present. That absence is worth stating because novelty shirts in this category often lean on a mascot: a sloth, a cat, a grumpy bear, some creature to carry the attitude so the wearer does not have to. This shirt has no mascot. The word has to carry everything.

That makes the typography load-bearing. There is no illustration to soften the message, no character to absorb the irony, no visual joke to explain the verbal one. The design is a single typographic event on a dark field. If the type were weak, the shirt would be nothing. Because the type is large and bold, the shirt works as a flat assertion. The absence of an animal is not a gap; it is a constraint that forces the lettering to do all the work.

I prefer it this way. Mascots tend to convert dry humor into cute humor. A sloth saying meh is a different object entirely — it is a joke about laziness, not a joke about register. The bare word keeps the register ambiguous, which is where the dry humor lives.

The difference between a slogan and a usable promise

Work-culture shirts usually make a promise. I fix things, I break things, Trust me, I’m an engineer — these are claims about identity or competence. They invite the reader to categorize the wearer. They are slogans in the ordinary sense: short, declarative, self-descriptive.

Meh. is not a promise. It is a refusal to promise. It does not tell you what the wearer does or believes. It tells you what the wearer is unwilling to muster. That is a different speech act. A slogan says here is my position. Meh. says I decline to have a position at this time. The period makes the decline final.

For technical readers, this distinction is familiar. A system that returns 200 OK with an empty body is not the same as a system that returns 204 No Content. Both decline to provide content, but one is a successful response with nothing to say, and the other is a formal statement that there is nothing to say. Meh. is closer to the second. It is not silence. It is a declared absence of enthusiasm, delivered with the confidence of a status code.

That is why the shirt reads as dry rather than rude. Rudeness would require engagement — an insult, a provocation, a demand. Meh. withholds engagement. It is the minimal viable response to a question the wearer did not want to answer.

Reading the period as a boundary

The period is the smallest element in the design and the one that changes it most. Without punctuation, meh is an interjection, a sound, a shrug rendered in letters. With a period, it becomes a sentence. A sentence has a speaker, a tense, and a terminus. The period says: this utterance is complete. Do not expect elaboration.

In editorial terms, that is a boundary. It is the typographic equivalent of closing a ticket with wontfix. The word alone might invite a follow-up question. The period forecloses it. The design is not asking for a conversation. It is stating a position and stopping.

I find this more interesting than the word by itself. A lot of novelty shirts rely on the reader to supply the joke. This one supplies the joke and then closes the thread. The period is the punchline’s punchline. It is the moment the design stops being friendly and becomes final.

What the shirt is not

It is not a pride variant. The observation notes no animal or pride variant. It is not a political statement. It is not a technical diagram. It is not a slogan about competence, productivity, or team culture. It is a single word in a specific typographic treatment on a dark ground.

That narrowness is a feature. The shirt does not try to be several things. It commits to one visual event and executes it at scale. In a category crowded with multi-element designs — text plus illustration plus border plus secondary joke — the single-word approach is a discipline. It is also a risk: if the type is not strong enough, there is nothing else to look at. Here the type is strong enough.

I am not going to claim the shirt improves anything, solves anything, or expresses a verified personality trait. It is a garment with a word on it. The word is meh. The word is large. The word is bold. The word is lowercase. The word has a period. That is the object, and the object is coherent.

How to verify the reading

If you want to check my interpretation against the artifact, look at the native garment preview on the listing. Confirm four things: the case is lowercase, the weight is bold, the scale is large relative to the shirt front, and the punctuation is a period. Then confirm the ground is dark and the lettering is white. Those six observations are the entire design. Everything else — the humor, the attitude, the cultural category — is inference from those six facts.

That is the right way to read a typographic shirt. Start with the visual facts. Then ask what the combination does. The answer here is that the design uses the visual grammar of emphasis to deliver a word that means the opposite of emphasis. The mismatch is the joke. The period makes it final. The missing animal keeps it clean. And the large bold lowercase lettering is what makes the whole thing legible from across a room, which is exactly the distance from which meh. is most effective.

How _edit_lock Works as a Pessimistic Lock Row in wp_postmeta — and Why Two Editors Can Still Collide After a Browser Crash

Two editors open the same post. One browser crashes. The other editor keeps working, saves, and then the first editor’s tab — restored from session — saves over the top. The lock was supposed to prevent this. It did not, because _edit_lock is not a lock in the mutual-exclusion sense. It is a timestamped freshness heuristic stored as ordinary postmeta, and the only thing standing between two writers is whether that timestamp is recent enough.

Symptom

The observable failure is not a database error or a REST 409. It is a silent overwrite. Editor A’s browser process dies at 10:04. Editor B opens the post at 10:06, sees no lock notice, edits, and saves. Editor A’s tab is restored at 10:09, the Heartbeat response no longer reports a lock error, and the editor saves. The post now contains A’s content. B’s changes are gone.

If you were watching the network panel, you would have seen the Heartbeat request from A’s tab stop at 10:04 and resume at 10:09. Between those points, wp_check_post_lock() returned false for B, and B was admitted legitimately by the read path.

Trace: the write path

The lock is written by wp_set_post_lock(). The function is short enough to read in full:

function wp_set_post_lock( $post ) {
    $post = get_post( $post );
    if ( ! $post ) {
        return false;
    }
    $user_id = get_current_user_id();
    if ( 0 === $user_id ) {
        return false;
    }
    $now = time();
    $lock = "$now:$user_id";
    update_post_meta( $post->ID, '_edit_lock', $lock );
    return array( $now, $user_id );
}

Two things matter here. First, the value is a single string formatted as timestamp:user_id. Second, the write is an unconditional update_post_meta() call. There is no compare-and-swap, no row-level constraint, no session token. Any caller with the post ID and a current user can overwrite the row. The function returns false only when the post does not exist or there is no current user (wp_set_post_lock()).

Because the value lives in wp_postmeta, it is visible to any SQL client and any WP-CLI session. That is the diagnostic surface:

wp post meta get 1234 _edit_lock
# 1729143840:7

Or directly:

SELECT meta_value
FROM wp_postmeta
WHERE post_id = 1234
  AND meta_key = '_edit_lock';

The first segment is a Unix timestamp. The second is a user ID. Nothing else is stored — no client identifier, no tab identifier, no heartbeat sequence number.

Trace: the read path

The lock is read by wp_check_post_lock(). The relevant portion of the source:

$lock = get_post_meta( $post->ID, '_edit_lock', true );
if ( ! $lock ) {
    return false;
}
$lock = explode( ':', $lock );
$time = $lock[0];
$user = isset( $lock[1] ) ? (int) $lock[1] : (int) get_post_meta( $post->ID, '_edit_last', true );
if ( ! get_userdata( $user ) ) {
    return false;
}
$time_window = apply_filters( 'wp_check_post_lock_window', 150 );
if ( $time && $time > time() - $time_window && get_current_user_id() !== $user ) {
    return $user;
}
return false;

Four conditions must all hold for the function to return a user ID:

  1. The _edit_lock meta value exists.
  2. The user ID parsed from the value (or falling back to _edit_last) resolves to a real user.
  3. The stored timestamp is within the last 150 seconds, filtered by wp_check_post_lock_window.
  4. The current user is not the lock holder.

If any condition fails, the function returns false — meaning “not locked” as far as the caller is concerned. The documentation states the return is false when the post does not exist, is not locked, the user with the lock does not exist, or the post is locked by the current user (wp_check_post_lock()).

The 150-second window is the entire enforcement mechanism. There is no other check. A lock older than 150 seconds is treated as absent, regardless of whether the editor’s browser is still open.

Trace: the refresh path

What keeps a live editor’s timestamp fresh is wp_refresh_post_lock(), which runs on the Heartbeat API. The function checks the lock and either reports a conflict or writes a new one:

$user_id = wp_check_post_lock( $post_id );
$user = get_userdata( $user_id );
if ( $user ) {
    $error = array(
        'name' => $user->display_name,
        'text' => sprintf( __( '%s has taken over and is currently editing.' ), $user->display_name ),
    );
    // ...
    $send['lock_error'] = $error;
} else {
    $new_lock = wp_set_post_lock( $post_id );
    if ( $new_lock ) {
        $send['new_lock'] = implode( ':', $new_lock );
    }
}
$response['wp-refresh-post-lock'] = $send;

Two details are worth noting. First, the function returns early without touching the lock if the current user lacks the edit_post capability for that post (wp_refresh_post_lock()). Second, when another user holds the lock, the response carries a lock_error with that user’s display name — this is the string the editor UI renders as the takeover notice.

When no other user holds the lock, the function writes a fresh timestamp via wp_set_post_lock() and returns it in the Heartbeat response. This is the mechanism that distinguishes a live editor from a dead one: the live editor’s tab keeps sending Heartbeat requests, and each one refreshes the timestamp. The dead editor’s tab stops, and the timestamp ages out.

Root cause: freshness, not exclusion

The collision after a browser crash is not a bug in the lock. It is the lock working as designed. The design is a freshness heuristic:

  • The write path does not prevent a second writer. It overwrites.
  • The read path does not enforce exclusivity. It checks whether the stored timestamp is recent.
  • The refresh path is the only thing that keeps a live editor’s timestamp recent.

When a browser crashes, the refresh path stops. The row remains in wp_postmeta with its last timestamp. For up to 150 seconds, wp_check_post_lock() still returns the crashed editor’s user ID, and a second editor is warned. After 150 seconds, the function returns false, and the second editor is admitted.

The first editor’s tab, if restored, resumes Heartbeat. At that point wp_check_post_lock() may return the second editor’s user ID, and the first editor sees the takeover notice. But the notice is informational. It does not prevent the first editor from saving. The save path does not re-check the lock in a way that blocks the write.

So the practical collision is not “the lock failed.” It is “the lock expired while the first editor still believed they held it.” The second editor was admitted legitimately by the read path. The first editor’s next Heartbeat is what surfaces the takeover — after the fact.

What the lock does not do

It is worth being explicit about the boundaries, because the failure mode is easy to misread:

  • It does not prevent concurrent writes at the database level. There is no unique constraint on _edit_lock, and update_post_meta() will happily overwrite an existing row.
  • It does not track sessions or tabs. The value is timestamp:user_id. Two tabs for the same user share the same lock identity.
  • It does not distinguish a crash from a slow save. Both leave a timestamp that ages out on the same schedule.
  • It does not block a save. The editor UI may show a notice, but the REST or admin-ajax save path does not reject the request based on lock state.

The lock is advisory. It is a coordination hint rendered in the editor UI, backed by a timestamped postmeta row and a 150-second window.

Fix: what you can actually tune

If your team sees frequent post-crash collisions, the tuning point is the window filter:

add_filter( 'wp_check_post_lock_window', function( $window ) {
    return 300; // seconds
} );

Widening the window trades collisions for longer lockouts after genuine crashes. A 300-second window means a crashed editor’s lock persists for five minutes, during which a second editor is warned and cannot take over without an explicit action. That is the trade-off, and it should be stated as one rather than presented as a fix.

Narrowing the window does the opposite: faster recovery after a crash, more frequent collisions if Heartbeat is delayed. If your Heartbeat interval is longer than the window, live editors will appear to have stale locks. That is a configuration mismatch, not a lock failure.

For self-maintained installs, the lock row is also directly clearable:

wp post meta delete 1234 _edit_lock

This is safe when you have confirmed the lock holder is not actively editing. It is not safe as a routine operation, because it removes the only signal the editor UI has that another user is present.

Verification: a diagnostic sequence

When a collision is reported, the useful sequence is:

  1. Read the row. wp post meta get <post_id> _edit_lock. Note the timestamp and user ID.
  2. Compare the timestamp to now. If now - timestamp > 150 (or your filtered window), the lock is already inert. The collision happened because the window lapsed, not because the lock was ignored.
  3. Resolve the user ID. wp user get <user_id>. If the user no longer exists, wp_check_post_lock() returns false regardless of timestamp — the lock is treated as absent.
  4. Check the window filter. If a plugin or theme has modified wp_check_post_lock_window, the effective window is not 150 seconds. Grep for the filter name in wp-content.
  5. Check the Heartbeat response. In the browser DevTools Network panel, filter for admin-ajax.php with action=heartbeat. The response body contains a wp-refresh-post-lock key. If it contains lock_error, another user holds the lock. If it contains new_lock, the current user just refreshed it.

That sequence distinguishes the three failure modes: a lock that expired (timestamp too old), a lock that was ignored (user ID missing), and a lock that was never written (no row).

Why the row lives in wp_postmeta

The choice of wp_postmeta as storage has a side effect that matters on sites with a persistent object cache. Every wp_set_post_lock() call is an update_post_meta() call, which invalidates the post’s cache entry. On a busy editorial site, Heartbeat-driven lock refreshes can generate a steady stream of cache invalidations for posts that are not actually being saved.

The Presence API feature plugin addresses this by moving post locks out of postmeta and into a presence table, so that refreshing a lock no longer invalidates cached post queries on sites with a persistent object cache (Make WordPress Core: Presence API). The same source notes that locks keep working when presence recording is switched off. This is a structural change to where the lock lives, not a change to the freshness-window behavior that causes the crash collision.

It is worth being precise: the Presence API is described in that source as a feature plugin, not as shipped core behavior. The _edit_lock postmeta row is what current core uses.

Related operational notes

The Heartbeat-driven lock refresh has been reported to keep auth-check requests firing on post edit screens even when wp_auth_check_load is removed. In a 2014 trac mailing list thread, a reporter described the auth-check still firing from a combination of post.js and heartbeat.js, likely driven by the refresh-post-lock path (wp-trac mailing list, February 2014). That report is old and should not be treated as a description of current behavior, but it illustrates that the lock refresh is entangled with other Heartbeat-driven admin behavior.

If you are debugging a new install that reports “nothing found” on the front end while the editor shows content, that is a different failure mode with a different diagnostic path. The relevant sequence is covered in What to Fix First When a New WordPress Site Says Nothing Found.

FAQ

Does the lock prevent two editors from saving at the same time?
No. The lock is advisory. The save path does not reject a request based on lock state. The editor UI may show a notice, but the write proceeds.

What is the default lock window?
150 seconds, applied via the wp_check_post_lock_window filter. The default is stated in the wp_check_post_lock() source.

Why does the lock persist after a browser crash?
Because the row is written to wp_postmeta and is only removed or refreshed by a subsequent request. A crashed browser sends no further requests, so the row remains until the window lapses or another process clears it.

Can I clear a stale lock manually?
Yes, with wp post meta delete <post_id> _edit_lock. Confirm the lock holder is not actively editing first, because deleting the row removes the only signal the editor UI has that another user is present.

Does the lock work across multiple tabs for the same user?
The lock identity is the user ID, not the tab. Two tabs for the same user share the same lock state, and wp_check_post_lock() returns false when the current user is the lock holder.

What happens if the lock holder’s user account is deleted?
wp_check_post_lock() returns false when get_userdata() fails for the parsed user ID. The lock is treated as absent regardless of the timestamp.

Is the lock stored anywhere else?
In current core, no. The Presence API feature plugin moves locks into a presence table, but that is not shipped core behavior as of the retrieved source.

Why Dynamic Block Output Leaks into get_the_excerpt(): do_blocks(), the excerpt_remove_blocks Filter, and the Parsed Block Tree Your Archive Cards Render

Your archive card shows a Latest Posts list where the excerpt should be. Or a Query Loop. Or a related-posts grid. The post has no manual excerpt, so WordPress generated one, and somewhere in that generation a dynamic block’s render_callback ran and its output landed in the card.

This is not a bug in excerpt_remove_blocks(). It is the expected result of render_block() executing a dynamic block during excerpt generation. The allowlist is the control surface, and the fix belongs there.

Symptom

An archive template calls the_excerpt(). The post has an empty post_excerpt. The card displays markup that belongs to a dynamic block: a list of recent posts, a query loop, a server-rendered widget. The post’s own prose is missing or truncated around it.

Two conditions produce this:

  1. The dynamic block’s name is in the allowed blocks list that excerpt_remove_blocks() uses.
  2. The post has no manual excerpt, so wp_trim_excerpt() generates one from the content.

If the post has a manual excerpt, get_the_excerpt() returns it and no block parsing happens. That is the cheapest diagnostic: set a manual excerpt on the post and reload. If the leak disappears, you are on the auto-generation path.

Trace

get_the_excerpt() returns the post’s post_excerpt value after applying the get_the_excerpt filter. The function source is short:

return apply_filters( 'get_the_excerpt', $post->post_excerpt, $post );

When post_excerpt is empty, wp_trim_excerpt() generates one from the content. That function applies the_content filters, and do_blocks() is attached to the_content. do_blocks() parses dynamic blocks out of post_content and re-renders them.

But the excerpt path does not simply call do_blocks() on the raw content. It calls excerpt_remove_blocks(), which parses blocks out of the content string and renders those appropriate for the excerpt. The function’s own description: “As the excerpt should be a small string of text relevant to the full post content, this function renders the blocks that are most likely to contain such text.”

Inside excerpt_remove_blocks(), the loop checks each parsed block’s blockName against an allowed list. If the name is in the list, the function calls render_block( $block ) and appends the result to the output.

render_block() constructs a WP_Block from the parsed block and returns the result of its render() method:

$block = new WP_Block( $parsed_block, $context );
return $block->render();

For a dynamic block, render() invokes the block’s render_callback. That callback runs during excerpt generation. Its output is now part of the excerpt string.

Root cause

The allowed blocks list is filterable through excerpt_allowed_blocks:

$allowed_blocks = apply_filters( 'excerpt_allowed_blocks', $allowed_blocks );

The default list includes inner blocks such as core/paragraph, core/heading, core/list, core/quote, and wrapper blocks such as core/columns, core/column, and core/group. The retrieved source lists these defaults but does not enumerate every dynamic block that a plugin or theme may add.

A dynamic block enters the excerpt in one of two ways:

  1. Its name is in the default allowed list.
  2. A site added it through excerpt_allowed_blocks.

The second is the common case for custom blocks. A developer wants a block’s text to appear in excerpts, adds the block name to the filter, and does not account for the fact that render_block() will execute the block’s render_callback during excerpt generation.

There is a documented hazard. The excerpt_remove_blocks() source warns that if a dynamic block is added to the allowed list, it must not generate another excerpt, because that causes an infinite loop. The Gutenberg commit that introduced the stripping approach includes a test comment explaining the mechanism: when dynamic blocks generate an excerpt, they can cause an infinite loop if that block is part of the post’s content. wp_trim_excerpt() applies the_content, which has do_blocks attached, trying to render the block, which again attempts to return an excerpt of that post.

The commit added strip_dynamic_blocks() and filters to strip dynamic blocks from excerpts before the_content is applied. That is a proposed implementation, not necessarily what shipped in core. The current core function is excerpt_remove_blocks(), and its allowlist is the control surface.

Fix

Remove the dynamic block from excerpt_allowed_blocks. If the block must contribute text to an excerpt, render that text through a path that does not call get_the_excerpt() again.

First, find the registrations. Grep the codebase for the filter name:

wp grep excerpt_allowed_blocks --all

Or with standard tools:

grep -rn "excerpt_allowed_blocks" wp-content/

For each callback, check which block names it adds. Compare those names against the dynamic block registry. A block is dynamic if it has a render_callback or a render file in its block.json. You can list registered block types with WP-CLI:

wp block-type list --fields=name,title

That command lists registered block types. To check whether a specific block is dynamic, inspect its registration in code or query the block type registry in a short script. The registry stores the block type object; a dynamic block has a non-null render_callback.

If the block should not contribute to excerpts, remove it from the filter. If you cannot edit the plugin that added it, remove it at a later priority:

add_filter( 'excerpt_allowed_blocks', function( $allowed ) {
    return array_diff( $allowed, array( 'my-plugin/latest-posts' ) );
}, 20 );

If the block must contribute text, do not let its render_callback call get_the_excerpt() or wp_trim_excerpt(). Read from a field or attribute that does not re-enter excerpt generation. For example, a block that stores a summary in post meta should read that meta directly:

function my_plugin_summary_block_render( $attributes, $content, $block ) {
    $post_id = $block->context['postId'] ?? get_the_ID();
    $summary = get_post_meta( $post_id, 'my_plugin_summary', true );
    if ( '' === $summary ) {
        return '';
    }
    return '

' . esc_html( $summary ) . '

'; }

This callback does not call get_the_excerpt(), so it cannot recurse. It also does not render post-derived content that belongs to another query.

For posts that use dynamic blocks near the top of the content, a manual excerpt is the cheapest reliable fix. get_the_excerpt() returns post_excerpt before any block parsing happens. Set a manual excerpt on those posts and the auto-generation path is bypassed entirely.

Verification

Confirm the leak is gone and the fix did not break the block’s front-end output.

First, check the excerpt directly. In a WP-CLI shell or a temporary script:

wp eval 'echo get_the_excerpt( 123 );'

Replace 123 with the post ID. The output should contain the post’s prose and not the dynamic block’s markup.

Second, check the front-end post. The dynamic block should still render where it belongs. The excerpt filter only affects excerpt generation, not the_content.

Third, if you removed the block from excerpt_allowed_blocks, verify the filter is no longer adding it. You can log the allowed list during a request:

add_filter( 'excerpt_allowed_blocks', function( $allowed ) {
    error_log( print_r( $allowed, true ) );
    return $allowed;
}, 999 );

Check the debug log for the block name. If it is absent, the removal worked.

Fourth, test the infinite-loop hazard. If you have a dynamic block that generates an excerpt, create a post containing that block, leave the excerpt empty, and request the post. If the request hangs or exhausts memory, the block is re-entering excerpt generation. Remove it from the allowed list.

Audit checklist

  • Grep for excerpt_allowed_blocks in plugins, themes, and mu-plugins.
  • For each added block name, check whether the block is dynamic (has a render_callback or render file).
  • For each dynamic block in the list, check whether its render_callback calls get_the_excerpt(), wp_trim_excerpt(), or the_excerpt().
  • If it does, remove the block from the list or change the callback to read from a non-recursive source.
  • For posts with dynamic blocks near the top, set manual excerpts.
  • Verify with wp eval 'echo get_the_excerpt( $id );' and check the front-end block output.

The parsed block tree your archive cards render is the same tree do_blocks() uses. The difference is which blocks excerpt_remove_blocks() chooses to render. Control that list, and the leak stops.

The Small Lowercase ‘meh.’ Is the Whole Design: Reading Scale and Understatement on the Meh Shirt

Editorial note: This publication and Quirky Cotton Collective share an owner.

The garment preview shows a dark shirt with very small, centered lowercase lettering: meh. That is the entire graphic. No animal, no mascot, no flag, no secondary line of copy. The period is present. The letters are lowercase. The mark sits in the middle of the chest area at a scale that reads as a whisper rather than a statement.

Most slogan shirts scale their type to be legible across a room. This one does the opposite. The design’s argument is carried by how little it says and how quietly it says it. That is worth reading closely, because the scale is not an accident of production — it is the joke.

What the artwork actually shows

From the native garment preview: very small, centered lowercase meh. lettering on a dark shirt. No animal illustration is present. The shirt type is minimal small lowercase typography, with no animal or pride variant. The retail title frames it as “Meh Funny Sarcastic Introvert Minimalist Saying,” but the printed artwork itself is just the word and the period.

I want to be precise about what that observation supports and what it does not. It supports a description of the graphic: scale, case, punctuation, placement, absence of illustration. It does not tell us how the shirt wears, how it fits, how the ink holds up, or what any buyer thinks of it. Those are separate questions, and I have no evidence for them here. The design is the object under discussion.

Why the period matters

A lowercase meh without punctuation is an interjection. It trails off. It is the sound of a shrug rendered in letters. Add the period and the word becomes a sentence — a complete, closed statement. The period is the smallest possible unit of finality. It says: this thought is finished, and it was not worth more than four letters.

That is a different rhetorical move than the standard sarcastic tee. Most sarcasm on apparel works by escalation — bigger type, sharper phrasing, an exclamation point doing the emotional labor. This design works by de-escalation. The period is the only emphatic mark on the shirt, and it is doing the opposite of emphasis. It is closing the door quietly.

I read the period as the design’s load-bearing element. Remove it and the shirt becomes a mood. Keep it and the shirt becomes a verdict. That is an interpretation, not a claim about the designer’s intent, but it is the interpretation the artwork most naturally supports.

Scale as the second joke

The lettering is very small. On a dark shirt, small type at chest center does not announce itself from across a room. It rewards proximity. You have to be close enough to read it, which means the shirt is not performing for a crowd. It is performing for one person at conversational distance.

That is a coherent choice for a word that means indifference. A large MEH would be a contradiction — shouting about not caring. A small meh. is consistent. The scale matches the sentiment. The design does not ask to be noticed; it asks to be read, and only if you are already close.

There is a practical consequence to this that is worth naming plainly: small centered type on a dark ground is a legibility decision, and legibility depends on contrast, print method, and viewing distance. I am not making claims about any of those for this specific garment, because the preview does not establish them. What the preview does establish is the compositional intent — the type is deliberately subordinate to the shirt, not the other way around.

The introvert framing and what it does not prove

The retail title includes “Introvert,” and related search terms include “meh introvert shirt” and “meh sarcastic shirt.” That tells us something about how the listing is categorized and what queries it is meant to answer. It does not tell us that introverts buy it, that it represents introversion, or that the word meh has any necessary relationship to introversion as a concept.

I would resist the easy reading. Meh is not a personality type. It is a response. The shirt does not say “I am an introvert.” It says “meh.” Those are different speech acts. One is a claim about identity; the other is a reaction to a situation. The design is closer to the second, and the period reinforces it — a reaction that has concluded, not an identity that is being declared.

If the shirt reads as introvert-coded, that is a function of context — the listing, the search terms, the cultural association between understatement and reserve — not of anything printed on the fabric. The printed content is four letters and a period. Everything else is supplied by the reader.

Minimalism as a constraint, not a style

It is tempting to file this under “minimalist” and move on. But minimalism here is not an aesthetic preference layered on top of a message. It is the message’s only viable form. A word that means low engagement cannot be delivered with high production values. The design has to be as uninvested as the word. Small lowercase type with a period is the minimum viable execution of the idea.

That constraint explains the absence of an animal. A mascot — a sloth, a cat, a cartoon face — would add affect. It would make the shirt cute, or wry, or relatable in a way the word does not authorize. The observation that no animal illustration is present is not a gap in the design. It is the design holding its line. The shirt refuses to give you anything to coo at.

The same logic rules out a pride variant. A rainbow meh would attach the word to a cause, and the word does not belong to a cause. It belongs to a mood. The shirt’s restraint is what keeps it from becoming a different kind of object.

What a slogan shirt can and cannot promise

Technical readers spend a lot of time around language that has to be precise — error messages, commit messages, status codes, incident summaries. A shirt like this is a small vacation from that precision. It is a word that means “no strong signal,” worn on purpose.

But it is worth being clear about what the shirt is not. It is not a statement about work culture, burnout, or boundaries. It is not a diagram joke. It is not a security posture. It is a word. Reading it as a manifesto would be overreading it in exactly the way the design avoids.

The useful observation is narrower and, I think, more interesting: the shirt demonstrates that understatement scales down. Most apparel graphics compete for attention. This one competes for nothing. It is a small, closed, lowercase sentence on a dark ground, and it is complete.

The object

The shirt is the Meh Funny Sarcastic Introvert Minimalist Saying from Quirky Cotton Collective. The artwork is the small centered lowercase meh. and nothing else. If you want a shirt that says one thing quietly and then stops, that is what this is. If you want a shirt that explains itself, this is not that shirt — and the period is the reason.

The design’s discipline is the point. Four letters, one period, centered, small. It does not ask for a reaction. It has already had one, and it is done.

The Periodic Table Pun on ‘I Wear This Shirt Periodically’ Is Not a Spelling

Editorial note: This publication and Quirky Cotton Collective share an owner.

The artwork reads I WEAR THIS SHIRT PERIODICALLY, and beneath the sentence sit four tiles styled after the periodic table. Each tile carries an atomic number and a symbol: 53 I, 74 W, 22 Ti, 16 S. The symbols are I, W, Ti, S. That sequence does not spell the word periodically. It spells IW TiS if you read the symbols as letters, which is not a word. The joke is not a hidden spelling. The joke is that the tiles are a visual grammar for the word periodically, and the word periodically is doing two jobs at once.

This is the kind of design that rewards a second look, because the first look produces a small category error. A reader who knows the periodic table sees element tiles and expects a chemical message. A reader who knows the sentence sees a pun about wearing a shirt sometimes. The two readings do not resolve into a single decoded phrase. They sit next to each other, and the friction between them is the point.

What the tiles actually say

The four tiles are not a rebus for periodically. They are a visual quotation of the periodic table’s format: atomic number, symbol, and the implicit suggestion of an element name. The numbers 53, 74, 22, and 16 correspond to iodine, tungsten, titanium, and sulfur. The symbols I, W, Ti, and S are the conventional chemical symbols for those elements. The design does not ask you to pronounce them as a word. It asks you to recognize the format and then notice that the format has been repurposed for a sentence about laundry rotation.

That distinction matters because a lot of chemistry-themed merchandise treats the periodic table as a substitution cipher. You spell a word by hunting for element symbols that happen to match the letters you need. Sometimes the result is clever. Sometimes it is strained. This design does not attempt that trick. It uses the table as a visual idiom, not a codebook. The tiles are there to signal chemistry and to make the word periodically land as a technical term borrowed for a mundane confession.

I read the tile arrangement as a deliberate refusal of the spelling game. If the designer had wanted a literal element spelling, the tiles would need to form a recognizable word. They do not. What they form is a visual rhyme with the periodic table itself, and the sentence above them supplies the meaning. The tiles are decoration with a specific referent, not a puzzle with a solution.

The pun works because ‘periodically’ is already a frequency claim

The sentence I wear this shirt periodically is funny for a reason that has nothing to do with chemistry. Periodically means at intervals. It is a word you use when you want to avoid committing to a schedule. I wear this shirt periodically is a softer statement than I wear this shirt often or I wear this shirt every week. It implies recurrence without promising regularity. The shirt is in the rotation, but the rotation is not documented.

Then the chemistry context arrives and adds a second meaning. In chemistry, periodic refers to the periodic table, which is organized by recurring patterns in elemental properties. The word periodically can point to both meanings at once: occasionally, and in the manner of the periodic table. The design does not explain this. It simply puts the sentence and the tiles in the same frame and lets the reader supply the connection.

That is a more sophisticated joke than a simple label. It assumes the reader knows what the periodic table looks like and knows what periodically means in ordinary speech. The humor comes from the collision of those two knowledges. If you have only one of them, the shirt still reads as a sentence. If you have both, the sentence becomes a small mechanism.

Why the tile symbols are not a spelling

I want to be precise about this because the visual observation is easy to misstate. The tiles show 53 I, 74 W, 22 Ti, and 16 S. The symbols are I, W, Ti, and S. If you concatenate them, you get IW TiS, which is not periodically. You cannot rearrange them into periodically without adding letters that are not present. The design is not a cryptogram. It is a set of four element tiles that happen to sit under a word that also has a chemistry meaning.

This is worth saying plainly because a casual description might call it a periodic table spelling of the sentence. It is not. The sentence is spelled out in ordinary letters above the tiles. The tiles are a separate visual element. They do not encode the sentence. They comment on it.

That comment is structural. The periodic table is a grid of recurring properties. The word periodically describes recurrence. The tiles make the recurrence visible as a pattern. The shirt is not claiming that iodine, tungsten, titanium, and sulfur have anything to do with each other in a chemical reaction. It is borrowing the table’s visual authority to make a joke about wearing a shirt sometimes.

The difference between a slogan and a usable promise

Technical readers spend a lot of time around terse messages. Error codes, status labels, commit messages, warning signs. A good terse message is a contract: it tells you what happened, what to expect, or what to do. A bad terse message is a slogan: it sounds authoritative but does not constrain anything.

I wear this shirt periodically is closer to a slogan than a contract, and that is why it works as a shirt. It is a statement about the wearer’s behavior that cannot be falsified in any useful way. If you wear it once, you have worn it periodically. If you wear it every day, you have also worn it periodically. The word is elastic enough to cover almost any pattern. The joke is that the sentence sounds like a specification but functions as a shrug.

I find that distinction useful when I look at work-culture objects. A shirt that says I fix bugs is a slogan. A shirt that says I will not deploy on Friday is closer to a policy. The periodic table shirt is in the slogan category, but it is a self-aware slogan. It knows that periodically is doing vague work, and it leans into the vagueness by pairing it with the most rigid visual system in science. The periodic table is all about predictable recurrence. The sentence is all about unpredictable recurrence. The tension is the joke.

Reading the design without overreading it

There is a temptation to treat a shirt like this as a statement about identity. I am a chemist. I am a science person. I wear this shirt periodically. That reading is available, but it is not required by the artwork. The design does not name a profession. It does not include a lab coat, a flask, or a molecular diagram. It uses the periodic table as a visual reference and a word that happens to have a technical double meaning. The rest is up to the wearer.

What I appreciate about the design is that it does not try to do too much. It has one joke, and it commits to it. The sentence is legible. The tiles are recognizable. The connection between them is not explained, which means the shirt rewards people who already know the reference and remains a plain sentence for everyone else. That is a reasonable design strategy for a garment that has to work in a grocery store and a conference hallway.

If you are looking for the shirt, it is listed as I Wear This Shirt Periodically Funny Chemistry Science Pun from Quirky Cotton Collective. The listing title uses the phrase i wear this shirt periodically, which is also the search term that describes the joke. The artwork is the thing worth looking at, though. The title is just a label. The tiles are the argument.

The small pleasure of a diagram joke

Diagram jokes are a specific genre. They work when the diagram is familiar enough that you recognize the format instantly, and when the content placed inside the format is slightly wrong. The periodic table is one of the most recognizable diagrams in science. Putting a sentence about shirt-wearing next to it is a mild violation of expectations. The tiles do not belong there. That is why they are funny.

The design does not push the violation very far. It does not replace the element names with punchlines or rearrange the table into a face. It simply places four tiles under a sentence and lets the format do the work. The restraint is part of the appeal. A louder version of this joke would be exhausting. This version is quiet enough to wear.

I do not know whether the designer intended the periodically double meaning to be the primary joke or whether the tiles were added to justify the word. It does not matter. The object as it exists has both elements, and the relationship between them is legible. The sentence says one thing. The tiles say another. The shirt is the space between them.

That is a useful thing to notice about visual language. A design can be funny without being a puzzle. It can be clever without being a code. The periodic table shirt is not asking you to solve it. It is asking you to recognize it. The recognition is the reward.

Why Your Custom Post Type Is Missing from the REST Search Endpoint: WP_REST_Search_Handler, the wp_rest_search_handlers Filter, and the post/page Default

Your custom post type has a working REST route. GET /wp-json/wp/v2/your-cpt returns a tidy collection. The block editor loads it. Then you query GET /wp-json/wp/v2/search?search=quarterly and get back posts, pages, maybe a category term, and nothing from your CPT. The instinct is to reach for wp_rest_search_handlers and register a handler. That instinct is usually wrong, and acting on it can duplicate results rather than fix them.

The /wp/v2/search endpoint does not enumerate post types directly. It delegates to a registry of search handlers. One of those handlers, WP_REST_Post_Search_Handler, already covers every public post type that is exposed to REST. If your CPT is missing, the problem is almost always in the post type registration flags, not in the handler list.

Symptom

A request to the search endpoint returns results whose subtype values are post and page, but never your CPT slug. The same CPT appears correctly at its own collection route. The block editor’s search panel, if it uses the search endpoint, also omits it.

Two separate registries are involved, and conflating them is the source of most wasted debugging time:

  • The handler list. A site-wide array of WP_REST_Search_Handler instances, filtered by wp_rest_search_handlers. The documentation states the default is only a handler for posts. That handler’s type is post.
  • The subtype list. Built once inside WP_REST_Post_Search_Handler::__construct() from get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ), minus attachment.

The handler list is global and rarely needs changing. The subtype list is built once, when the default post search handler is instantiated during REST route registration, not per request. This is exactly why a register_post_type() call on a late or conditional hook can miss the list. A CPT that is absent from search results but present at its own route points at the flags, not the handler list.

Trace

Start by reading the constructor of the default post search handler. The relevant lines are short:

public function __construct() {
    $this->type = 'post';
    // Support all public post types except attachments.
    $this->subtypes = array_diff(
        array_values( get_post_types( array( 'public' => true, 'show_in_rest' => true ), 'names' ) ),
        array( 'attachment' )
    );
}

Two flags gate inclusion: public and show_in_rest. Both must be true. The register_post_type() documentation is explicit that show_in_rest controls whether the post type is included in the REST API, and that public defaults to false. A CPT registered with only show_in_rest => true and no public argument will not appear in the subtype list.

When a search request arrives with subtype any, search_items() replaces the requested post types with the handler’s subtype list:

if ( in_array( WP_REST_Search_Controller::TYPE_ANY, $post_types, true ) ) {
    $post_types = $this->subtypes;
}

That is the mechanism. If your CPT is not in $this->subtypes, it is not queried. The handler then runs a WP_Query with 'post_status' => 'publish'. A correctly registered CPT whose entries are all drafts will also appear missing. Separate the registration question from the status question before changing code.

The search results schema lists subtype values as post, page, category, and post_tag. That list is illustrative of core defaults, not exhaustive of every handler a site may register. The type parameter on the endpoint defaults to post, which is why a bare search request without a type or subtype argument behaves as a post search.

Root cause

The default post search handler already enumerates every public, REST-exposed post type. Adding a custom handler to wp_rest_search_handlers to fix a missing CPT is likely to duplicate results, because the default handler is already covering the same post type when the flags are set correctly. The filter is for object types the post handler cannot represent, not for post types it already covers.

The wp_rest_search_handlers filter is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler. That is a real extension point, but it is not the first place to look when a CPT is absent.

Two failure modes produce the same symptom:

  1. Registration flags. public is false or omitted, or show_in_rest is false or omitted. The CPT is not in the subtype list, so the default handler never queries it.
  2. Status. The CPT is registered correctly, but all its entries are drafts, pending, or otherwise not publish. The handler queries with post_status => 'publish', so nothing returns.

A third, less common case: the CPT is registered on a hook that runs too late for the handler’s constructor, or the registration is conditional on a context that is not true during REST requests. The constructor runs when the handler is instantiated, which is during REST route registration. If your register_post_type() call is hooked to something that has not fired by then, the subtype list will not include it.

Fix

Diagnose in order. Do not start with the filter.

1. Inspect the registered post type object

Use WP-CLI to confirm the flags as WordPress sees them:

wp eval '
$obj = get_post_type_object( "your-cpt" );
if ( ! $obj ) {
    echo "not registered\n";
    exit;
}
echo "public: " . var_export( $obj->public, true ) . "\n";
echo "show_in_rest: " . var_export( $obj->show_in_rest, true ) . "\n";
echo "rest_base: " . $obj->rest_base . "\n";
'

If public or show_in_rest is false, that is your root cause. Fix the registration arguments. Both must be true for the default post search handler to include the CPT.

2. Inspect the search response’s subtype field

Request the search endpoint with the subtype set to your CPT slug and compare against the subtype values the response reports:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

If the response is an empty array or an error, the subtype is not recognized. If it returns results, the registration is fine and the earlier absence was a status or query issue. The subtype field in the response is the observable that distinguishes “not registered as searchable” from “registered but empty.”

3. Inspect the handler list

Only after the first two checks should you look at the handler registry. A quick eval confirms what is registered:

wp eval '
$handlers = apply_filters( "wp_rest_search_handlers", array() );
foreach ( $handlers as $handler ) {
    echo get_class( $handler ) . " => " . $handler->get_type() . "\n";
    print_r( $handler->get_subtypes() );
}
'

If the default post handler is present and its subtype list includes your CPT, the handler list is not your problem. If the subtype list does not include your CPT, return to step 1.

4. When a custom handler is actually warranted

A custom handler is appropriate when you need to search an object type the post handler cannot represent: a custom database table, an external index, a non-post entity. It is not appropriate for a CPT that the post handler already covers.

WP_REST_Search_Handler declares three abstract methods that a subclass must implement:

  • search_items( WP_REST_Request $request ) — returns an associative array with RESULT_IDS (an array of found IDs) and RESULT_TOTAL (the total count).
  • prepare_item( $id, array $fields ) — returns the fields for a given item.
  • prepare_item_links( $id ) — returns links for a given item.

The base class also provides get_type() and get_subtypes(), which return the $type and $subtypes properties set by the subclass. A minimal skeleton:

class My_CPT_Search_Handler extends WP_REST_Search_Handler {
    public function __construct() {
        $this->type = 'my-cpt';
        $this->subtypes = array( 'my-cpt' );
    }

    public function search_items( WP_REST_Request $request ) {
        $query = new WP_Query( array(
            'post_type' => 'my-cpt',
            'post_status' => 'publish',
            's' => $request['search'],
            'paged' => (int) $request['page'],
            'posts_per_page' => (int) $request['per_page'],
        ) );
        return array(
            self::RESULT_IDS => wp_list_pluck( $query->posts, 'ID' ),
            self::RESULT_TOTAL => $query->found_posts,
        );
    }

    public function prepare_item( $id, array $fields ) {
        $post = get_post( $id );
        $data = array();
        if ( in_array( WP_REST_Search_Controller::PROP_ID, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_ID ] = (int) $post->ID;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TITLE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TITLE ] = get_the_title( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_URL, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_URL ] = get_permalink( $post->ID );
        }
        if ( in_array( WP_REST_Search_Controller::PROP_TYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_TYPE ] = $this->type;
        }
        if ( in_array( WP_REST_Search_Controller::PROP_SUBTYPE, $fields, true ) ) {
            $data[ WP_REST_Search_Controller::PROP_SUBTYPE ] = $post->post_type;
        }
        return $data;
    }

    public function prepare_item_links( $id ) {
        $post = get_post( $id );
        $links = array();
        $item_route = rest_get_route_for_post( $post );
        if ( ! empty( $item_route ) ) {
            $links['self'] = array(
                'href' => rest_url( $item_route ),
                'embeddable' => true,
            );
        }
        $links['about'] = array(
            'href' => rest_url( 'wp/v2/types/' . $post->post_type ),
        );
        return $links;
    }
}

add_filter( 'wp_rest_search_handlers', function( $handlers ) {
    $handlers[] = new My_CPT_Search_Handler();
    return $handlers;
} );

Register this only when the default post handler genuinely cannot cover the object type. If you register it for a CPT that is already public and REST-exposed, you will get duplicate entries in search results.

Verification

Confirm the subtype appears in a live search response before and after the registration change. A minimal check:

curl -s 'https://example.com/wp-json/wp/v2/search?search=quarterly&subtype=your-cpt' | jq '.subtype'

Before the fix, expect an empty array or an error. After correcting the registration flags, expect the CPT slug to appear in the subtype values. If you added a custom handler, verify that the default post handler’s results are not duplicated.

For a broader diagnostic sequence on empty search results, see What to Fix First When a New WordPress Site Says Nothing Found. The same order applies here: check registration and status before touching the handler registry.

FAQ

Does the search endpoint search all post types by default?

No. The type parameter defaults to post, and the default post search handler’s subtype list is built from public, REST-exposed post types. A CPT that is not public or not exposed to REST will not appear.

Why does my CPT appear at its own REST route but not in search?

The collection route for a CPT is registered by WP_REST_Posts_Controller when the post type has show_in_rest true. The search endpoint uses a separate handler whose subtype list requires both public and show_in_rest to be true. A CPT with show_in_rest true but public false can have a working collection route and still be absent from search.

Can I add my CPT to search with the wp_rest_search_handlers filter?

You can, but you should not need to for a standard CPT. The default post handler already covers public, REST-exposed post types. Adding a handler for a CPT the default handler already covers will produce duplicate results. Use the filter for object types the post handler cannot represent.

What if my CPT entries are all drafts?

The default post search handler queries with post_status => 'publish'. Drafts, pending, and private entries will not appear. This is a status issue, not a registration issue. Publish at least one entry and retest before changing code.

Where is the wp_rest_search_handlers filter applied?

It is applied in create_initial_rest_routes(), which registers the default REST API routes. Each handler instance passed through the filter must extend WP_REST_Search_Handler.

What methods must a custom search handler implement?

WP_REST_Search_Handler declares search_items(), prepare_item(), and prepare_item_links() as abstract. search_items() must return an array with RESULT_IDS and RESULT_TOTAL keys.

Sources