Create Block Theme and Version Control

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

The Site Editor never writes to a block theme’s files; it stores every save in the database. A theme developer wants the result in the theme, under version control, reviewed and deployed like any other code. This page makes that round trip for the Lumen Press theme lumen with the Create Block Theme plugin, with the core export where the plugin is not an option, and as a repeatable team workflow.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, theme.json version 3, WP-CLI 2.12.0 and Create Block Theme 2.10.2 (released 2026-10-01; it requires WordPress 6.8 and PHP 7.4 and declares "Tested up to: 6.9", but every call below was run against 7.1.3), and checked on 2026-10-28. The Gutenberg plugin (24.2.0) is not needed. WordPress 7.2 is in beta and due in December 2026; nothing announced for it changes the storage model described here.

The site owner’s side (Styles, templates, fonts, revisions, storage and the Tools > Export zip) is covered in Site Editor and Global Styles.

Why Site Editor changes have to be moved

Each Site Editor save creates or updates a post: a wp_global_styles post holds the user style layer, wp_template and wp_template_part posts hold customized templates and parts, wp_navigation holds menus and wp_block holds synced patterns. At render time the database copy wins over the theme file of the same slug. So nothing designed in the Site Editor is in Git (it cannot be reviewed, rolled back or deployed except by copying the database), a later edit to templates/home.html seems to do nothing on a site where home was customized, and theme updates do not reach customized templates.

The fix is always the same: write the database state into the theme files, commit them, then delete the database copies so the files are served again.

The Create Block Theme plugin

Create Block Theme is maintained by WordPress.org contributors in the WordPress/create-block-theme repository and distributed in the Plugin Directory. Its own description calls it a development mode: it rewrites theme files on the server, so it belongs on a local or staging site with the theme checked out from Git, never on production.

After activation it adds a wrench icon next to the Save button of the Site Editor, which opens the plugin sidebar, and an Appearance > Create Block Theme landing page (visible to users with edit_themes). Tutorials written for the 1.x releases show form-based admin screens, including a separate font manager; in 2.x the actions live in the Site Editor sidebar and the plugin’s readme calls the old admin page deprecated.

What it can do

Sidebar action Effect

Save Changes to Theme

Writes the user style layer into theme.json, customized templates and parts into templates/ and parts/, synced patterns into patterns/, and activated fonts into assets/fonts/, then deletes the database copies it wrote.

Edit Theme Metadata

Rewrites the style.css header and readme.txt (name, description, URIs, version, tags, "Requires at least", credits, recommended plugins, screenshot).

Edit Theme Settings

Since 2.10.0: edits settings, customTemplates and templateParts of the theme’s theme.json directly.

Create Theme > Clone Theme

Copies the active theme, with the current customizations applied, to a new theme folder with a new name and slug.

Create Theme > Create Child Theme

Creates a child of the active theme containing the current user styles, and activates it.

Create Blank Theme

Creates a new minimal block theme (header and footer parts, an index template, system fonts).

Create Theme Variation

Writes the current user style layer as styles/<slug>.json in the active theme.

Export Zip

Downloads the active theme as a zip with the customizations, images and activated fonts included.

Inspect Theme JSON

Shows the theme’s theme.json read-only.

Reset Theme

Deletes user styles, customized templates and customized template parts, each selectable.

Editor preference (2.10.0)

Opt-in expanded typography controls in the block inspector while authoring a theme.

The Theme Handbook’s Quick-Start Guide uses this plugin to create and export a first theme; the Developer Blog’s 2023 walkthrough shows the same actions in an older release (1.13).

Save Changes to Theme options

The save dialog lists checkboxes; each maps to one request parameter of the save endpoint. The defaults are the sidebar’s defaults in 2.10.2.

Option (parameter) Default What it does

Save Fonts (saveFonts)

on

Copies fonts activated in the Font Library into the theme and declares them in theme.json, deactivating them in the user layer; fonts installed in the theme but deactivated in the Font Library are removed from the theme.

Save Style Changes (saveStyle)

on

