Template Lock Is an Editor Contract, Not a Storage Contract

The failure mode is quiet. A container block ships with an InnerBlocks template and templateLock="all". Editors behave. Then someone with edit_theme_options opens the same post, the lock affordance is gone or overridable, and the saved markup no longer matches the template. Nothing throws. The front end renders whatever was stored.

That is the part worth internalizing: templateLock is a constraint on the editing session, not on the database. The Block Editor handbook describes templates as specifying a “default initial state for an editor session.”1 A default state is not an invariant. If your theme or plugin assumes the stored block tree will always match the template, you have assumed something the editor never promised.

Symptom

A custom block, say acme/callout, registers an InnerBlocks template of exactly one core/paragraph and locks it with templateLock="all". The rendered output wraps that paragraph in a styled aside. One day the aside renders empty, or with three paragraphs, or with a heading where the paragraph should be. The post was saved through the editor by a user who could unlock, or saved through the REST API, or created programmatically by a migration script that never touched the editor at all.

The lock did its job for the users it was designed for. It did not do the job you assumed it did for everyone else.

Trace

Three layers are involved, and they are not the same layer.

Editor-side locking. templateLock on InnerBlocks, template_lock on a post type registration, and the per-block lock attribute all constrain what the editor UI permits. The handbook is explicit about the values: all “prevents all operations. It is not possible to insert new blocks, move existing blocks, or delete blocks.” insert prevents inserting or removing but allows moving. contentOnly restricts editing to content attributes.1 These are descriptions of UI behavior.

Inheritance. Locks propagate. “If templateLock is not set in an InnerBlocks area, the locking of the parent InnerBlocks area is used. If the block is a top level block, the locking configuration of the current post type is used.”1 Inheritance is convenient and also means a lock can be absent at the level you are inspecting while still being present somewhere above it, or vice versa.

Storage. What actually lands in wp_posts.post_content is serialized block markup. The block parser stores attributes from the comment delimiters in the parsed block structure.2 Nothing in that parse step consults templateLock. The parser does not know what template you intended; it only knows what delimiters and attributes are present.

Render. The front end walks the stored tree and renders it. The render_block filter runs there. The handbook notes it “filters the front-end content of any block” and “has no impact on the behavior of blocks in the Editor.”3 That sentence is the whole argument in miniature: the editor and the renderer are separate surfaces, and only one of them is where your output contract can be enforced.

Root cause

The premise in the question — that a user with edit_theme_options can bypass templateLock — is plausible but not something the retrieved documentation establishes. current_user_can() “returns whether the current user has the specified capability” and accepts an object ID for meta capabilities.4 That tells you how to check a capability. It does not tell you which capability the editor consults before honoring a lock, and the handbook’s locking pages describe the lock values without documenting a capability gate on them.

So treat the specific capability claim as a hypothesis to verify in your own install, not a documented fact. The durable point does not depend on it: any path that writes post_content without going through the locked editor — a REST update, a WP-CLI post update, an import, a programmatic wp_insert_post(), a future editor change — produces stored markup the lock never saw. If your invariant matters, it cannot live only in the editor.

Two tiers are useful here:

  • Editor locks reduce accidental damage for the users the UI is designed for. Keep them.
  • Render-time enforcement asserts the output contract for everyone, including writers who never open the editor.

Fix: enforce at render time

The render_block callback receives three arguments: the block content, the full block including name and attributes, and the WP_Block instance.3 That is enough to inspect structure and decide what to emit. For a single block type, render_block_{namespace/block} is the narrower form.3

The pattern below is illustrative, not a claim about a specific site. It asserts that acme/callout contains exactly one core/paragraph child and emits a safe fallback otherwise. It does not mutate stored content; it only changes what the front end receives.

add_filter( 'render_block_acme/callout', function ( $block_content, $block ) {
    $inner = $block['innerBlocks'] ?? array();

    $paragraphs = array_filter( $inner, function ( $child ) {
        return isset( $child['blockName'] ) && 'core/paragraph' === $child['blockName'];
    } );

    if ( 1 !== count( $inner ) || 1 !== count( $paragraphs ) ) {
        // Structural violation: emit a minimal, safe wrapper instead of the stored tree.
        return '<aside class="acme-callout acme-callout--invalid"></aside>';
    }

    return $block_content;
}, 10, 2 );

Two details matter. First, $block['innerBlocks'] is the parsed child tree, so you are inspecting structure rather than regexing HTML. Second, returning a fallback rather than throwing keeps a single malformed post from taking down a page; if you would rather fail loudly in staging, log the violation and let the fallback stand in production.

If the invariant is about attributes rather than children — say a variant attribute that must be one of a fixed set — inspect $block['attrs'] the same way. The block attributes reference documents how attributes are declared and where their values are sourced from, which is what makes $block['attrs'] reliable to read at render time.5

Why not enforce at save time?

Save-time enforcement means rejecting or rewriting post_content on write. It sounds stronger and is usually worse. It has to cover every write path — REST, WP-CLI, imports, programmatic inserts — and it changes stored data, which means a later change to your rules leaves old posts in a state you have to migrate. Render-time enforcement covers every read path with one filter and leaves the database alone. The tradeoff is that the database can hold content your renderer will not display; that is a deliberate choice, and it is the same choice core makes when it lets render_block alter output without touching storage.3

Do not try to enforce structure through blocks.getSaveContent.extraProps or similar editor-side save filters. The handbook warns that modifying existing content through those filters causes block validation errors the next time the post is edited, and points to render_block as the correct place for server-side modification.3

Verification

You cannot verify a render-time guard by editing in the UI, because the UI is the surface you are no longer trusting. Verify by writing content the editor would not have produced.

First, confirm the stored tree for a suspect post. This query shows the raw serialized content; look for the acme/callout delimiters and count the child block comments between them.

wp db query "SELECT ID, post_title, post_content
FROM wp_posts
WHERE post_content LIKE '%wp:acme/callout%'
AND post_status = 'publish'
LIMIT 20;"

Second, create a violation deliberately. Take a post that renders correctly, copy its post_content, remove the inner paragraph block comments, and write it back with WP-CLI. This simulates any write path that bypassed the editor.

wp post update 123 --post_content="$(cat broken-callout.txt)"

Third, load the post on the front end. With the filter active you should see the fallback wrapper and no stored children. With the filter removed you should see the malformed output. That difference is the proof that enforcement lives at render time and not in the editor.

Fourth, if you want to test the capability hypothesis directly, create a user with edit_theme_options and without edit_posts on the target post type, open a locked post as that user, and observe whether the lock affordance is present and whether the block tree can be changed. Record what you see. Do not assume the answer from the capability name; the documentation does not settle it.

What to keep in the editor

None of this argues for removing templateLock. It argues for knowing what it is for. Locks keep ordinary editing sessions inside the shape you designed, which is most of the value. The render filter is the backstop for the sessions the lock does not cover. If you are still bringing a new install up and deciding which failures to chase first, the ordering in what to fix first when a new WordPress site says nothing found is a reasonable companion for the earlier stage of that work.

The rule of thumb: if a structural property is merely nice for editors, keep it in the editor. If it is a contract your front end depends on, assert it in render_block and test it by writing content the editor would never have allowed.

FAQ

Does templateLock get enforced when a post is saved through the REST API?

The retrieved documentation does not describe any server-side enforcement of templateLock on save. The locking pages describe editor behavior, and the parser stores whatever delimiters and attributes are present.12 Treat REST writes as a path that can produce content the lock did not shape.

Can I just check current_user_can( 'edit_theme_options' ) and tighten the lock for those users?

You can check the capability, but the documentation does not establish that the editor consults it before honoring a lock, so a check you add is your own policy, not a fix for a documented bypass. A render-time assertion covers every writer regardless of capability, which is why it is the more durable layer.

Will a render filter break block validation in the editor?

No. render_block “has no impact on the behavior of blocks in the Editor.”3 Validation compares stored markup to the block’s save() output; a front-end filter is downstream of both.

What about contentOnly locks?

contentOnly is a lock value with its own semantics — it restricts editing to content attributes and, unlike the other lock types, is not overridable by children.1 It is still an editor-side constraint. If the content attributes it protects feed a front-end contract, assert that contract at render time too.

Sources

  1. Block Editor Handbook — Block Templates (template as default editor state; template_lock values; lock inheritance).
  2. WordPress Reference — WP_Block_Parser_Block (attributes from block comment delimiters stored in parsed structure).
  3. Block Editor Handbook — Block Filters (render_block and render_block_{namespace/block}; callback arguments; no editor impact; save-filter validation warning).
  4. WordPress Reference — current_user_can() (capability check semantics).
  5. Block Editor Handbook — Attributes (attribute declaration and value sources).

The Small Lowercase ‘meh.’ Shirt Is a Typographic Shrug, Not a Personality

The native garment preview shows a dark shirt with very small, centered lowercase lettering: meh. No animal illustration. No pride variant. No slogan in a bold display face. The period is there, and the scale is the argument. This is not a shirt that announces a mood; it is a shirt that files one.

