Lock Block Patterns So Clients Can’t Break Them (templateLock, Block Lock, Server-Side Guards)

Every client project eventually sees someone accidentally delete the hero section, drag the footer into a column, or nest the newsletter form three levels deep by holding the wrong key. WordPress 2026 ships three mechanisms to prevent that. This snippet covers all three — templateLock on group blocks, per-block lock attributes, and a server-side safety net that rejects saves that violate the lock. Ship patterns your client can edit without the parts they can’t.

I handed off a fresh Ollie-theme site to a client in 2024. Two days later: “the site looks weird”. Their office manager had moused over the header pattern, hit ‘Ctrl+A’ thinking she was selecting text, and deleted the entire thing. No backup of the draft. Two hours rebuilding from Git. That’s when I started locking every client-handoff pattern. These are the exact approaches I use now — three layers, each preventing a different accident, none relying on the client’s discipline. The lock state survives editor reloads, REST API saves, and block paste operations. It doesn’t survive an admin with manage_options determined to unlock, but that’s the correct boundary.

What the three locks do

  • templateLock on group blocks — the outer Group or Cover block has a templateLock attribute that prevents its children from being moved, removed, or added to. Three values: all (fully locked), insert (can rearrange but not add/remove), contentOnly (can edit text but not move or delete)
  • Per-block lock attribute — individual blocks inside a pattern get a lock: { move: true, remove: true } attribute. Reorder/delete is greyed out in the toolbar. The block still accepts content edits (text, colours, spacing) unless you also set contentOnly: true
  • Server-side save validation — the save_post hook inspects the incoming block tree and reverts to the previous revision if a locked block was removed. Last line of defence against paste-based bypass
  • Pattern-level locking — patterns registered with inserter: true, blockTypes: [...] plus pre-locked content produce a pattern that’s locked by default when inserted
  • Admin bypass — users with manage_options (admins) can unlock via the block menu → Unlock. Lower roles see the lock UI but not the unlock button, so editors and below can’t override
  • Full-site-editing compatible — templateLock works on template parts (header, footer, 404) so your FSE templates get the same protection as patterns
  • Zero client-side JS — all three locks are declarative block attributes that WordPress handles natively, no React code to maintain
  • Visible lock indicator — locked blocks show a subtle padlock icon in the toolbar so clients can tell which parts they’re not supposed to move

Three approaches — pick what fits

If you’re building patterns in the Block Editor UI and saving via Site Editor → Patterns, use the UI’s “Lock” option on each block. If you’re defining patterns in PHP (the approach most devs use for real client work), embed the lock attributes directly in the block markup. If you want belt-and-braces protection against REST API or paste-based bypasses, add the server-side hook.

<?php
/**
 * Plugin Name: GT Locked Patterns + Server Guard
 * Description: Template-locked group patterns plus server-side save validation for content-locked blocks.
 */
defined( 'ABSPATH' ) || exit;

/* ============================================================
 * 1. Register a fully-locked hero pattern
 *    templateLock="all" on the outer group prevents add/move/remove of children
 *    contentOnly on inner blocks lets clients edit text without breaking structure
 * ============================================================ */
add_action( 'init', function () {
    if ( ! function_exists( 'register_block_pattern' ) ) return;

    register_block_pattern( 'gt/locked-hero', [
        'title'       => __( 'Hero — Locked Layout (Edit Text Only)' ),
        'description' => __( 'Hero layout where clients can edit the text but cannot move, delete, or add blocks.' ),
        'categories'  => [ 'gt-brand' ],
        'content'     => '<!-- wp:group {
            "align":"full",
            "templateLock":"all",
            "layout":{"type":"constrained"}
        } -->
<div class="wp-block-group alignfull" style="padding:5rem 1rem;text-align:center">
  <!-- wp:heading {
    "level":1,
    "lock":{"move":true,"remove":true}
  } -->
  <h1>Edit this headline, but you can\'t move or delete it</h1>
  <!-- /wp:heading -->

  <!-- wp:paragraph {
    "lock":{"move":true,"remove":true}
  } -->
  <p>This paragraph is locked in place. Clients can rewrite the copy, not the structure.</p>
  <!-- /wp:paragraph -->

  <!-- wp:buttons {
    "lock":{"move":true,"remove":true}
  } -->
  <div class="wp-block-buttons">
    <!-- wp:button {"lock":{"move":true,"remove":true}} -->
    <div class="wp-block-button"><a class="wp-block-button__link">Primary CTA</a></div>
    <!-- /wp:button -->
  </div>
  <!-- /wp:buttons -->
</div>
<!-- /wp:group -->',
    ] );
} );