Merges the user layer into theme.json (into the child’s file when a child theme is active) and empties the wp_global_styles post.

Save Template Changes (saveTemplates)

on

Writes templates and parts, then deletes every customized wp_template and wp_template_part post of the theme.

Process Only Modified Templates (processOnlySavedTemplates)

on

Rewrites only templates that have a database copy. Off, every template of the theme is reprocessed (a large diff the first time).

Save Patterns (savePatterns)

on

Writes every synced pattern (wp_block post) to patterns/<name>.php, replaces references to it and deletes the post. It stops with an error when a pattern file of that name already exists.

Localize Text (localizeText)

off

Wraps visible text in templates and patterns in translation calls, so it can be translated with the theme’s text domain.

Localize Images (localizeImages)

off

Copies media used in templates and patterns into assets/images/ (filterable with cbt_media_folder_path_images) and points the markup at the theme copy.

Remove Navigation Refs (removeNavRefs)

off

Drops the "ref" attribute of Navigation blocks, so the theme does not point at a wp_navigation ID that exists only on this site.

Remove Taxonomy Query (removeTaxQuery)

off

Drops taxQuery from Query Loop blocks, whose term IDs are also site-specific.

In every mode the plugin also removes image and cover block IDs (wp-image-123 classes) and strips PHP tags from template and pattern content before writing it.

For a theme that ships to several sites, turn Remove Navigation Refs on (a header with "ref":42 points at a menu that exists nowhere else) and Save Patterns off unless every synced pattern on the site belongs in the theme: editors' synced patterns are content, and saving them moves them out of the Patterns screen.

REST endpoints and permissions

The sidebar calls REST routes in the create-block-theme/v1 namespace. The plugin has no WP-CLI commands, but the routes can be called from wp eval-file with rest_do_request(), as the examples below do.

Route Method Purpose

/save

POST

Save Changes to Theme (parameters in the previous table)

/reset-theme

PUT, PATCH, POST

resetStyles, resetTemplates, resetTemplateParts

/create-variation

POST

name, saveFonts

/create-child, /clone, /create-blank

POST

name plus optional metadata (description, author, uri, version, requires_wp, tags_custom, credits)

/update

POST

Edit Theme Metadata

/theme-settings

POST

Edit Theme Settings (JSON body)

/export

POST

Streams the zip

/font-families

GET

Fonts used by the theme and its variations (needs only edit_theme_options)

Every route except /font-families requires current_user_can( 'edit_themes' ) and wp_is_file_mod_allowed( 'create_block_theme_modify_theme' ). In practice:

  • DISALLOW_FILE_EDIT set to true removes edit_themes from everybody, so the plugin returns 403 (verified on 7.1.3) while the Site Editor keeps working with edit_theme_options. DISALLOW_FILE_MODS and the file_mod_allowed filter also block it.

  • On Multisite only Super Admins have edit_themes, so site administrators cannot rewrite a network theme (enforced since 2.10.0).

  • The web server user must be able to write the theme folder. On a hardened server it cannot, and the save fails; that is the intended state for production (see File Permissions and Filesystem).

A working copy to try it on

The examples use the Compose stack (MariaDB and a WP-CLI container; the scripts call the REST routes in process) of Site Editor and Global Styles, with one more volume: the lumen theme of Themes Overview (plus a parts/header.html) bind-mounted from a Git checkout, so every file the plugin writes shows up in git diff.

# compose.yaml, service cli: the volumes list
    volumes:
      - wp:/var/www/html
      - ./lumen:/var/www/html/wp-content/themes/lumen
      - ./scripts:/scripts:ro

The bind mount makes Docker create wp-content as root, so it is handed to the web server user (33) before anything is installed:

export WP_DB_PASSWORD='<a-local-db-password>' WP_ADMIN_PASSWORD='<a-local-admin-password>'
docker compose up -d
docker compose exec -u 0 cli chown 33:33 /var/www/html/wp-content /var/www/html/wp-content/themes
docker compose exec cli sh -c 'php -d memory_limit=512M /usr/local/bin/wp core download --version=7.1.3 --skip-content'
docker compose exec cli sh -c 'wp config create --dbname=wordpress --dbuser=wordpress --dbpass="$WP_DB_PASSWORD" --dbhost=db &&
  wp core install --url=https://lumenpress.test --title="Lumen Press" --admin_user=admin \
    --admin_email=admin@lumenpress.test --admin_password="$WP_ADMIN_PASSWORD" --skip-email &&
  wp theme activate lumen &&
  wp plugin install create-block-theme --version=2.10.2 --activate'

Every script below runs with --user=admin: the routes check capabilities, and global styles saved without a user lose their wp_theme term.

From the Site Editor to a Git commit

Making the change

This script makes the two saves a designer would make in the Site Editor of lumen: in Styles, links become ink coloured with a red hover state and headings bold; in Templates, a new "Blog Home" template (home) with a heading above the post list.

<?php
/**
 * Makes the two saves a designer would make in the Site Editor:
 * a Styles change and a new "Blog home" template.
 * Run with: wp eval-file /scripts/edit-like-site-editor.php --user=admin
 */
$id      = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
$request = new WP_REST_Request( 'POST', "/wp/v2/global-styles/$id" );
$request->set_body_params(
	array(
		'styles' => array(
			'elements' => array(
				'link'    => array(
					'color'  => array( 'text' => 'var(--wp--preset--color--ink)' ),
					':hover' => array( 'color' => array( 'text' => 'var(--wp--preset--color--lumen-red)' ) ),
				),
				'heading' => array(
					'typography' => array( 'fontWeight' => '700' ),
				),
			),
		),
	)
);
WP_CLI::log( 'styles   HTTP ' . rest_do_request( $request )->get_status() );

$markup  = '<!-- wp:template-part {"slug":"header","tagName":"header"} /-->' . "\n\n";
$markup .= '<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->' . "\n";
$markup .= '<main class="wp-block-group"><!-- wp:heading {"level":1} -->' . "\n";
$markup .= '<h1 class="wp-block-heading">New from Lumen Press</h1>' . "\n";
$markup .= '<!-- /wp:heading -->' . "\n\n";
$markup .= '<!-- wp:query {"queryId":2,"query":{"inherit":true}} -->' . "\n";
$markup .= '<div class="wp-block-query"><!-- wp:post-template -->' . "\n";
$markup .= '<!-- wp:post-title {"isLink":true} /-->' . "\n";
$markup .= '<!-- /wp:post-template --></div>' . "\n";
$markup .= '<!-- /wp:query --></main>' . "\n";
$markup .= '<!-- /wp:group -->';

$request = new WP_REST_Request( 'POST', '/wp/v2/templates' );
$request->set_body_params(
	array(
		'slug'    => 'home',
		'title'   => 'Blog Home',
		'content' => $markup,
	)
);
WP_CLI::log( 'template HTTP ' . rest_do_request( $request )->get_status() );
$ wp eval-file /scripts/edit-like-site-editor.php --user=admin
styles   HTTP 200
template HTTP 201
$ wp post list --post_type=wp_template,wp_template_part,wp_global_styles --post_status=any --fields=ID,post_type,post_name
ID	post_type	post_name
4	wp_global_styles	wp-global-styles-lumen
6	wp_template	home

At this point git status in the lumen checkout is clean: the work exists only in the database.

Saving it to the theme

The next script sends the request that the sidebar’s Save Changes button sends, with Save Patterns and Save Fonts off and Remove Navigation Refs on, the settings recommended above for a shared theme:

<?php
/**
 * Calls the endpoint behind Create Block Theme's "Save Changes to Theme".
 * Run with: wp eval-file /scripts/save-to-theme.php --user=admin
 */
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/save' );
$request->set_body_params(
	array(
		'saveStyle'                 => true,
		'saveTemplates'             => true,
		'processOnlySavedTemplates' => true,
		'savePatterns'              => false,
		'saveFonts'                 => false,
		'removeNavRefs'             => true,
		'localizeText'              => false,
		'localizeImages'            => false,
		'removeTaxQuery'            => false,
	)
);
$response = rest_do_request( $request );
WP_CLI::log( 'HTTP ' . $response->get_status() . ' ' . wp_json_encode( $response->get_data() ) );
$ wp eval-file /scripts/save-to-theme.php --user=admin
HTTP 200 {"status":"SUCCESS","message":"Theme Saved."}
$ wp post list --post_type=wp_template,wp_template_part,wp_global_styles --post_status=any --fields=ID,post_type,post_name
ID	post_type	post_name
4	wp_global_styles	wp-global-styles-lumen
$ wp post get 4 --field=post_content
{"styles":[],"settings":[],"isGlobalStylesUserThemeJSON":true,"version":3}
$ git -C lumen status --short && git -C lumen diff
 M theme.json