That distinction matters because the retail title stacks three adjectives — funny, sarcastic, introvert — onto a design that visually refuses all three. The printed artwork is a single word in a quiet face, set small and centered. The title is metadata; the shirt is typography. If you are buying the meh shirt expecting a punchline across the chest, the preview will correct you. The joke, such as it is, lives in the gap between how little ink is used and how much the word implies.

Scale as the load-bearing element

Most text shirts fail because the type is sized to be read across a room. That is a billboard contract: the wearer agrees to be a moving sign. This design does the opposite. The lettering is small enough that a reader has to be within conversational distance to resolve it, which means the shirt only performs for people already close enough to warrant a response. The period does the rest. A bare meh is a sound; meh. is a verdict, closed and filed.

I read the lowercase as deliberate understatement rather than laziness. Lowercase removes the emphasis that capitals would add, and the small point size removes the emphasis that scale would add. What remains is a word that has already given up on being impressive. That is a coherent design decision, and it is the only thing the preview actually proves. Whether it reads as sarcastic, introverted, or merely tired depends on the wearer and the room, not on the garment.

The title is doing work the shirt refuses

“Funny Sarcastic Introvert Minimalist Saying” is a search-friendly description, not a printed claim. The word minimalist is the only one the artwork supports on its own. Funny and sarcastic are audience attributions — they describe how a buyer might use the shirt, not what the ink says. Introvert is the most speculative of the three: a small word on a dark shirt does not encode a social preference, and treating it as a personality label is a reading, not a fact.

This is the same category error I see in software when a function name promises behavior the implementation never delivers. The name is a contract; the body is the truth. Here the title is the name and the preview is the body. The body says: one word, small, lowercase, period. Everything else is marketing metadata that the garment does not have to honor.

What a terse message can and cannot do

There is a real use case for clothing that communicates a boundary without a speech. A small meh. functions as a low-stakes signal: not hostile, not enthusiastic, not available for a longer conversation. It is the visual equivalent of a one-line reply that closes a thread. For technical readers who spend the day writing terse commit messages and terse status updates, the appeal is legible — it is the same economy of language applied to a social interaction.

But I want to be precise about what the shirt does not do. It does not improve anyone’s mood, cure introversion, or guarantee that strangers respect the signal. A printed word is not an API; it has no enforcement mechanism. The most honest claim is that the design is internally consistent: the scale, the case, and the punctuation all point in the same direction. That consistency is the craft. The rest is interpretation.

Reading the object without overreading it

The preview establishes the object: small centered lowercase meh. on a dark shirt, no animal, no variant. That is enough to judge the design on its own terms. The word is set quietly, the period is present, and the composition is centered rather than offset. If you want a shirt that says a lot with very little, this is a coherent example. If you want a shirt that makes a joke at distance, this is not it — and the preview tells you so before you buy.

The useful argument is not that the shirt is funny or that it suits introverts. It is that the design’s restraint is the point, and the retail title is a separate layer that should not be mistaken for the artwork. Read the preview, not the adjectives. The preview is the contract; the adjectives are the pitch.

Editorial note: This publication and Quirky Cotton Collective share an owner; this is not an independent review.

The ‘I Wear This Shirt Periodically’ Shirt Is a Diagram, Not a Sentence: Reading the Element Tiles

The shirt does not spell the sentence. It diagrams it. Across the chest, four periodic-table-style tiles sit in a row: 53 I, 74 W, 22 Ti, 16 S. Above or beside them, the retail wording reads I WEAR THIS SHIRT PERIODICALLY. The tiles themselves give you I, W, Ti, S. That is not a literal transcription of the phrase. It is a compressed notation that asks you to do the reading.

That gap between the printed tiles and the sentence is the whole design. Most slogan shirts hand you a finished sentence and ask only that you agree or laugh. This one hands you a small parsing problem: four element symbols, four atomic numbers, one pun. The joke is not that chemistry is hard. The joke is that the sentence has been routed through a notation system that was never meant to carry it, and the result is legible only if you know how to read the table.

What the tiles actually say

Read literally, the four tiles are:

  • 53 I — iodine
  • 74 W — tungsten, from Wolfram
  • 22 Ti — titanium
  • 16 S — sulfur

String the symbols together and you get I W Ti S. Say it aloud and it collapses toward “I wear this” — not exactly, but close enough that the eye completes the phrase. The final S is doing double duty: it is the sulfur tile and the last letter of the sentence. The Ti tile is the hinge. It is the one place where the notation refuses to be a single letter, and that refusal is what makes the pun work. A shirt that printed I W T S would be a rebus with a hole in it. The Ti fills the hole.

This is worth stating plainly because retail descriptions often smooth the design into “periodic table shirt” and move on. The specific arrangement matters. The tiles are not decorative filler around a slogan. They are the slogan, compressed into a notation that only resolves when you read the symbols as sounds rather than as element names.

Why the compression is the joke

There is a particular pleasure in a design that withholds its own punchline by exactly one step. The shirt does not say “I wear this shirt periodically” in plain English and then add element tiles as garnish. It makes the tiles carry the sentence. That means the wearer is not just displaying a fact about their wardrobe. They are displaying a small act of translation.

I think that is why the design reads as a diagram rather than a slogan. A slogan is a closed statement. A diagram is an invitation to trace. The four tiles are a trace: atomic number, symbol, atomic number, symbol. The sentence is the output. The pun is the fact that the output is also a frequency claim — periodically — which the periodic table happens to literalize. The shirt is not making a chemistry argument. It is making a notation argument, and the notation happens to be chemistry-shaped.

That distinction matters for anyone who spends their working life reading systems that compress meaning into symbols. A configuration file, a schema, a query plan: each is a notation that only resolves if you know the grammar. The shirt is a low-stakes version of the same cognitive move. You see four tiles. You do not see a sentence until you read them correctly. The pleasure is in the resolution, not in the initial glance.

The difference between a slogan and a usable promise

There is a broader point here about terse messages and what they can carry. A slogan asserts. A usable promise specifies. “I wear this shirt periodically” is a joke about frequency, not a commitment to a schedule. The shirt does not tell you when. It does not tell you how often. It tells you that the wearing is periodic, and then it makes you read the word periodically through a table that organizes elements by period. The promise is deliberately underspecified. That is what makes it funny rather than pedantic.

In work culture, the same distinction shows up constantly. A team that says “we ship regularly” has made a slogan. A team that says “we deploy on Tuesdays and Thursdays, and here is the rollback procedure” has made a usable promise. The shirt sits on the slogan side, but it is a good slogan because it knows it is a slogan. It does not pretend to be a specification. It compresses, and it lets the compression be the point.

That is a useful thing to notice about boundary wording, too. The recent “Not Friendly Do Not Touch” shirt made a boundary into a printed sign. This shirt makes a frequency claim into a notation puzzle. Both are terse. Both rely on the reader to complete the message. The difference is that the boundary shirt wants to be read instantly, as a warning, while this one wants to be read slowly, as a joke. Terse messages are not all the same kind of object. Some are doors. Some are diagrams.

Reading the object without overclaiming it

I am not going to tell you the shirt is comfortable, or that it fits well, or that it survives a wash cycle. I have not worn it. What I can read is the printed artwork: four tiles, four atomic numbers, four symbols, one sentence that resolves only when you sound out the symbols. That is the object. The rest is retail.

The design is available as a standard t-shirt from Quirky Cotton Collective. If you are the kind of reader who enjoys a notation that withholds its own punchline, the shirt is a small, wearable version of that pleasure. If you want a shirt that states its message outright, this is not that shirt. It is a shirt that asks you to read the tiles first.

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

The ‘Not Friendly Do Not Touch’ Shirt Is a Boundary, Not a Bug Report

The shirt reads NOT FRIENDLY DO NOT TOUCH in warning-sign lettering, with a yellow caution triangle and a crossed-out hand symbol. That is the whole design. No punchline, no mascot, no secondary caption. It borrows the visual grammar of industrial signage — the triangle, the imperative, the prohibition mark — and applies it to a person.

I want to argue something specific about that move: the design works because it is a boundary statement, not a personality claim. The distinction matters, and it is the same distinction that separates a usable system contract from a slogan taped to a monitor. A slogan describes a mood. A boundary describes an interface.

What the sign language actually says

Look at the three elements separately. The caution triangle is a hazard marker: it says something here can hurt you, not something here is broken. The crossed-out hand is a prohibition glyph: it says do not initiate contact, not contact is impossible. The lettering is imperative and unhedged: DO NOT TOUCH, not please avoid touching.

Read together, the design is not claiming the wearer is hostile. It is declaring a contact policy. That is a narrower and more defensible claim than “I am unfriendly,” which is how the phrase is usually paraphrased. The shirt says: the default state is closed to unsolicited input. It does not say the interface has no endpoints.

That reading is my interpretation of the printed artwork, not a historical claim about warning-sign conventions or a statement about introversion as a category. I am reading the graphic the way I would read any other interface label: what does it promise, what does it prohibit, and what does it leave unspecified?