/* ============================================================
 * 2. contentOnly variant — even tighter: clients can edit TEXT
 *    but colours, alignment, typography are also locked
 * ============================================================ */
add_action( 'init', function () {
    register_block_pattern( 'gt/locked-hero-text-only', [
        'title'       => __( 'Hero — Text-Only Editing' ),
        'description' => __( 'Clients can only edit the text content, nothing else.' ),
        'categories'  => [ 'gt-brand' ],
        'content'     => '<!-- wp:group {
            "align":"full",
            "templateLock":"contentOnly"
        } -->
<div class="wp-block-group alignfull">
  <!-- wp:heading {"level":1} --><h1>Edit only this text</h1><!-- /wp:heading -->
  <!-- wp:paragraph --><p>Colours, alignment, fonts all locked. Only the text is editable.</p><!-- /wp:paragraph -->
</div>
<!-- /wp:group -->',
    ] );
} );

/* ============================================================
 * 3. Server-side guard — reject saves that violate the lock
 *    Catches REST API bypasses and rogue block paste operations
 * ============================================================ */
add_action( 'save_post', function ( $post_id, $post ) {
    if ( wp_is_post_revision( $post_id ) ) return;
    if ( current_user_can( 'manage_options' ) ) return; /* admin bypass */
    if ( ! has_blocks( $post->post_content ) ) return;

    $blocks = parse_blocks( $post->post_content );
    foreach ( $blocks as $b ) {
        if ( empty( $b['attrs']['templateLock'] ) ) continue;
        $lock = $b['attrs']['templateLock'];
        if ( $lock === 'all' || $lock === 'contentOnly' ) {
            $expected_count = gt_count_inner( $b );
            $revision = wp_get_post_revisions( $post_id, [ 'numberposts' => 1 ] );
            if ( $revision ) {
                $prev = array_shift( $revision );
                $prev_blocks = parse_blocks( $prev->post_content );
                foreach ( $prev_blocks as $pb ) {
                    if ( ( $pb['blockName'] ?? '' ) === $b['blockName'] && ! empty( $pb['attrs']['templateLock'] ) ) {
                        $prev_count = gt_count_inner( $pb );
                        if ( $prev_count !== $expected_count ) {
                            /* Structure changed on a locked group — revert */
                            wp_update_post( [
                                'ID'           => $post_id,
                                'post_content' => $prev->post_content,
                            ], true );
                            wp_die( __( 'Structure of a locked section cannot be modified. Revision restored.' ) );
                        }
                    }
                }
            }
        }
    }
}, 99, 2 );

function gt_count_inner( $block ) {
    if ( empty( $block['innerBlocks'] ) ) return 0;
    $n = count( $block['innerBlocks'] );
    foreach ( $block['innerBlocks'] as $child ) $n += gt_count_inner( $child );
    return $n;
}

How the three lock layers fight each other to protect the pattern