?? templates/home.html
diff --git a/theme.json b/theme.json
@@ -5,9 +5,21 @@
 		"appearanceTools": true,
 		"color": {
 			"palette": [
-				{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
-				{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
-				{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" }
(15 added lines: the same three entries, one key per line, keys sorted)
@@ -21,9 +33,21 @@
 		"elements": {
+			"heading": {
+				"typography": {
+					"fontWeight": "700"
+				}
+			},
 			"link": {
-				"color": { "text": "var(--wp--preset--color--lumen-red)" }
+				":hover": {
+					"color": {
+						"text": "var(--wp--preset--color--lumen-red)"
+					}
+				},
+				"color": {
+					"text": "var(--wp--preset--color--ink)"
+				}
 			}
 		}
 	}
-}
+}
\ No newline at end of file

What the run shows:

  • templates/home.html holds the saved markup, the wp_template post is gone and the user style layer is empty: the site renders as before, now from the files.

  • theme.json is rewritten, not patched: keys are sorted, compact one-line objects are expanded, $schema and version are kept at the top (since 2.8.0) and the final newline is dropped. The first save of a hand-written theme therefore produces a noisy diff. Committing that reformatting on its own ("normalize theme.json") keeps later diffs down to the real changes, because the plugin always writes the same layout.

  • The style revisions of the wp_global_styles post are kept, but no longer describe what the files contain.

The two files are then committed like any other change.

Without the plugin: the core export

Where installing a plugin is not allowed, the Site Editor’s Tools > Export (core since 5.9; the zip holds the whole theme since 6.0) produces a zip of the theme with the customizations written in. wp_generate_block_templates_export_file() builds the same zip, so it can be unpacked straight into the checkout. Here a tagline is added to the header part, then only templates/ and parts/ are taken from the export:

<?php
/**
 * Adds a tagline to the header part, as an edit in the Site Editor would.
 * Run with: wp eval-file /scripts/edit-header.php --user=admin
 */
$markup  = '<!-- wp:group {"tagName":"header","layout":{"type":"constrained"}} -->' . "\n";
$markup .= '<header class="wp-block-group"><!-- wp:site-title {"level":0} /-->' . "\n\n";
$markup .= '<!-- wp:site-tagline /--></header>' . "\n";
$markup .= '<!-- /wp:group -->';

$request = new WP_REST_Request( 'POST', '/wp/v2/template-parts/lumen//header' );
$request->set_body_params( array( 'content' => $markup ) );
WP_CLI::log( 'header HTTP ' . rest_do_request( $request )->get_status() );
docker compose exec cli wp eval-file /scripts/edit-header.php --user=admin
docker compose exec cli sh -c 'zip=$(wp eval "echo wp_generate_block_templates_export_file();" --user=admin)
  unzip -o -q "$zip" "parts/*" "templates/*" -d wp-content/themes/lumen && rm -f "$zip"'
git -C lumen diff
diff --git a/parts/header.html b/parts/header.html
@@ -1,5 +1,5 @@
 <!-- wp:group {"tagName":"header","layout":{"type":"constrained"}} -->
-<header class="wp-block-group">
-	<!-- wp:site-title {"level":0} /-->
-</header>
-<!-- /wp:group -->
+<header class="wp-block-group"><!-- wp:site-title {"level":0} /-->
+
+<!-- wp:site-tagline /--></header>
+<!-- /wp:group -->
\ No newline at end of file

Templates that were never customized come out of the export byte for byte as they are in the theme, so they produce no diff. Compared with the plugin, the core export:

  • does not delete the database copies; that is a separate step (next section), and forgetting it is the most common source of "my file change does nothing";

  • writes a theme.json that is the theme file merged with the user layer, but with keys sorted alphabetically (version ends up last) instead of the plugin’s layout; mixing both tools on one theme produces churn in every diff;

  • keeps Navigation "ref" IDs, image IDs and query term IDs, which then have to be removed by hand;

  • knows nothing about fonts, patterns, variations or child themes.

Style variations and child themes

The same mechanism saves a look as a style variation or starts a child theme. The script sets a dark background in Styles, saves it as the variation "Reading Room" and then creates a child theme from it:

<?php
/**
 * Calls "Create Theme Variation" and "Create Child Theme" of Create Block Theme.
 * Run with: wp eval-file /scripts/variation-and-child.php --user=admin
 */
$id      = WP_Theme_JSON_Resolver::get_user_global_styles_post_id();
$colors  = array(
	'background' => 'var(--wp--preset--color--ink)',
	'text'       => 'var(--wp--preset--color--paper)',
);
$request = new WP_REST_Request( 'POST', "/wp/v2/global-styles/$id" );
$request->set_body_params( array( 'styles' => array( 'color' => $colors ) ) );
WP_CLI::log( 'styles    HTTP ' . rest_do_request( $request )->get_status() );

$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/create-variation' );
$request->set_body_params( array( 'name' => 'Reading Room', 'saveFonts' => false ) );
WP_CLI::log( 'variation HTTP ' . rest_do_request( $request )->get_status() );

$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/create-child' );
$request->set_body_params(
	array(
		'name'        => 'Lumen Child',
		'description' => 'Site-specific adjustments to Lumen.',
		'author'      => 'Lumen Press',
		'version'     => '0.1.0',
	)
);
WP_CLI::log( 'child     HTTP ' . rest_do_request( $request )->get_status() );
$ wp eval-file /scripts/variation-and-child.php --user=admin
styles    HTTP 200
variation HTTP 200
child     HTTP 200
$ wp theme list --fields=name,status,version
name	status	version
lumen-child	active	0.1.0
lumen	parent	0.1.0
$ cd wp-content/themes && ls lumen-child lumen/styles && grep -E "Requires|Template:" lumen-child/style.css
lumen-child:
readme.txt  screenshot.png  style.css  theme.json
lumen/styles:
reading-room.json
Requires at least: 7.1
Requires PHP: 5.7
Template: lumen

styles/reading-room.json holds the styles.color object above, "version": 3 and "title": "Reading Room".

Points to check after these two actions:

  • The variation is written into the active theme’s styles/ folder, but the user layer is not cleared: the site still shows the dark look as a customization. Reset the styles (next section) and pick the variation in Styles > Browse styles instead.

  • The child theme receives the current user styles in its theme.json, a readme.txt and a generated screenshot.png, and is activated immediately. Its style.css says Requires at least = the running version unless requires_wp is sent, and Requires PHP: 5.7, a value hard-coded by the plugin that corresponds to no PHP release. Edit the header to the real requirements (Requires at least: 6.9, Requires PHP: 7.4 for Lumen) before the first commit. The finished lumen-child is described in Child Themes.

  • Clone Theme and Create Blank Theme write a new folder next to lumen, outside the Git checkout, so the new theme needs its own repository. Variation rules are on Style Variations and Section Styles.

Resetting templates and styles

Once the files hold the change, the database copies must go, on every environment that has them. The ways to do it:

Method Removes

Site Editor > Templates or Patterns > a customized item > Reset (or "Clear customizations")

That template or part’s database copy

Site Editor > Styles > Revisions > Reset to defaults

The user style layer (the post stays, emptied; revisions stay)

Create Block Theme > Reset Theme

Any of user styles, all customized templates, all customized parts

wp post delete <ids> --force on wp_template / wp_template_part posts

Exactly the copies listed; final, with no trash

DELETE /wp/v2/templates/<theme>//<slug>?force=true

One template copy, through REST (without force it is trashed)

Run any of them only after the files are committed, because a copy that was not exported is lost. The scriptable form lists the active theme’s customized templates and parts through the template API and deletes exactly those posts:

<?php
/**
 * Lists the active theme's templates and parts that have a database copy.
 * Run with: wp eval-file /scripts/list-customized.php
 */
foreach ( array( 'wp_template', 'wp_template_part' ) as $type ) {
	foreach ( get_block_templates( array(), $type ) as $template ) {
		if ( 'custom' === $template->source ) {
			WP_CLI::log( sprintf( '%d  %s  %s', $template->wp_id, $type, $template->id ) );
		}
	}
}
$ wp eval-file /scripts/list-customized.php
8  wp_template_part  lumen//header
$ wp post delete 8 --force
Success: Deleted post 8.
$ wp eval 'echo get_block_template( "lumen//header", "wp_template_part" )->source, PHP_EOL;'
theme

theme as the source means the header is served from parts/header.html again. A shortcut such as wp post delete $(wp post list --post_type=wp_template,wp_template_part --format=ids) --force removes the copies of every theme, and wp post list cannot narrow it: a --wp_theme=lumen argument is silently ignored.

A REST DELETE on a template without force=true does not delete it: it moves the copy to the trash, renamed home__trashed, and --post_status=any no longer lists it. Pass force=true, or include trash in the status list when cleaning up.

The plugin’s reset route does the same for the active theme only:

<?php
/**
 * Calls Create Block Theme's "Reset Theme" for styles, templates and parts.
 * Run with: wp eval-file /scripts/reset-theme.php --user=admin
 */
$request = new WP_REST_Request( 'PATCH', '/create-block-theme/v1/reset-theme' );
$request->set_body_params(
	array(
		'resetStyles'        => true,
		'resetTemplates'     => true,
		'resetTemplateParts' => true,
	)
);
$response = rest_do_request( $request );
WP_CLI::log( 'HTTP ' . $response->get_status() . ' ' . wp_json_encode( $response->get_data() ) );

Run as wp eval-file /scripts/reset-theme.php --user=admin, it answers HTTP 200 {"status":"SUCCESS","message":"Theme Reset."} and leaves the wp_global_styles post as {"styles":[],"settings":[],"isGlobalStylesUserThemeJSON":true,"version":3}.

Menus (wp_navigation) and synced patterns (wp_block) are content, not theme state; they move with the content export (Importing, Exporting and Migrating).

A team workflow for block themes

Lumen Press keeps lumen in Git while designers keep using the Site Editor; production only receives theme files.

flowchart LR pull["git pull: branch from main"] --> edit["Design in the Site Editor (local or staging site)"] edit --> db[("wp_global_styles, wp_template, wp_template_part")] db --> save["Create Block Theme: Save Changes to Theme"] save --> files["theme.json, templates/, parts/, styles/ updated"] save -. "database copies deleted" .-> db files --> review["git diff and review: refs, IDs, URLs, formatting"] review --> commit["commit and pull request"] commit --> ci["CI: JSON and markup checks"] ci --> deploy["Deploy theme files to staging, then production"] deploy --> reset["Reset leftover customizations on each environment"] reset --> pull
  1. One source of truth. The theme repository is the design. A site’s database may hold customizations only temporarily, on the environment where someone is designing.

  2. Design on a disposable site. wp-env, the Compose stack above, or WordPress Playground with the theme imported from GitHub (the Developer Blog describes a Playground flow that ends with "Export to GitHub" opening a pull request). Set define( 'WP_DEVELOPMENT_MODE', 'theme' ); in that site’s wp-config.php so theme.json and pattern caches are skipped and edits to files show at once.

  3. Save to the theme, never edit the same template in both places at once. If a developer changes templates/home.html in Git while a designer has a database copy of home, the designer’s save overwrites the developer’s change. Pull before designing, and save to the theme before pulling again.

  4. Review the diff like code. Look for "ref" on Navigation blocks, wp-image-<id> classes, absolute URLs to https://lumenpress.test/wp-content/uploads/..., term IDs in taxQuery, and unexpected patterns/*.php files. Validate theme.json against its $schema in CI and keep the JSON layout produced by the tool the team chose.

  5. Deploy files, then reset. After a deploy, any environment that still has customizations of the changed templates keeps serving the old copies. List them with the list-customized.php script above and reset what is listed, after confirming it is not unsaved work.

  6. Lock production. Do not install Create Block Theme there; set DISALLOW_FILE_EDIT (which also disables the plugin if present) and keep the theme folder unwritable by the web server. Administrators can still customize in the Site Editor; if that is not wanted, restrict who holds edit_theme_options (see Users, Roles and Profiles).

For a theme that a third party maintains, the same workflow applies to a child theme, so that the parent can still be updated (Child Themes).

What older books get wrong or leave out

Older advice Current state (7.1)

Full site editing appeared in 5.4 to 5.6 (PRO) or is "still maturing" (UWH)

Block themes and the Site Editor arrived in 5.9 (2022), with the Site Editor export; Create Block Theme is the WordPress.org-maintained tool for turning Site Editor work into theme files.

Customizing a theme means editing its files in Appearance > Theme File Editor, or keeping Customizer settings (PRO, UWH)

For block themes, design changes are made in the Site Editor and saved to the database; they reach the theme files only through an export or Create Block Theme. Editing files on a live server is disabled on a hardened site (DISALLOW_FILE_EDIT).

A theme update keeps or overwrites your changes as a whole (AIO)

Customized templates and styles survive updates in the database and hide the updated files until they are reset.

Version control is not discussed for site design

Site Editor work can be committed: save to the theme, review the diff, deploy files, reset database copies.

A child theme is created by hand-writing style.css and functions.php (UWH)

A block child theme needs only style.css and theme.json; Create Block Theme generates one from the current styles (with a header that has to be corrected).

Common errors and pitfalls

Symptom Cause and fix

The plugin sidebar’s actions fail with "Sorry, you are not allowed to do that." (403)

The user lacks edit_themes: DISALLOW_FILE_EDIT or DISALLOW_FILE_MODS is set, a file_mod_allowed filter denies it, or on Multisite the user is not a Super Admin.

Save reports success but the files did not change in the repository

The theme was saved on another copy (the container’s own wp-content, not the bind mount or checkout), or the web server cannot write the folder. Check get_stylesheet_directory() and permissions.

Edits to a template file have no effect

A customized database copy of that template exists. Reset it after saving its content.

Synced patterns disappeared from the Patterns screen

Save Patterns was on: they were moved to patterns/ and deleted from the database. Restore them from the theme files or a backup, and leave the option off.

"A pattern with this name already exists"

Save Patterns found a file with the same name in patterns/; rename the synced pattern or remove the old file.

The header’s menu is empty on another site

A Navigation "ref" was committed. Save with Remove Navigation Refs, or delete the attribute so the block falls back to the page list or the first menu.

Images in templates are broken after deploy

The markup points at the design site’s uploads. Save with Localize Images, which copies them into assets/images/.

A new child theme is active unexpectedly, or lists Requires PHP: 5.7

Create Child Theme activates the child and writes a placeholder PHP requirement. Switch back if needed and fix the header.

The site still looks customized after creating a variation

Create Theme Variation does not clear the user layer. Reset styles, then choose the variation.

Security notes: the plugin can write any file content an administrator puts into templates and patterns into a PHP folder of the theme (patterns are .php files). Since 2.10.0 it strips PHP tags from pattern bodies and validates downloaded media against an extension and MIME allowlist, but it remains a tool for trusted developers on non-public sites. Review every generated patterns/*.php file before committing it.