The difference between a slogan and a usable promise

In systems work, a slogan is a sentence that sounds like a guarantee and behaves like a vibe. “Production-ready.” “Secure by default.” “Zero-config.” None of them tell you what happens at the boundary. A usable promise does: it names the input, the condition, and the outcome. POST /orders returns 201 or 422. That is a contract. “We care about reliability” is not.

The shirt is interesting because it is closer to the contract end of that spectrum than most slogan apparel. It names the input (touch), the condition (unsolicited), and the outcome (prohibited). It is terse, but it is not vague. The crossed-out hand is doing the work a status code does: it tells you what the response will be before you send the request.

What it deliberately leaves unspecified is the exception path. There is no “unless” clause, no allowlist, no “friends and family” carve-out printed anywhere. That is not a flaw in the design; it is the point. The sign is the default policy, and defaults are supposed to be legible without a meeting.

Why the caution triangle is the right glyph and the wrong metaphor

Here is where I want to be careful, because it would be easy to overreach. The caution triangle is a hazard symbol, and it is tempting to read the shirt as saying the wearer is dangerous. I do not think the artwork supports that. The triangle marks the zone, not the person. It is the same grammar as a wet-floor sign: the hazard is the condition, not the janitor.

That is a meaningful distinction for anyone who has watched a team confuse a boundary with an insult. “Do not page me after 7pm” is not a claim that the on-call engineer is unfriendly. It is a statement about the interface between work and rest. The shirt makes the same move in fabric: it marks the zone and states the rule, and it declines to apologize for either.

I would not claim the design demonstrates anything about introversion, social psychology, or workplace dynamics. It is a graphic. But as a piece of visual rhetoric, it is unusually disciplined: three elements, one message, no hedging.

The terse message as a design constraint

There is a craft argument here that technical readers will recognize. Terse messages are hard. A status line, a commit subject, a runbook heading — each has to carry the full meaning in the fewest tokens, because the reader is mid-task and will not read a paragraph. The shirt’s lettering is doing that job. NOT FRIENDLY sets the state. DO NOT TOUCH sets the rule. The glyphs carry the rest.

Most slogan shirts fail this test because they try to be funny and clear at the same time and end up neither. This one is funny because it is clear. The joke is the mismatch between industrial signage and a human torso, and the mismatch only lands if the sign is read literally first. That is a real constraint, and the design respects it.

If you want the object itself, it is the Not Friendly Do Not Touch warning sign shirt from Quirky Cotton Collective, and the listing matches the artwork described above: the caution triangle, the crossed-out hand, the blunt imperative.

What the shirt is not

It is not a security control. It is not an access-management policy. It is not evidence that the wearer has any particular temperament, and it does not cure or improve anything. It is a printed boundary, worn in public, in the visual language of a hazard sign. That is a modest thing to be, and it is enough.

The useful argument is this: the design earns its place by being a contract rather than a slogan. It states a default, names the prohibited input, and leaves the exception path unstated on purpose. Most apparel copy does the opposite — it gestures at a personality and leaves the reader to guess the rule. This one guesses nothing. It tells you where the zone ends and what happens if you cross it, and then it stops.

That is a good discipline for a shirt. It is also a good discipline for a system. The sign is legible because someone decided what it would not say.

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

A Revisions-Count REST Field Without the N+1 Tax

A Revisions-Count REST Field Without the N+1 Tax

Symptom. A custom revision_count field appears in /wp-json/wp/v2/posts. The response is correct. Query Monitor, however, shows one SELECT COUNT(*) against wp_posts per post in the collection — ten posts, ten counts; a hundred posts, a hundred counts. The endpoint is not slow enough to page anyone at 3 a.m., which is precisely why it survives until the archive page starts rendering a hundred rows.

Trace. The field was registered with register_rest_field() and a get_callback that calls a counting function. The callback contract is the problem: register_rest_field() documents that the get callback “will be passed the prepared object data” — one prepared post at a time. The REST server invokes that callback once for each item it serializes. A query inside the callback therefore runs once per item. That is the N+1 shape, and no amount of cleverness inside the callback removes it, because the callback never sees the collection.

Root cause. Per-item work placed in a per-item hook. The fix is not a faster count; it is moving the count out of the callback and into a batched step that runs once per request, keyed on the IDs the request is about to serialize. The callback then reads from a prebuilt map.

What register_rest_field() actually gives you

The function registers a field on an existing object type and accepts three arguments that matter here: get_callback, update_callback, and schema. The documentation is explicit that the get callback receives the prepared object data, and that omitting update_callback makes the field read-only. It also notes that fields should be registered on rest_api_init, not init, so registration does not run on requests that never touch the REST API.

That is the whole surface. There is no collection-level callback, no “before serialize” hook in the registration arguments, and no batch parameter. If you want batching, you supply it yourself.

The naive field, and its query shape

Here is the version that produces the symptom. It is deliberately ordinary.

add_action( 'rest_api_init', function () {
    register_rest_field(
        'post',
        'revision_count',
        array(
            'get_callback' => function ( $post_arr ) {
                global $wpdb;
                return (int) $wpdb->get_var(
                    $wpdb->prepare(
                        "SELECT COUNT(*) FROM {$wpdb->posts}
                         WHERE post_parent = %d
                           AND post_type = 'revision'
                           AND post_status = 'inherit'",
                        $post_arr['id']
                    )
                );
            },
            'schema' => array(
                'description' => 'Number of stored revisions for the post.',
                'type'        => 'integer',
                'context'     => array( 'view', 'edit' ),
                'readonly'    => true,
            ),
        )
    );
} );

Two things are correct here and should stay: the value is cast to an integer, and the post ID goes through $wpdb->prepare() with a %d placeholder. The wpdb documentation is unambiguous that all untrusted values in a SQL statement must be escaped, and that prepare() accepts %s, %d, and %f placeholders. The post ID is not attacker-controlled in most requests, but “most” is not a security posture.

What is wrong is the placement. The callback is invoked per item, so the query is per item. The count itself is cheap on a small site and linear on a large one; the cost is not the individual COUNT, it is the round trips multiplied by page size.

The batching move

The collection’s post IDs are known before the per-item callbacks run. The REST posts controller builds a WP_Query, and the resulting post objects are what get prepared and serialized. If you can observe the query results once, you can compute a map of post_id => revision_count in a single grouped query, stash it in a request-scoped variable, and have the per-item callback read from that map.

The hook that makes this practical is the_posts, which fires after a WP_Query has fetched its posts. It receives the array of posts and the query object. You can inspect the query to decide whether it is a REST posts collection, collect the IDs, run one grouped query, and store the result.

/**
 * Request-scoped map of post ID => revision count.
 *
 * @var array<int,int>
 */
function jooom_revision_count_map( ?array $set = null ) {
    static $map = array();
    if ( null !== $set ) {
        $map = $set;
    }
    return $map;
}

add_action( 'the_posts', function ( $posts, $query ) {
    if ( ! $query->get( 'rest_route' ) && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
        return;
    }
    if ( empty( $posts ) ) {
        return;
    }

    $ids = array();
    foreach ( $posts as $post ) {
        if ( $post instanceof WP_Post && 'revision' !== $post->post_type ) {
            $ids[] = (int) $post->ID;
        }
    }
    if ( empty( $ids ) ) {
        return;
    }

    global $wpdb;
    $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
    $sql = $wpdb->prepare(
        "SELECT post_parent, COUNT(*) AS revision_count
         FROM {$wpdb->posts}
         WHERE post_parent IN ($placeholders)
           AND post_type = 'revision'
           AND post_status = 'inherit'
         GROUP BY post_parent",
        $ids
    );

    $rows = $wpdb->get_results( $sql, ARRAY_A );
    $map  = array_fill_keys( $ids, 0 );
    foreach ( $rows as $row ) {
        $map[ (int) $row['post_parent'] ] = (int) $row['revision_count'];
    }

    jooom_revision_count_map( $map );
}, 10, 2 );

Then the field callback becomes a lookup:

'get_callback' => function ( $post_arr ) {
    $map = jooom_revision_count_map();
    $id  = (int) $post_arr['id'];
    return isset( $map[ $id ] ) ? $map[ $id ] : 0;
},

Three details keep this honest. First, the map is initialized with zero for every requested ID, so posts with no revisions return 0 rather than null or a missing key. Second, the grouped query is driven by the collection’s IDs, so its cost scales with page size, not with the size of the posts table. Third, the IN list is built from placeholders and passed through prepare(); the wpdb documentation warns that % characters inside SQL string literals must be double-escaped, which is why the query avoids LIKE entirely.

One caveat about the hook: the_posts fires for every WP_Query, including queries that have nothing to do with the REST request. The guard above checks REST_REQUEST or the rest_route query var, but a plugin that runs its own WP_Query during a REST request will also trigger the batch. That is wasted work, not incorrect data, because the map is keyed by ID and the callback only reads IDs it asked for. If you want to be stricter, check $query->get( 'post_type' ) against the post types whose REST controllers you registered the field on.

Why not just call a core helper?