templateLock is evaluated by the block editor when rendering the toolbar and drop zones. Set templateLock: "all" on a Group and the editor hides the + insertion targets inside it, disables drag handles on children, and removes the delete option. contentOnly goes further: the block inspector sidebar hides colour, typography, and spacing panels; clients can only click into the text and change it. Both are client-side declarations — the editor respects them, but a determined user with the Code Editor view open can edit the raw block markup and strip the lock attribute. That’s where the per-block lock attribute helps: even if the outer group’s templateLock is bypassed, each inner block with its own lock: { move, remove } still disables its own delete/move UI. Two layers of client-side enforcement. The server-side save guard is the last line. When the post is saved, we parse the incoming blocks, walk the tree, compare the structure of any template-locked Group against the same block in the previous revision. If the child count has changed, we reject the save and restore the prior revision. This catches the edge cases: REST API clients (“smart” integrations, AI agents, block-managing plugins) that post malformed content, block paste operations where the user has copied unlocked markup over locked markup, and manual wp_update_post calls from rogue plugins. Admins with manage_options bypass the server check because they legitimately need to edit locked patterns when updating them. Editors and below are guarded. This three-layer approach has held up on every client site I’ve shipped since 2024 — three years of no accidental pattern destruction.

Download and source

FAQs

What’s the difference between templateLock=all and templateLock=contentOnly?

all locks the structure (no add/move/remove) but allows style/content changes within each block. contentOnly locks the structure AND styling — only the text/media content is editable. Use contentOnly when brand consistency matters (you don’t want clients changing colours). Use all when you want structure fixed but clients can still make styling choices (more common).

What’s the difference between templateLock on the parent and lock on each child?

templateLock on a Group prevents adding/removing children of that group. lock on a child block disables move/remove for that specific block but doesn’t affect its siblings. Combine both for full protection: parent templateLock prevents structure changes, child locks prevent the case where the parent’s templateLock gets stripped.

Can clients (editors) unlock these themselves?

By default, only users with manage_options (admins) see the Unlock option in the block menu. Editors and below see the lock icon but no unlock affordance. If your client team are admins, they can unlock — in which case the server-side guard is your fallback. For multi-admin sites, consider demoting some to Editor role; most “admin” use cases don’t actually need full admin capability.

Does this work with ACF Blocks or custom plugin blocks?

Yes. The lock attribute is a core WordPress block attribute; any block that uses standard block registration inherits support. ACF blocks (v3+), GenerateBlocks, Kadence, Stackable — all honour lock on their own block tags. Third-party blocks from obscure plugins occasionally don’t; test per-plugin if that’s a concern.

Will locked blocks be visible to my client in the sidebar outline?

Yes — they appear in the list view with a small padlock icon. Useful affordance: clients see what exists without being able to change the structure. The List View is the best UX for editing content inside templateLocked sections.

What about deeply nested locks?

Nested group blocks with templateLock cascade correctly — a templateLocked Group inside another templateLocked Group honours both locks. The inner lock is redundant in most cases but doesn’t hurt. For very deep nesting (3+ levels), expect the editor UX to feel restrictive — consider whether you really need locks that deep or whether the pattern should be simpler.

How do I update a locked pattern after shipping?

Two paths. (a) As an admin, unlock the block in the editor (toolbar → Unlock), make changes, re-lock. The unlock is local to that one instance, so if clients have inserted the pattern on 10 pages, you’d need to update each. (b) Edit the pattern definition in your mu-plugin, which updates the source pattern — but existing instances on pages keep the old content until someone re-inserts. Pattern updates are one of the genuine weaknesses of the pattern system.

Can I lock the pattern at insert time so it’s always locked when clients use it?

The lock attributes are part of the pattern’s source content — if you register a pattern with lock attributes on every block, every insertion creates a locked instance. That’s the approach shown above. There’s no “lock this pattern globally and never unlock” flag because patterns are inert content, not live references.

What about synced patterns (the old reusable blocks)?

Synced patterns are a single shared post that multiple pages reference. Locking the shared post locks the content, but since synced patterns render as one block at the page level, templateLock on internal blocks is essentially bypassed by the sync mechanism. For synced patterns, use the pattern’s own sync UI rather than templateLock.

Is the server-side guard reliable?

It works for the common cases but isn’t bulletproof. Example failure mode: the guard compares child counts between revisions, but if a block was re-styled rather than added/removed, the count stays the same and the change goes through. A more rigorous guard would hash the full block tree structure. For most client-protection use cases, the child-count check catches the damaging cases (deleted sections, added rogue blocks) without false-positive friction.