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).