There is a natural instinct to reach for a core function that counts revisions. The retrieved sources for this article do not include a fetched page documenting such a helper’s signature, so this article does not assert one exists or recommend calling it. The batched query above is presented as an illustrative shape, not as a claim about how core implements revision counting internally. If you do find a core helper that accepts a post ID and returns a count, it will still be a per-post call, and the N+1 problem returns. The batching is the point, not the specific SQL.

The revisions-specific wrinkle

Revisions are posts. The core revisions controller queries them with post_parent set to the parent ID, post_type set to 'revision', and post_status set to 'inherit', and it only does so after checking wp_revisions_enabled( $parent ). It also exposes the total revision count in the X-WP-Total response header for the revisions collection endpoint.

That header is useful context but not a substitute for the field. It belongs to a different request — /wp/v2/posts/<id>/revisions — and reading it would require a second HTTP round trip per post, which is a worse N+1 than the one you started with. The custom field exists so a collection response can carry the count inline. Do not try to reuse the revisions controller’s internals; treat it as a separate endpoint with its own contract.

There is also a correctness question the field should answer explicitly: does “revision count” include autosaves? Autosaves are stored as revisions with a post_name containing -autosave, and core’s save routine skips them when pruning. The query above counts every row with post_type = 'revision' and post_status = 'inherit', which includes autosaves. If your editorial workflow treats autosaves as noise, add AND post_name NOT LIKE '%%-autosave%%' — note the doubled percent signs required by prepare() — and document the choice in the field description. Either answer is defensible; an undocumented one is not.

Schema and read-only discipline

The schema should say what the field is: an integer, described, and read-only. Omitting update_callback is what makes it read-only, per the REST API handbook. A revision count is derived data; accepting writes to it would invite clients to set a number that core’s revision system will immediately contradict.

The handbook also draws a line worth repeating: changing or removing data from core REST responses can break plugins or core behavior and should be avoided, while adding fields is described as not dangerous. A new revision_count field is an addition. Do not “improve” it by filtering rest_prepare_post to rewrite an existing core field.

Verification

Query Monitor is the instrument. Load /wp-json/wp/v2/posts?per_page=20 with the plugin active and filter the queries panel to wp_posts. Before the fix you should see one SELECT COUNT(*) per post, all with the same shape and different post_parent values. After the fix you should see a single grouped query with an IN (...) list and a GROUP BY post_parent, plus the normal posts query. The count of COUNT(*) queries should be one, not twenty.

From the command line, the same check without a browser:

wp eval '
$request = new WP_REST_Request( "GET", "/wp/v2/posts" );
$request->set_param( "per_page", 20 );
$response = rest_do_request( $request );
$data = $response->get_data();
foreach ( $data as $post ) {
    echo $post["id"] . " => " . $post["revision_count"] . PHP_EOL;
}
'

Run it with and without the batching hook and compare the query log. If your site has no revisions on the sampled posts, seed a few with wp post update <id> --post_content="..." before measuring, or the test proves only that zero equals zero.

Finally, confirm the field is absent from non-REST requests. Because registration is on rest_api_init, a normal page load should not run the registration callback at all. If it does, something is calling register_rest_field() on init, and that is a separate bug worth fixing while you are in the file.

Checklist

  • Register on rest_api_init, not init.
  • Keep the per-item callback free of queries; it reads from a request-scoped map.
  • Build the map with one grouped query keyed on the collection’s IDs.
  • Use $wpdb->prepare() with placeholders for every value, including the IN list.
  • Declare the field as an integer with a description and no update_callback.
  • Decide explicitly whether autosaves count, and say so in the schema description.
  • Verify with Query Monitor or wp eval that the number of COUNT queries equals one, not the page size.

FAQ

Does register_rest_field() have a batch callback?

No. The documented arguments are get_callback, update_callback, and schema. The get callback receives one prepared object at a time. Batching has to be supplied by the surrounding code.

Can I cache the counts in a transient instead?

You can, but a revision count changes on every save, so the transient would need invalidation on every revision creation and deletion. A request-scoped map avoids that problem entirely: it is computed once per request from current data and discarded. Persistent caching is a different optimization with a different failure mode — stale counts — and should be a deliberate choice, not a default.

Why not query the revisions endpoint and read X-WP-Total?

Because that is one HTTP request per post, which is a worse version of the same N+1. The header is useful when you are already fetching a revisions collection; it is not a substitute for an inline field on the posts collection.

Is the the_posts hook the only place to batch this?

No. It is a convenient place because it sees the fetched posts before serialization. A custom rest_{$post_type}_query filter or a controller-level preparation step could also work, but those are more invasive and more likely to collide with other plugins. The principle is the same: compute once per request, read per item.

What if the field is registered on a custom post type?

The registration and the query are unchanged; only the object type string passed to register_rest_field() changes. The guard in the batching hook should be widened to include that post type, or the map will be empty and every post will report zero.

The Composition Notebook Shirt Is a Printed Cover, Not a Costume: Reading the Speckled Rectangle

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

The shirt does not show a notebook. It shows a printed notebook cover: a black-and-white speckled rectangle, a dark binding strip down the left edge, and a white ruled label headed COMPOSITION with an outlined ghost and pumpkin inside it. That distinction is the whole design. The garment is not a costume that happens to have a school motif; it is a flat, legible reproduction of a familiar writing surface, transferred onto fabric and worn as a group signal.

Most teacher Halloween shirts reach for a slogan — Teaching Is My Boo, Witch Better Have My Coffee — and let the type do the work. This one withholds the sentence. The only words are the label heading, and the label is not a joke. It is a piece of stationery furniture. The ghost and pumpkin sit inside the ruled lines where a student’s name would go. The design’s argument is structural: the notebook cover is the costume, and the wearer is the page.

What the rectangle actually contains

Look at the observed artwork in order. The outer field is a speckled black-and-white ground — the marbled waste-paper texture that composition books have carried for decades. A dark vertical strip runs along the left side, reading as the taped binding or spine. Inside the field, a white rectangle with thin horizontal rules sits near the top, headed by the word COMPOSITION in a plain, slightly condensed face. Below the heading, inside the ruled area, an outlined ghost and an outlined pumpkin are drawn in line, not filled. The ghost is the rounded sheet-with-eyes shape; the pumpkin is a ribbed circle with a stem. Neither is rendered in color. The whole design is essentially two-tone: dark ink on a light speckled ground, with the label as the only high-contrast white block.

That is a specific visual grammar, and it matters for how the shirt reads at a distance. From across a hallway, the speckle and the white label are the recognizable parts. The ghost and pumpkin are small-scale details that resolve only at conversation distance. The design is therefore doing two jobs at once: it signals school supply at ten feet and Halloween at three. A slogan shirt usually inverts that ratio — the words carry across the room, and the graphic is secondary. Here the graphic carries the room and the words are almost incidental.

The costume is a category, not a garment

The retail title calls this a costume, and the phrase teacher group Halloween appears in the same string. That wording is doing more work than it first appears. A group costume is not a single object; it is a coordination protocol. Everyone in the group has to agree on the same visual token, and the token has to be recognizable without a caption. The composition notebook cover is a good token for exactly the reason the design withholds a slogan: it is a shape people already know. You do not have to read it to recognize it. The ghost and pumpkin are the seasonal flag; the speckled rectangle is the shared identifier.

This is where the shirt differs from a generic teacher Halloween tee. A slogan shirt individualizes the wearer — the joke is your joke, and the group is implied by everyone wearing a different variant. A printed cover individualizes no one. It is closer to a uniform patch: the same rectangle on every torso, with the only variation coming from the garment color underneath and the wearer’s own proportions. That is a deliberate trade. You give up the punchline and gain a visual token that works in a row of people.

Why the label is the load-bearing element

The white ruled label is the part of the design that makes the rest legible. Without it, the speckled rectangle is just texture. With it, the rectangle becomes a composition book — a specific object with a specific history in American classrooms. The word COMPOSITION is not decorative; it is the caption that converts an abstract pattern into a recognizable artifact. The ghost and pumpkin then occupy the label’s ruled space, which is normally reserved for a name, a subject, or a date. Putting Halloween figures there is a small substitution joke: the page has been claimed by the holiday.

I read that substitution as the design’s only real wit. The rest is faithful reproduction. The binding strip, the speckle, the rules, the heading — all of it is doing documentary work. The ghost and pumpkin are the one deviation, and they are drawn in outline rather than filled, which keeps them subordinate to the label’s typography. A filled ghost would compete with the word COMPOSITION; an outlined one sits inside the rules like a pencil sketch. The hierarchy is deliberate: label first, holiday second, texture third.

What the shirt is not

It is not a physical notebook, and it does not pretend to be one. The retail observation is explicit: the teacher Halloween costume is a printed notebook cover, not a physical notebook. That sounds like a trivial clarification, but it matters for how you read the object. A shirt printed to look like a notebook is a representation of a school supply, not a wearable supply. The design’s fidelity is visual, not functional. You are not carrying the book; you are quoting its cover.

It is also not a claim about teaching, curriculum, or classroom outcomes. The artwork contains no slogan about teachers, no promise about learning, and no institutional mark. The only text is the label heading. Any reading of the shirt as a statement about education is an inference the design does not make on its own. What it makes is a visual rhyme: the most ordinary object in a classroom, dressed for October.

The group-costume problem, and why this design solves it

Group costumes fail in predictable ways. The token is too obscure, so no one recognizes it without an explanation. The token is too generic, so it reads as a random Halloween shirt rather than a coordinated set. Or the token depends on a caption, which means the group has to keep explaining itself all night. The composition notebook cover avoids all three. It is widely recognized, it is specific enough to read as a deliberate choice, and it needs no caption because the label supplies the caption in print.

The trade-off is that the design is quiet. In a dim room, the speckle and the white label still read, but the ghost and pumpkin may not. That is not a flaw so much as a calibration: the shirt is built for daylight, hallways, and group photos, not for a dark bar. If you want a costume that announces itself across a crowded room, this is not that object. If you want one that reads as a shared visual token among people standing near each other, the printed cover is better suited than a slogan because it does not depend on anyone reading a sentence.

How to read the object before you buy it

The useful question is not is this cute but what does the design ask the viewer to do. This one asks the viewer to recognize a stationery format and then notice the seasonal substitution inside the label. That is a two-step read, and it rewards proximity. A slogan shirt is a one-step read: the words land, the joke is over. The printed cover is slower, which is why it works better as a group token than as an individual statement.

If you are coordinating a teacher group costume, the practical test is whether the token survives without explanation. Show the shirt to someone outside the group and ask what object it represents. If they say composition notebook, the design is doing its job. If they say speckled shirt with a ghost, the label is not carrying enough weight at the distance you need. That is a legibility question, not a quality judgment, and it is the one that matters for a group.

The shirt is available as a standard t-shirt through the listing: Composition Notebook Costume Teacher Group Halloween. The design is the argument. Read the label first, then the ghost, then the speckle — and notice that the order is the same one the artwork uses.

Injecting a Class into core/group Based on a Child Heading Level, Using render_block_data

The symptom is familiar: a publishing team wants one group of blocks to look different because it opens with an h2, and another group to look different because it opens with an h3. They do not want to edit every post, and they do not want an editor-side convention that contributors will forget. They want the server to decide at render time.

The obvious move is to filter render_block and run a regular expression over the HTML. That works until it doesn’t: the regex matches a heading inside a nested group, or inside a column, or inside a block that renders its own heading from a template, and the class lands on the wrong wrapper. The less obvious move — filtering render_block_data and inspecting the parsed block tree — is more precise, provided you understand what is and is not in that tree when the filter runs.

Where render_block_data sits in the pipeline

WordPress documents render_block_data as filtering “the block being rendered in render_block(), before it’s processed” (developer.wordpress.org). The hook was introduced in 5.1.0; the $parent_block argument was added in 5.9.0. Its signature is:

apply_filters( 'render_block_data', array $parsed_block, array $source_block, WP_Block|null $parent_block )

The core function render_block() applies this filter to the parsed block, then constructs a WP_Block and calls render() on it. The source of render_block() shows the order plainly: $parsed_block = apply_filters( 'render_block_data', $parsed_block, $source_block, $parent_block ); comes before new WP_Block( $parsed_block, $context ) and before $block->render() (developer.wordpress.org).

Inner blocks get the same treatment. WP_Block::render() iterates over inner_content, and for each inner block it applies render_block_data to that inner block’s parsed data before rendering it (developer.wordpress.org). So the filter fires for the parent and for each descendant, with $parent_block set when the block is nested.

This timing is the whole reason the technique works. By the time render_block fires, you have a string of HTML and must reverse-engineer structure from markup. By contrast, render_block_data hands you the parsed block array — blockName, attrs, innerBlocks, innerHTML, innerContent — as documented on the hook reference. The heading-level check becomes a structural test on arrays, not a string search.

The shape of the parsed block

The hook reference lists the keys explicitly: blockName (string or null), attrs (array from block comment delimiters), innerBlocks (an array of arrays with the same structure), innerHTML (string from inside the delimiters), and innerContent (a list of string fragments and null markers where inner blocks were found). A user-contributed note on the same page shows a concrete example of that nesting, including a child block inside innerBlocks with its own innerHTML and innerContent.

Two consequences follow. First, innerBlocks is already parsed when the parent’s render_block_data callback runs. You do not need to call parse_blocks() yourself. Second, innerBlocks contains parsed arrays, not rendered HTML. If you want to know whether a group contains a heading at a given level, you inspect the child’s blockName and attrs, not the child’s output.

For core/heading, the level is stored in the level attribute. The core blocks reference lists level among the Heading block’s attributes (developer.wordpress.org). In parsed data it appears as $child['attrs']['level']. Treat a missing level as “not the target level” unless you have verified your site’s saved content always includes it; the safe default is to require an explicit match.

A minimal callback

The following is a complete, runnable filter. It targets core/group, scans the group’s direct innerBlocks for a core/heading whose level equals a chosen integer, and appends a class to the group’s className attribute. It does not recurse; the limitation is discussed below.

<?php
/**
 * Add a class to core/group when it directly contains a core/heading
 * at a specific level.
 *
 * @param array $parsed_block The parsed block being rendered.
 * @return array Modified parsed block.
 */
function jv_group_class_for_heading_level( $parsed_block ) {
    if ( empty( $parsed_block['blockName'] ) || 'core/group' !== $parsed_block['blockName'] ) {
        return $parsed_block;
    }

    $target_level = 3;
    $class        = 'has-h3-lead';
    $found        = false;

    if ( ! empty( $parsed_block['innerBlocks'] ) && is_array( $parsed_block['innerBlocks'] ) ) {
        foreach ( $parsed_block['innerBlocks'] as $inner ) {
            if ( empty( $inner['blockName'] ) || 'core/heading' !== $inner['blockName'] ) {
                continue;
            }
            if ( isset( $inner['attrs']['level'] ) && (int) $inner['attrs']['level'] === $target_level ) {
                $found = true;
                break;
            }
        }
    }

    if ( ! $found ) {
        return $parsed_block;
    }

    if ( empty( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ) {
        $parsed_block['attrs'] = array();
    }

    $existing = isset( $parsed_block['attrs']['className'] ) ? (string) $parsed_block['attrs']['className'] : '';
    $classes  = preg_split( '/\s+/', trim( $existing ) );
    $classes  = array_filter( $classes );

    if ( ! in_array( $class, $classes, true ) ) {
        $classes[] = $class;
    }

    $parsed_block['attrs']['className'] = implode( ' ', $classes );

    return $parsed_block;
}
add_filter( 'render_block_data', 'jv_group_class_for_heading_level' );

Why className rather than editing innerHTML or innerContent? Because className is a block attribute consumed by normal block rendering. The hook reference’s own user-contributed example sets $parsed_block['attrs']['className'] and returns the modified array, which is the same pattern used here. Mutating innerHTML would bypass the attribute system and risk producing markup that disagrees with what the block’s save function expects.

Why not render_block?

render_block filters the content of a single block and receives $block_content, $block, and $instance (developer.wordpress.org). It is the documented tool for post-render HTML changes. The Block Filters guide notes that render_block “has no impact on the behavior of blocks in the Editor” and demonstrates adding a class to Paragraph blocks with WP_HTML_Tag_Processor rather than regex (developer.wordpress.org).

For this problem, render_block is the wrong layer. You would have to parse the group’s rendered HTML to find a heading and read its tag name. That is possible with the HTML API, but it is more work and more fragile than reading innerBlocks before rendering. Use render_block_data when the decision depends on block structure; use render_block when the decision depends on final markup that only exists after rendering.

Failure modes to test

The minimal callback above is correct for its stated scope. It is not a general solution. Test these cases before trusting it in production.

Groups with no heading

The callback returns the block unchanged. No class is added. This is the intended behavior, but verify it with a group that contains only paragraphs and images.

Headings nested inside inner groups or columns

A top-level-only scan of innerBlocks will miss a heading inside a nested core/group, core/columns, or core/column. The parsed tree is recursive; the loop above is not. If your editorial pattern routinely nests groups, either recurse or accept that the class applies only to groups whose heading is a direct child.

A recursive variant is straightforward: walk innerBlocks depth-first and return true on the first matching heading. The trade-off is that a group containing a nested group with an h3 will receive the class even if its own direct heading is an h2. Decide which semantics you want before writing the recursion.

Headings whose level attribute is absent

If attrs.level is not set, the callback treats the heading as not matching. That is deliberate. Do not assume a default level; confirm against your saved content. A quick WP-CLI query can show whether any core/heading blocks in the database lack a level attribute:

wp db query "SELECT ID, post_title FROM wp_posts WHERE post_content LIKE '%wp:heading%' AND post_content NOT LIKE '%\"level\"%' AND post_status = 'publish' LIMIT 20;"

Adjust the table prefix if your install does not use wp_. If the query returns rows, those posts contain heading blocks without an explicit level, and the callback will not match them.

Dynamic blocks and innerBlocks

The hook reference describes innerBlocks as parsed from the block comment delimiters. For blocks whose content is generated at render time, the parsed array may not contain the inner structure you expect. The evidence available here does not establish a general rule for every dynamic block, so verify on your own install: log $parsed_block inside the callback for a page that uses the block in question and inspect innerBlocks before relying on it.

Debugging the callback

Do not guess the parsed structure. Log it. The following temporary addition writes the parsed block to the PHP error log when a group is encountered:

if ( 'core/group' === $parsed_block['blockName'] ) {
    error_log( print_r( $parsed_block, true ) );
}

Load the page with WP_DEBUG_LOG enabled and read wp-content/debug.log. Confirm three things: that blockName is core/group, that innerBlocks contains the heading you expect, and that the heading’s attrs.level is the integer you are matching. If the log shows the heading at a deeper level than the loop scans, that is the bug, not the filter.

Query Monitor is useful here for a different reason: it shows which hooks fired and in what order, which helps confirm that render_block_data is running on the front end and not only in some other context. It will not show you the parsed array contents; the error log does that.

What this approach does not do

It does not change the editor. The class is added during front-end rendering and is not saved to post content. Contributors will not see it in the block inspector, and the block’s saved markup will not contain it. If you need editor-visible styling, this is the wrong tool; look at editor-side filters or a block style variation instead.

It does not persist. Every page load re-evaluates the condition. That is usually desirable for a rule based on content structure, but it means the class disappears if the heading is removed, which is the point.

It does not replace render_block for cases where the decision depends on rendered HTML. The two hooks solve different problems at different stages.

Verification checklist

After deploying the callback, verify in this order:

  1. Load a page containing a group with a direct h3. Inspect the group wrapper in DevTools. The class should appear alongside the existing wp-block-group class.
  2. Load a page containing a group with a direct h2. The class should not appear.
  3. Load a page containing a group with no heading. The class should not appear.
  4. Load a page containing a group with a nested group that contains an h3. With the non-recursive callback, the outer group should not receive the class. If it does, your recursion or your scan is broader than intended.
  5. Check the error log for PHP notices from the callback. An undefined index warning on attrs or level means a guard is missing.

If step 1 fails, log $parsed_block and compare the actual structure to the assumptions above. The filter is reliable; the assumptions about the tree are where the bugs live.

Why Your Child Theme’s theme.json Overrides settings.spacing.units — and How to Read the Merged Value

The symptom is small and specific. A spacing control in the block editor offers a unit your parent theme’s theme.json never listed, or a unit the parent listed has quietly disappeared. The file on disk says one thing; the editor behaves as if it read another. Nothing in the browser console is red. Nothing in the debug log is complaining. The site is not broken, which is the annoying part.

This is not a bug in the merge. It is the merge working as designed, and the design is worth reading before you edit either file. The useful question is not “did the child theme override my units?” It almost certainly did. The useful question is which origin supplied the value I am reading, because once you know that, the fix is a one-line decision rather than a specificity escalation.

Trace: theme.json is a merge, not a file swap

WordPress does not read a single theme.json. It assembles settings from several origins and merges them in a fixed order. The theme.json specification is shared across those origins: core, themes, and users all use the same format, and themes override core defaults by shipping a theme.json, while users can override theme or core preferences through the site editor (Global Settings & Styles (theme.json)).

For a child theme, the resolver does something slightly more literal than “child wins.” When the active theme has a parent, core reads the parent’s theme.json, then merges the child’s theme.json into it. The source comment is explicit: “Merge the child theme.json into the parent theme.json. The child theme takes precedence over the parent” (WP_Theme_JSON_Resolver). So the child is not a replacement layer sitting beside the parent. It is an incoming layer applied on top.

That distinction matters because the merge rule for spacing.units is not additive. WP_Theme_JSON::merge() uses array_replace_recursive() for the general case, but then explicitly handles sequential arrays: “For leaf values that are sequential arrays it will use the numeric indexes for replacement. We rather replace the existing with the incoming value, if it exists. This is the case of spacing.units” (WP_Theme_JSON::merge). The same method then walks each setting node and, if the incoming data provides spacing.units, sets it wholesale:

// Replace the spacing.units.
$path = $node['path'];
$path[] = 'spacing';
$path[] = 'units';
$content = _wp_array_get( $incoming_data, $path, null );
if ( isset( $content ) ) {
    _wp_array_set( $this->theme_json, $path, $content );
}

So a child theme that declares settings.spacing.units is not appending to the parent’s list. It is replacing it. If the parent lists px, em, rem and the child lists rem only, the merged value is rem only. Reasoning about this as an additive override produces the wrong fix — usually adding the missing units in both files, which then produces a second, quieter disagreement later.

For reference, the documented default for settings.spacing.units is ["px","em","rem","vh","vw","%"], described as “List of units the user can use for spacing values” (Theme.json Version 3 Reference). If neither theme declares units, that default is what you are seeing. If one of them does, the incoming list wins for that node.

Inspect: read the merged value, not the file

The supported inspection tool is wp_get_global_settings(). Its own description is the whole argument: “Gets the settings resulting of merging core, theme, and user data” (wp_get_global_settings()). It takes a $path array and a $context array. The context accepts a block_name and an origin; the origin is either 'all' (core, theme, and user) or 'base' (core and theme), and defaults to 'all' when empty or unknown.

Start by dumping everything, then narrow. The function’s own user-contributed note recommends exactly this: call it with no path, var_dump the result, locate the setting, then convert the nested keys into a flat path array.

$settings = wp_get_global_settings();
var_dump( $settings['spacing']['units'] );

Then query the specific path directly:

$units = wp_get_global_settings( array( 'spacing', 'units' ) );
var_dump( $units );

That tells you what the merged value is. It does not tell you who supplied it. For that, query the same path twice with different origins:

// Core + theme only.
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);

// Core + theme + user.
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

var_dump( $base, $all );

If $base and $all differ, a saved user layer is involved and editing either theme.json will not change what the editor offers. If they match, the value came from core plus the theme stack, and the child theme is the likely supplier. This two-query check is cheap and it prevents the most common wasted afternoon: rewriting a child theme.json while a saved global styles record quietly wins.

If you want to see the raw merged data rather than the settings slice, WP_Theme_JSON_Resolver::get_merged_data() is what wp_get_global_settings() calls internally. But the resolver class is documented as internal core API: “This class is for internal core usage and is not supposed to be used by extenders (plugins and/or themes). This is a low-level API that may need to do breaking changes. Please, use get_global_settings(), get_global_styles(), and get_global_stylesheet() instead” (WP_Theme_JSON_Resolver). Read it, do not ship a dependency on it.

Root cause: the cache is part of the merge story

There is a second reason a merged value can look wrong after a file edit, and it is not merge logic. wp_get_global_settings() caches its result in the theme_json cache group, and the source marks that group as non-persistent across requests. The cache key is built from the origin, and the function skips the cache entirely when development mode is set to theme:

$can_use_cached = ! wp_is_development_mode( 'theme' );

That line is in the function source on the reference page. The practical consequence: if you edit theme.json and re-read the merged value in the same request lifecycle without development mode on, you may be reading a cached array. The cache is non-persistent, so a fresh request usually clears it, but “usually” is not a diagnostic. If a value looks stale, confirm development mode before you conclude the merge is wrong.

There is also a filter layer above the file. The resolver applies wp_theme_json_data_default to core data and wp_theme_json_data_theme to theme data before merging (visible in the resolver source). A plugin that hooks either filter can change spacing.units without touching any file on disk. The sources establish that these filters exist; they do not establish that a filter is your cause. Treat it as a layer to rule out, not a default suspect.

Fix: decide which layer owns the list

Because spacing.units is replaced wholesale, the fix is an ownership decision, not a merge trick. Pick one layer to own the list and remove the declaration from the other.

  • Parent owns the units. Remove settings.spacing.units from the child theme.json. The parent’s list survives the merge because the child no longer supplies an incoming value for that node.
  • Child owns the units. Keep the list in the child and remove it from the parent, or accept that the parent’s list is documentation only. Do not maintain two lists; the second one is a future bug report.
  • Neither should own it. Remove the declaration from both and let the documented default apply.

What you should not do is add the missing units to both files and assume the union is what the editor sees. It is not. The incoming list replaces the existing one at that node, so the union exists only in your head.

If the two-query check showed that $base and $all differ, the fix is not in either theme.json. A saved user layer is supplying the value, and the correct action is to decide whether that saved customization should be kept, revised, or removed through the site editor’s styles workflow. That is an editorial decision about the site’s design, not a code change, and it should be made by whoever owns the design intent.

Verification: re-read after each change

After editing either file, re-run the same two queries. The verification is not “the editor looks right” — it is that the merged value at the path you care about changed to the value you intended, and that the base and all reads agree (or that the difference is the saved layer you deliberately kept).

// After the edit, in a request where development mode is 'theme'
// or after a fresh request:
$base = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'base' )
);
$all = wp_get_global_settings(
    array( 'spacing', 'units' ),
    array( 'origin' => 'all' )
);

// Expected: the list you decided on, and no unexplained divergence.
var_dump( $base, $all );

If the value is still wrong after a confirmed fresh read, the next layer to check is a filter on wp_theme_json_data_theme or wp_theme_json_data_default. Disable plugins that touch global styles one at a time and re-read. Do not escalate to !important in CSS; the unit selector is a settings-layer question, and CSS specificity has no opinion about it.

Why this failure mode is worth understanding

Most theme.json confusion comes from treating the file as a single source of truth. It is not. It is one input to a deterministic merge across origins, and the merge has explicit rules for which kinds of values are replaced and which are combined. spacing.units is in the replaced category, and the child theme is a layer applied on top of the parent, not a sibling. Once you read the merged value with wp_get_global_settings() and separate base from all, the question stops being “why is WordPress ignoring my file” and becomes “which layer did I actually ask to own this list.” That is a question with an answer.

FAQ

Does a child theme’s theme.json replace the parent’s entire file?

No. Core reads the parent theme.json and merges the child’s into it, with the child taking precedence. The replacement behavior is per-node: for sequential arrays like spacing.units, the incoming list replaces the existing one at that node rather than being concatenated.

Why does the editor offer a unit my parent theme.json does not list?

Either the child theme supplies a different spacing.units list, or a saved user layer supplies one, or neither theme declares units and the documented default applies. Query wp_get_global_settings( array( 'spacing', 'units' ) ) with origin 'base' and 'all' to tell those cases apart.

Can I just add the missing units to both files?

You can, but the merged value will still be whichever list arrives last at that node, not the union. Pick one layer to own the list and remove the declaration from the other.

Why did my edit not change the merged value?

Check two things before suspecting the merge: whether development mode is set to theme (which bypasses the theme_json cache), and whether a plugin is filtering wp_theme_json_data_theme or wp_theme_json_data_default. Both can produce a value that does not match the file you just saved.

Should I call WP_Theme_JSON_Resolver directly to debug?

Read it if you need to understand the merge, but do not ship code that depends on it. The class is documented as internal core API subject to breaking changes, with get_global_settings(), get_global_styles(), and get_global_stylesheet() named as the supported alternatives.

Why wp_options Grows Unbounded When update_option() Runs in a Loop — and How to Find the Autoloaded Rows Slowing Your Admin

Slow admin pages rarely announce themselves as an options problem. They arrive as a two-second delay on wp-admin/edit.php, a dashboard that hangs before the widgets paint, and a wp_options table that has quietly become the largest thing in the database. The usual suspect is a plugin calling update_option() inside a loop. That phrase describes two different failures, and they need different diagnostics.

Symptom

You have a self-maintained install. Admin pages are slow, the front end is tolerable, and wp_options is bloated. Someone on the team says “the plugin calls update_option() in a loop.” That is not yet a diagnosis. It is a description of a code shape that can produce two distinct outcomes:

  • Mode A — row-count growth. The loop iterates over distinct option names. Each new name becomes a new row, because update_option() creates the option if it does not exist. The table grows in width, not depth.
  • Mode B — payload growth. The loop iterates over one option name, writing an accumulating value. Row count stays flat. The stored value grows, and if that option is autoloaded, every request pays for it.

Only mode B, combined with autoload, directly inflates the payload that wp_load_alloptions() pulls on every request. Mode A can still matter — thousands of rows are not free — but row count alone is a misleading metric. A table with thousands of non-autoloaded rows can be harmless to page load, while a single large autoloaded row can dominate request cost. Triage should measure autoloaded bytes first, then row count.

Trace

Start with what core actually does. The update_option() reference states plainly: “If the option does not exist, it will be created.” That is the mechanism behind mode A. A loop over unique keys — say, one option per post, per user, or per cron event — will insert a row for every key it has not seen before. The function is not misbehaving; it is doing what its documentation says.

Mode B is subtler. The same option name is written repeatedly. The row count does not move. The option_value column grows. If the option was created with autoload enabled, or if the autoload heuristic decided to enable it, that growing value is part of the alloptions payload.

Here is the core path that matters. wp_load_alloptions() is documented as loading and caching “all autoloaded options, if available or all options.” Its source queries the options table filtering on the set of autoload values returned by wp_autoload_values_to_autoload():

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

Note the fallback in the same function: if that query returns nothing, core selects all options. A diagnostic that assumes wp_load_alloptions() output is strictly “autoloaded only” can misattribute non-autoloaded rows. Keep that in mind before you blame a row you found through that function.

Why does this matter per request? The WordPress optimization guide is direct: “your site’s options data needs to be available for each page view. Without a persistent object cache, your web server must read those options from the database to handle every page view.” It goes on to say those extra trips “slow down your web server’s response times such as ‘Time to first byte’ (TTFB) and can quickly overwhelm your database server during traffic spikes.” The autoload payload is not a background concern; it is on the critical path of every admin page load.

The update_option() reference also carries the warning that matters for prevention: “Autoloading too many options can lead to performance problems, especially if the options are not frequently used.” And it gives the rule: options accessed across several places in the frontend should autoload; options accessed only on a few specific URLs should not. The add_option() reference repeats the same warning and the same recommendation to pass false for options read on few specific URLs.

Root cause

The root cause is not “a loop.” It is a loop whose key strategy and value strategy were never checked against the autoload contract.

Mode A root cause: the loop uses variable option names. Each iteration writes a new key. update_option() creates it. The table accumulates rows that may never be read again, and if the autoload heuristic or an explicit true marked them autoloaded, they join the alloptions payload permanently.

Mode B root cause: the loop uses a constant option name and an accumulating value — appending to an array, concatenating a log, storing a growing serialized structure. The row count is stable, which is why this mode hides from table-size checks. The payload grows without bound, and if the option is autoloaded, the cost is paid on every request.

There is a third trap in remediation. The update_option() reference states: “For existing options, $autoload can only be updated using update_option() if $value is also changed.” A cleanup script that calls update_option( $name, $same_value, false ) will silently fail to flip autoload off, because the value did not change. The autoload column stays as it was. You will believe you fixed it and the admin will still be slow.

Fix

Work in this order. Measure autoloaded bytes first, rank the individual options second, verify against the database third, and only then look at row count.

Step 1 — total autoloaded bytes

Since WordPress 6.6.0, WP_Site_Health::get_autoloaded_options_size() calculates the total amount of autoloaded data and returns it in bytes. Its source sums the string length of each autoloaded value, serializing arrays and objects first:

$total_length += strlen( (string) $option_value );

Run it from WP-CLI:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

This gives you a number to compare against after remediation. It does not tell you which option is responsible. That is the next step.

Step 2 — rank autoloaded options by value length

wp_load_alloptions() is the right enumeration primitive because it mirrors what core loads. Sort the result by serialized length and print the top offenders:

wp eval '
$all = wp_load_alloptions();
$rows = array();
foreach ( $all as $name => $value ) {
    $rows[ $name ] = strlen( (string) maybe_serialize( $value ) );
}
arsort( $rows );
$i = 0;
foreach ( $rows as $name => $len ) {
    printf( "%10d  %s\n", $len, $name );
    if ( ++$i >= 25 ) { break; }
}
'

If a single option dominates the list, you are looking at mode B. If the list is long and flat, you are looking at mode A with autoload enabled across many small keys.

Step 3 — verify against the database

The alloptions cache can be stale, and the fallback path in wp_load_alloptions() can return non-autoloaded rows. Confirm with direct SQL filtered on the autoload values core treats as autoloaded. The $wpdb class documentation notes that $wpdb can read from any table in the WordPress database, not just core tables, so this is a supported diagnostic path:

SELECT option_name,
       LENGTH(option_value) AS bytes,
       autoload
FROM wp_options
WHERE autoload IN ( 'yes', 'on', 'auto-on', 'auto' )
ORDER BY bytes DESC
LIMIT 25;

Adjust the autoload value list to match what wp_autoload_values_to_autoload() returns on your version. The point is to query the table directly rather than trusting a cached array. If you want to run it through WordPress, wpdb::query() returns the number of rows affected or selected for non-DDL queries and false on error, so you can check the result before iterating.

Step 4 — row count, last

Only after the autoload picture is clear should you look at row count:

SELECT COUNT(*) AS total_rows,
       SUM(autoload IN ( 'yes', 'on', 'auto-on', 'auto' )) AS autoloaded_rows
FROM wp_options;

A large total with a small autoloaded count is a different problem from a small total with a large autoloaded count. Treat them separately.

Step 5 — flip autoload correctly

Because update_option() only changes the autoload flag when the value also changes, a remediation that passes the same value will not work. Two safe options:

  • Pass a genuinely changed value along with false, then verify the autoload column afterward.
  • Use wp_set_option_autoload_values() where it fits your version and workflow, and verify the column afterward either way.

Do not assume the flag flipped. Query it:

SELECT option_name, autoload FROM wp_options WHERE option_name = 'your_option_name';

If the row still shows an autoload value in the autoloaded set, the change did not take.

Step 6 — fix the code, not just the data

Cleaning the table without fixing the loop means the table refills. The code review question is simple: in any loop that writes options, is the key constant or variable?

  • Variable key: you are in mode A. Ask whether each key needs to be a separate option at all. Often a single option holding an array, or a custom table, is the correct shape.
  • Constant key with growing value: you are in mode B. Ask whether the value needs to grow without bound. Logs, histories, and append-only structures belong in a custom table, not in an autoloaded option.
  • Autoload argument: make it explicit. The documentation recommends true for options read across several places and false for options read only on specific URLs. Leaving it to the heuristic is how options you never intended to autoload end up in the alloptions payload.

Verification

Re-run the measurement from step 1 and compare. Then confirm the specific rows changed:

wp eval '$health = WP_Site_Health::get_instance(); echo $health->get_autoloaded_options_size() . " bytes\n";'

And confirm the autoload column for the options you touched:

SELECT option_name, autoload, LENGTH(option_value) AS bytes
FROM wp_options
WHERE option_name IN ( 'option_a', 'option_b' );

If the byte total dropped and the autoload column shows a non-autoloaded value, the fix took. If the byte total is unchanged, the most likely cause is the update_option() autoload caveat: the value did not change, so the flag did not change either.

For a related failure mode on fresh installs — where the symptom is “Nothing Found” rather than slow admin — see What to Fix First When a New WordPress Site Says Nothing Found. It is a different trace, but the same discipline applies: read the core path before installing around it.

FAQ

Does update_option() in a loop always bloat wp_options?

No. The documentation does not say the loop itself is a bug. The outcome depends on key strategy and value growth. A loop over a constant key with a bounded value is fine. A loop over variable keys grows row count. A loop over a constant key with an unbounded value grows payload. Only the payload case, combined with autoload, directly inflates the alloptions load.

Why did my cleanup script not turn autoload off?

Because update_option() only updates the autoload flag for an existing option when the value also changes. If you passed the same value with false, the function returned without touching the autoload column. Verify with a direct SELECT on the autoload column.

Is wp_load_alloptions() output always autoloaded options only?

Not guaranteed. The function is documented as loading autoloaded options “if available or all options,” and its source falls back to selecting all options when the autoload-filtered query returns nothing. Treat its output as a diagnostic aid, not as an authoritative autoload manifest, and confirm against the database.

What is the right order to triage this?

Total autoloaded bytes first, per-option length ranking second, direct SQL against the options table filtered by autoload values third, row count last. Row count alone will mislead you in both directions.

Where do I find the autoload values core treats as autoloaded?

They are returned by wp_autoload_values_to_autoload(), which wp_load_alloptions() uses in its query. Read that function on your installed version rather than hardcoding a list from a tutorial; the accepted values have changed across releases, and the update_option() changelog notes that 'yes' and 'no' were deprecated in 6.7.0.

The ‘There Is Motion At Your Front Door’ Shirt Is a Notification, Not a Promise

The shirt shows a white rounded notification banner with a bell. Inside the banner, in plain sans-serif, it reads: There is motion at your front door. Below the banner sits a circular, green-tinted view — the kind of low-light, slightly fish-eyed frame a doorbell camera produces — of a wide-eyed orange tabby pressed close to the lens, with porch siding and lights visible behind it.

That is the whole design. No slogan, no punchline caption, no brand mark. Just the alert and the frame that triggered it. The joke is not that a cat is at the door. The joke is that the notification is technically correct, and that correctness is the entire problem.

I keep coming back to this shirt because it is a rare piece of visual writing about a specific failure mode: the message that is accurate and useless at the same time. The banner does not say a cat is at your door. It says there is motion. Motion is what the sensor detects. The cat is what the human sees. The gap between those two statements is where the humor lives, and it is also where a lot of operational writing goes wrong.

What the artwork actually shows

Look at the composition in order. The banner is on top, white and rounded, with a small bell icon at the left. The text is sentence case, not all caps, not stylized. It reads like a system string, not a greeting. Below it, the circular frame is tinted green — the infrared or night-vision cast that cheap doorbell cameras produce — and the cat’s face fills most of the circle. The eyes are wide. The porch siding runs vertically behind the animal, and lights are visible in the background.

The design is doing something deliberate with hierarchy. The notification comes first because that is the order of experience: the phone buzzes, you read the alert, then you open the camera feed. The shirt reproduces that sequence as a static image. The banner is the promise; the circle is the delivery. And the delivery is a cat staring directly into a lens at close range, which is not what the banner led you to expect.

I am reading the green tint as a doorbell-camera convention, not as a claim about the cat’s actual color. The cat is orange. The tint is the camera’s. That distinction matters if you are the kind of person who reads interface copy for a living, because it is the same distinction between what a system reports and what is actually there.

The banner is a contract, and the circle is the breach

Every notification is a small contract. It says: here is a fact you asked to know. The fact is supposed to be sufficient. When the banner says there is motion at your front door, it is fulfilling the contract at the lowest possible resolution. Motion is true. Motion is also what a leaf produces, what a passing car produces, what a change in sunlight produces. The banner is not lying. It is under-specifying.

The circle below is the correction. It shows you what the motion was, and the answer is a cat with its face against the glass. The humor is not that the system failed. The humor is that the system succeeded at exactly the level it was designed for, and that level was never going to be enough.

This is the same failure mode I see in monitoring dashboards and log lines. A check returns status: degraded. The alert fires. Someone opens the dashboard and finds that the degradation is a single slow query on a table nobody reads. The alert was correct. The alert was also not the information anyone needed. The gap between the banner and the circle is the gap between a threshold and a diagnosis.

The shirt is funny because it compresses that gap into two visual elements and lets you feel the mismatch in under a second. It does not explain the joke. It does not caption the cat. It trusts you to read the banner, read the circle, and notice that the second thing is not what the first thing promised.

Why the cat has to be close to the lens

The framing is not incidental. A cat at the far edge of a porch would be a different shirt. A cat sitting politely on a mat would be a different shirt. This cat is pressed against the camera, eyes wide, filling the circle. The proximity is what makes the notification feel absurd. The sensor reported motion; the motion turned out to be a face.

There is a long tradition of doorbell-camera footage as comedy, and I am not going to pretend I can trace where this specific phrasing started. What I can say is that the design uses the visual grammar of that genre — the circular frame, the green cast, the porch background — without needing to cite it. The grammar is legible on its own. You do not need to have seen a particular video to understand that this is a camera view and that the camera view is the punchline.

The wide eyes matter too. A sleeping cat would read as a false alarm. A hissing cat would read as a threat. Wide eyes read as surprise, and surprise is the emotion the notification itself is guilty of. The banner is surprised by motion. The cat is surprised by the lens. They are both reacting to the same event from opposite ends of the same wire.

The wording is a system string, not a slogan

There is motion at your front door is not a sentence a human writes to another human. It is a sentence a device writes. The passive construction, the vague noun motion, the prepositional phrase locating it — this is the register of firmware. That register is what makes the shirt work as a piece of writing. If the banner said Your cat is at the door again, the joke would collapse into sentiment. The system string keeps it cold. The coldness is the setup; the cat is the release.

I have written a lot of interface copy in my life, and the hardest part is always deciding how much to say. Say too little and the user has to open the camera. Say too much and you have made a promise you cannot keep. The banner on this shirt is the minimal viable notification. It is technically complete. It is practically useless. The circle is what happens when you click through.

That is the argument I would make for the shirt as an object: it is a diagram of the difference between a signal and a message. A signal says something happened. A message says here is what happened and what to do about it. The banner is a signal. The cat is the message. The shirt puts them in the same frame and lets the gap do the work.

Who this is for, and what it is not

If you spend your days reading alerts, dashboards, and log lines, the shirt is a small, wearable version of a problem you already know. It does not require you to explain it. The banner and the cat are enough. If you do not spend your days that way, the shirt is still legible as a joke about a cat setting off a doorbell camera, which is a perfectly good joke on its own.

What the shirt is not: it is not a statement about security, it is not a comment on surveillance, and it is not a claim that doorbell cameras are good or bad. It is a visual joke about a notification that is correct and insufficient. I am not going to load it with more than that. The design does not ask for it.

The shirt is available as a standard t-shirt through the Amazon listing. The listing title is There Is Motion At Your Front Door Funny Cat Doorbell Meme, and the brand is Quirky Cotton Collective. I have not worn it, washed it, or tested it. I have looked at the artwork, and the artwork is the thing worth writing about.

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

The takeaway

The next time you write a notification, a status line, or an alert, look at what you have written and ask whether it is a banner or a circle. A banner says motion. A circle shows the cat. Both are true. Only one of them is useful. The shirt is funny because it shows you the moment before you click through, when all you have is the banner and the vague sense that something is at the door.

That is a small, specific joke, and it is executed cleanly. The banner is white and rounded. The bell is there. The text is plain. The cat is close. The porch is behind it. Nothing is explained. Nothing needs to be.