Hybrid Themes and Migration to Block Themes

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.

A classic theme does not have to be rewritten in one go to benefit from the block editor. WordPress lets it adopt block features one at a time: a theme.json file, block-based template parts printed from PHP, block patterns, and single block templates for some routes. A theme in that middle state is usually called a hybrid theme. Adding templates/index.html finally turns it into a block theme. This page, for theme developers maintaining a classic theme and site owners planning a redesign, takes the Lumen Press theme lumen-classic through every stage, checking after each step what WordPress detects, which admin screens appear and what the front end renders.

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 and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2, in beta and due in December 2026, does not change the hybrid-theme behaviour described here so far.

Theme kinds are introduced in Themes Overview; the classic pieces replaced here are covered in Customizer API, Widgets and Sidebars and Navigation and Menus.

What a hybrid theme is

"Hybrid theme" is a community term, not a third theme type. Only one file decides: a theme with a readable templates/index.html is a block theme (wp_is_block_theme() returns true); every other theme is classic, however many block features it uses. A hybrid theme renders pages through the PHP template hierarchy, has no Site Editor, and keeps the Customizer, Menus and Widgets screens.

Each block feature that a classic theme can adopt has its own switch and arrived in its own version:

Feature Since What it gives a classic theme

theme.json in the theme root

5.8

Global settings and styles: palettes, font sizes, layout widths, CSS custom properties, editor controls. wp_theme_has_theme_json() (6.2) becomes true, and core adds block-templates support, which enables block templates (see Step 4).

block_template_part()

5.9

Prints parts/<name>.html from a PHP template. It works without any theme support.

add_theme_support( 'block-template-parts' )

6.1

Lets administrators edit those parts in the Site Editor; without it they are files only.

patterns/*.php files

6.0

Auto-registered block patterns, as in block themes.

Appearance > Patterns

6.5

The Site Editor’s Patterns screen for any classic theme (moved into the Site Editor in 6.6).

Appearance > Design

6.8

Replaces Patterns for a classic theme that declares editor-styles or ships a theme.json: the Style Book and the Patterns screen.

Appearance > Fonts

7.0

The Font Library page for every theme, classic ones included.

Learn WordPress treats each stage as a valid stopping point. Staying hybrid suits a site that depends on PHP logic in its templates or on Customizer options it cannot drop yet; a full conversion suits owners who should edit layouts without a developer, or a theme being redesigned anyway.

The migration path

The site keeps working after each step, so each can ship as a separate release and be checked the same way.

flowchart TD C["Classic theme lumen-classic: PHP templates, Customizer, menus, widgets"] S1["Step 1: theme.json (Customizer colours to presets, Design screen)"] S2["Step 2: block-template-parts support and parts/header.html via block_template_part()"] S3["Step 3: patterns replace the footer widget area"] S4["Step 4: selected templates such as templates/single-lp_book.html"] S5["Step 5: templates/index.html, PHP templates and functions.php removed"] B["Block theme: Site Editor, Navigation block, global styles"] C --> S1 --> S2 --> S3 --> S4 --> S5 --> B

A site to follow along

The examples use the Compose stack from Themes Overview (MariaDB 11.8 and the wordpress:cli-2.12.0-php8.4 image, WordPress 7.1.3 at https://lumenpress.test). Add one more volume to the cli service, ./scripts:/scripts:ro, for the two helper scripts below. Activate the lumen-catalog plugin for the lp_book post type. WP_DEVELOPMENT_MODE set to theme turns off the caches of theme patterns and theme.json (not for production), and wp server serves the front end inside the container.

docker compose exec cli wp config set WP_DEVELOPMENT_MODE theme
docker compose exec cli wp theme activate lumen-classic
docker compose exec cli sh -c '
  wp rewrite structure "/%postname%/"
  wp post create --post_type=page --post_title=About --post_status=publish --porcelain     # 4
  wp post create --post_type=page --post_title=Contact --post_status=publish --porcelain   # 5
  wp post create --post_type=lp_book --post_title="The Glass Orchard" --post_status=publish
  wp menu create Primary
  wp menu item add-custom primary Books https://lumenpress.test/books/
  wp menu item add-post primary 4
  wp menu item add-post primary 5
  wp menu location assign primary primary
  wp widget reset --all
  wp widget add block lumen-footer --content="<!-- wp:paragraph --><p>Lumen Press, independent publishers since 1998.</p><!-- /wp:paragraph -->"
  wp theme mod set lumen_classic_accent_color "#8a1f18"'
docker compose exec -d cli wp server --host=127.0.0.1 --port=8080

Source: WP-CLI Commands

The seed data is what a migration must not lose: a classic menu, a footer widget and an owner’s Customizer colour. The first helper prints how WordPress classifies the active theme and builds the Appearance menu as wp-admin does:

<?php
// Prints how WordPress sees the active theme. Usage: wp eval-file /scripts/check-theme.php --user=admin

printf( "theme:                %s\n", get_stylesheet() );
printf( "wp_is_block_theme:    %s\n", var_export( wp_is_block_theme(), true ) );
printf( "has theme.json:       %s\n", var_export( wp_theme_has_theme_json(), true ) );
printf( "block-templates:      %s\n", var_export( current_theme_supports( 'block-templates' ), true ) );
printf( "block-template-parts: %s\n", var_export( current_theme_supports( 'block-template-parts' ), true ) );

// Build the admin menu the way wp-admin does, then list Appearance.
require_once ABSPATH . 'wp-admin/includes/admin.php';
global $menu, $submenu, $_wp_submenu_nopriv;
$_SERVER['REQUEST_URI'] = '/wp-admin/';
require ABSPATH . 'wp-admin/menu.php';
$items = array();
foreach ( $submenu['themes.php'] as $item ) {
	$label   = trim( wp_strip_all_tags( preg_replace( '/<span.*<\/span>/', '', $item[0] ) ) );
	$items[] = $label . ' (' . remove_query_arg( 'return', $item[2] ) . ')';
}
echo 'Appearance:           ' . implode( ', ', $items ) . "\n";

The second requests a page from wp server and prints the parts of the HTML that matter:

<?php
// Prints the matches of a regex in a page served by wp server. Usage: php /scripts/fetch.php <path> <regex>

$context = stream_context_create( array( 'http' => array( 'header' => "Host: lumenpress.test\r\n", 'ignore_errors' => true ) ) );
$html    = file_get_contents( 'http://127.0.0.1:8080' . $argv[1], false, $context );
preg_match_all( '#' . $argv[2] . '#', $html, $matches );
echo implode( "\n", array_unique( $matches[0] ) ), "\n";

Step 0: the classic starting point

lumen-classic is the minimal classic theme from Themes Overview, split into header.php, index.php and footer.php as described in Classic Theme Anatomy and The Loop. header.php prints the menu with wp_nav_menu( array( 'theme_location' => 'primary' ) ) and footer.php prints the widget area with dynamic_sidebar( 'lumen-footer' ). Its functions.php holds the three things the migration has to map: a menu location, a widget area and a Customizer colour.

<?php
/**
 * Lumen Classic theme setup (the starting point of the migration).
 *
 * @package Lumen_Classic
 */

function lumen_classic_setup() {
	add_theme_support( 'title-tag' );
	add_theme_support( 'post-thumbnails' );
	add_theme_support( 'html5', array( 'search-form', 'comment-form', 'comment-list', 'gallery', 'caption', 'style', 'script' ) );
	register_nav_menus( array( 'primary' => __( 'Primary menu', 'lumen-classic' ) ) );
}
add_action( 'after_setup_theme', 'lumen_classic_setup' );

function lumen_classic_widgets_init() {
	register_sidebar(
		array(
			'id'            => 'lumen-footer',
			'name'          => __( 'Footer', 'lumen-classic' ),
			'before_widget' => '<div id="%1$s" class="widget %2$s">',
			'after_widget'  => '</div>',
		)
	);
}
add_action( 'widgets_init', 'lumen_classic_widgets_init' );

function lumen_classic_customize_register( $wp_customize ) {
	$wp_customize->add_setting(
		'lumen_classic_accent_color',
		array(
			'default'           => '#a3271f',
			'sanitize_callback' => 'sanitize_hex_color',
		)
	);
	$wp_customize->add_control(
		new WP_Customize_Color_Control(
			$wp_customize,
			'lumen_classic_accent_color',
			array(
				'label'   => __( 'Accent colour', 'lumen-classic' ),
				'section' => 'colors',
			)
		)
	);
}
add_action( 'customize_register', 'lumen_classic_customize_register' );

function lumen_classic_enqueue_assets() {
	wp_enqueue_style( 'lumen-classic-style', get_stylesheet_uri(), array(), wp_get_theme()->get( 'Version' ) );
	$accent = sanitize_hex_color( get_theme_mod( 'lumen_classic_accent_color', '#a3271f' ) );
	wp_add_inline_style( 'lumen-classic-style', ':root{--lumen-classic-accent:' . $accent . ';}' );
}
add_action( 'wp_enqueue_scripts', 'lumen_classic_enqueue_assets' );
docker compose exec cli wp eval-file /scripts/check-theme.php --user=admin
docker compose exec cli php /scripts/fetch.php / '<header[^>]*>|<ul id="menu-primary"[^>]*>|--lumen-classic-accent:[^;]+;|<div id="block-[0-9]+" class="widget[^"]*">'
theme:                lumen-classic
wp_is_block_theme:    false
has theme.json:       false
block-templates:      false
block-template-parts: false
Appearance:           Themes (themes.php), Patterns (site-editor.php?p=%2Fpattern), Customize (customize.php), Widgets (widgets.php), Fonts (font-library.php), Menus (nav-menus.php), Theme File Editor (theme-editor.php)
--lumen-classic-accent:#8a1f18;
<header class="site-header">
<ul id="menu-primary" class="menu">
<div id="block-7" class="widget widget_block widget_text">

Even this plain classic theme gets two block-era screens: Patterns (6.5) and Fonts (7.0).

Step 1: add theme.json

A theme.json in the theme root moves design tokens out of PHP and CSS and switches on the editor’s design tools. It uses the palette and widths of the block theme lumen; defaultPalette: false hides core’s colours:

{
	"$schema": "https://schemas.wp.org/wp/7.1/theme.json",
	"version": 3,
	"settings": {
		"appearanceTools": true,
		"color": {
			"defaultPalette": false,
			"palette": [
				{ "slug": "paper", "name": "Paper", "color": "#fbf8f1" },
				{ "slug": "ink", "name": "Ink", "color": "#1f1d1a" },
				{ "slug": "lumen-red", "name": "Lumen red", "color": "#a3271f" }
			]
		},
		"layout": { "contentSize": "680px", "wideSize": "1140px" }
	},
	"styles": {
		"color": { "background": "var(--wp--preset--color--paper)", "text": "var(--wp--preset--color--ink)" },
		"elements": {
			"link": {
				"color": { "text": "var(--wp--preset--color--lumen-red)" }
			}
		}
	}
}

The colour now has a home in theme.json, so the Customizer setting and the inline CSS go. functions.php loses lumen_classic_customize_register() and its hook, lumen_classic_enqueue_assets() keeps only the wp_enqueue_style() call, and the a { color: ... } rule leaves style.css, because styles.elements.link now produces it. Editor-related theme supports that a classic theme may declare are superseded by theme.json, which wins when both exist:

add_theme_support() feature theme.json equivalent

editor-color-palette, editor-gradient-presets, editor-font-sizes

settings.color.palette, settings.color.gradients, settings.typography.fontSizes

editor-spacing-sizes, custom-spacing, custom-units

settings.spacing.spacingSizes, spacing.padding, spacing.units

disable-custom-colors, disable-custom-gradients, disable-custom-font-sizes

color.custom, color.customGradient, typography.customFontSize set to false

custom-line-height, link-color, border, appearance-tools

typography.lineHeight, color.link, border.*, appearanceTools

wp-block-styles

Not recommended with theme.json; style blocks in styles.blocks instead

$ wp eval-file /scripts/check-theme.php --user=admin
theme:                lumen-classic
wp_is_block_theme:    false
has theme.json:       true
block-templates:      true
block-template-parts: false
Appearance:           Themes (themes.php), Design (site-editor.php), Customize (customize.php), Widgets (widgets.php), Fonts (font-library.php), Menus (nav-menus.php), Theme File Editor (theme-editor.php)
$ php /scripts/fetch.php / '--wp--preset--color--lumen-red: [^;]+;|--wp--style--global--content-size: [^;]+;|a:where\(:not\(.wp-element-button\)\)\{[^}]+\}'
--wp--preset--color--lumen-red: #a3271f;
--wp--style--global--content-size: 680px;
a:where(:not(.wp-element-button)){color: var(--wp--preset--color--lumen-red);}

Still classic, the theme now has block-templates support; Patterns became Design (6.8: Style Book and Patterns), and the global stylesheet carries the presets and the link rule.

Carrying the owner’s Customizer value over

The owner’s Customizer value #8a1f18 lives in the option theme_mods_lumen-classic, which nothing reads any more. Owner choices belong in the user layer of global styles, a wp_global_styles post per theme where the Site Editor also stores edited presets. A one-off script copies the value across:

<?php
/**
 * Copies the Lumen Classic accent colour from the Customizer into the user's global styles.
 * Usage: wp eval-file /scripts/migrate-customizer.php --user=admin
 */
// Theme mods are stored per theme slug, so read the classic theme's option directly.
$mods   = get_option( 'theme_mods_lumen-classic', array() );
$accent = isset( $mods['lumen_classic_accent_color'] ) ? sanitize_hex_color( $mods['lumen_classic_accent_color'] ) : '';

if ( ! $accent ) {
	WP_CLI::success( 'The accent colour was never customized; the theme.json default applies.' );
	return;
}
// The wp_global_styles post of the active theme (created if missing).
$post = WP_Theme_JSON_Resolver::get_user_data_from_wp_global_styles( wp_get_theme(), true );
$data = json_decode( $post['post_content'], true );
// Override the theme palette, as the Site Editor does when a preset colour is edited.
$palette = wp_get_global_settings( array( 'color', 'palette', 'theme' ) );
foreach ( $palette as $i => $color ) {
	if ( 'lumen-red' === $color['slug'] ) {
		$palette[ $i ]['color'] = $accent;
	}
}
$data['settings']['color']['palette']['theme'] = $palette;

wp_update_post(
	array(
		'ID'           => $post['ID'],
		'post_content' => wp_json_encode( $data ),
	)
);
WP_CLI::success( "lumen-red is now {$accent} in global styles post {$post['ID']}." );
$ wp eval-file /scripts/migrate-customizer.php --user=admin
Success: lumen-red is now #8a1f18 in global styles post 10.
$ php /scripts/fetch.php / '--wp--preset--color--lumen-red: [^;]+;'
--wp--preset--color--lumen-red: #8a1f18;

The user layer applies to a classic theme with theme.json too, so the colour is right from this step on. Other Customizer settings map in the same spirit:

Classic (Customizer) Block theme equivalent

Colour, font and width settings of the theme (theme_mod)

theme.json presets and styles for the defaults; the owner’s values in the user global styles (Styles in the Site Editor)

Additional CSS (a custom_css post per theme)

Styles > Additional CSS. A block theme still prints the old Customizer CSS, now inside the global stylesheet (checked in Step 5)

Site Identity: logo, title, tagline, site icon

Site Logo, Site Title and Site Tagline blocks; the site icon stays under Settings > General

Header image, background image

An Image or Cover block in the header part; styles.background in theme.json

Text settings such as footer text

Plain content in a template part or pattern, edited in place

Step 2: block template parts in PHP templates

The header becomes a block template part: the part file, its theme.json registration and a block_template_part() call in header.php. Adding add_theme_support( 'block-template-parts' ) to lumen_classic_setup() lets administrators edit the part in the Site Editor; without it the part renders from the file only.

parts/header.html contains the Site Title and a Navigation block. The Navigation block has no ref yet, which is deliberate (see below):

<!-- wp:group {"tagName":"header","className":"site-header","layout":{"type":"flex","justifyContent":"space-between"}} -->
<header class="wp-block-group site-header">
	<!-- wp:site-title {"level":0} /-->
	<!-- wp:navigation {"overlayMenu":"mobile"} /-->
</header>
<!-- /wp:group -->

theme.json gains a top-level list that assigns the part to the Header area, "templateParts": [ { "name": "header", "title": "Header", "area": "header" } ].

header.php keeps the document head and replaces the hand-written header with the part. The wp-site-blocks wrapper is the class block templates use for their outer element, so block layout styles apply the same way in both:

<?php // header.php: document head, then the block template part parts/header.html. ?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
	<meta charset="<?php bloginfo( 'charset' ); ?>">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<?php wp_head(); ?>
</head>
<body <?php body_class(); ?>>
<?php wp_body_open(); ?>
<div class="wp-site-blocks">
<?php block_template_part( 'header' ); ?>
<main id="content">

footer.php closes the extra </div> before wp_footer(). block_template_part( $part ) looks up <stylesheet>//<part> (an edited copy in the database first, then the theme file), prints it through do_blocks(), and prints nothing if the part does not exist; block_header_area() and block_footer_area() are shortcuts.

$ wp eval --user=admin 'foreach ( rest_do_request( "/wp/v2/template-parts" )->get_data() as $part ) { printf( "%s %s %s\n", $part["id"], $part["area"], $part["source"] ); }'
lumen-classic//header header theme
$ php /scripts/fetch.php / '<header class="wp-block-group site-header|<p class="wp-block-site-title"|<nav class="[^"]*wp-block-navigation[^"]*"|<span class="wp-block-navigation-item__label">[^<]+'
<header class="wp-block-group site-header
<p class="wp-block-site-title"
<nav class="is-responsive wp-block-navigation is-layout-flex wp-block-navigation-is-layout-flex"
<span class="wp-block-navigation-item__label">Books
<span class="wp-block-navigation-item__label">About
<span class="wp-block-navigation-item__label">Contact
$ wp post list --post_type=wp_navigation --fields=ID,post_title,post_name
ID	post_title	post_name
12	Primary	primary

The Appearance menu does not change in 7.1: the header part is edited from Appearance > Design > Patterns, under the Header area. The REST API lists the part with source theme until someone edits it.

Menus become Navigation

The menu items appear although nothing was migrated by hand: a Navigation block without ref uses the fallback, which converted the classic menu in the primary location into a wp_navigation post (ID 12) on the first page view, leaving the classic menu untouched. The converted items keep the classic CSS classes (menu-item menu-item-type-post_type ...) and absolute URLs. Once the result looks right, select the menu in the editor so that the part stores {"ref":12} instead of relying on the fallback. Importing from the block’s menu, scripting the import with WP_Classic_To_Block_Menu_Converter, and turning the automatic creation off are described in Navigation and Menus. register_nav_menus() stays in functions.php until Step 5, so the classic menu can still be edited and switched back to.

Step 3: patterns replace widgets

Widget areas have no block-theme equivalent; their content becomes patterns placed in template parts. As a pattern, the Lumen Press footer text becomes translatable and versioned with the theme:

<?php
/**
 * Title: Footer imprint
 * Slug: lumen-classic/footer-imprint
 * Categories: footer
 * Block Types: core/template-part/footer
 * Description: Publisher imprint and catalogue link, replacing the Footer widget area.
 *
 * @package Lumen_Classic
 */

?>
<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
	<!-- wp:paragraph -->
	<p><?php esc_html_e( 'Lumen Press, independent publishers since 1998.', 'lumen-classic' ); ?></p>
	<!-- /wp:paragraph -->
	<!-- wp:paragraph -->
	<p><a href="<?php echo esc_url( home_url( '/books/' ) ); ?>"><?php esc_html_e( 'Browse the catalogue', 'lumen-classic' ); ?></a></p>
	<!-- /wp:paragraph -->
</div>
<!-- /wp:group -->

parts/footer.html places the pattern, and theme.json adds { "name": "footer", "title": "Footer", "area": "footer" } to templateParts:

<!-- wp:group {"tagName":"footer","className":"site-footer","layout":{"type":"constrained"}} -->
<footer class="wp-block-group site-footer">
	<!-- wp:pattern {"slug":"lumen-classic/footer-imprint"} /-->
</footer>
<!-- /wp:group -->

In footer.php, <?php block_template_part( 'footer' ); ?> (or the shortcut block_footer_area()) replaces the <footer> element and its dynamic_sidebar() call; the closing </main>, </div> and wp_footer() stay.

$ wp eval '$p = WP_Block_Patterns_Registry::get_instance()->get_registered( "lumen-classic/footer-imprint" ); printf( "%s | %s | %s\n", $p["title"], implode( ",", $p["categories"] ), implode( ",", $p["blockTypes"] ) );'
Footer imprint | footer | core/template-part/footer
$ php /scripts/fetch.php / '<footer class="wp-block-group site-footer[^"]*"|<p class="wp-block-paragraph">Lumen Press[^<]*</p>|Browse the catalogue</a>|<div id="block-7"'
<footer class="wp-block-group site-footer is-layout-constrained wp-block-group-is-layout-constrained"
<p class="wp-block-paragraph">Lumen Press, independent publishers since 1998.</p>
Browse the catalogue</a>

The widget block-7 is no longer printed but still exists in the sidebars_widgets option. Sites with many widgets need not rewrite them by hand: in a block theme, the Template Part block’s Advanced panel has Import widget area, which copies a widget area’s blocks into a new template part (legacy PHP widgets arrive as Legacy Widget blocks). It lists only registered, non-empty areas, so keep register_sidebar() until the import is done (see Step 5). Writing widgets and blocks is covered in Widgets and Sidebars.

Without WP_DEVELOPMENT_MODE set to theme, the list of theme pattern files is cached in a site transient until the theme’s Version header changes. A new file in patterns/ then silently does not register. Bump Version with every release that adds patterns.

Step 4: block templates for selected routes

With block-templates support (from theme.json), core’s block template loader runs even in a classic theme: locate_block_template() takes the PHP template the hierarchy found and renders an equally or more specific block template from templates/ instead, through template-canvas.php. Routes can move one at a time; Lumen Press starts with the book page:

<!-- wp:template-part {"slug":"header","tagName":"div"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:post-title {"level":1} /-->
	<!-- wp:post-featured-image /-->
	<!-- wp:post-content {"layout":{"type":"constrained"}} /-->
	<!-- wp:post-terms {"term":"lp_genre"} /-->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"div"} /-->

With ?_wp-find-template=true the front end returns the chosen block template as JSON (PHP-served pages return HTML):

$ php /scripts/fetch.php '/books/the-glass-orchard/?_wp-find-template=true' '"id":"[^"]+"|<main id="content">'
"id":"lumen-classic\/\/single-lp_book"
$ php /scripts/fetch.php '/about/?_wp-find-template=true' '"id":"[^"]+"|<main id="content">'
<main id="content">
$ php /scripts/fetch.php /books/the-glass-orchard/ '<h1 class="wp-block-post-title"[^>]*>[^<]+|<main[^>]*>'
<main id="wp--skip-link--target" class="wp-block-group is-layout-constrained wp-block-group-is-layout-constrained">
<h1 class="wp-block-post-title">The Glass Orchard

The book uses single-lp_book.html, and its <main> gets the skip-link target that core adds to block templates. The About page still uses index.php. The same support lets administrators create templates for single posts or pages from the editor’s Template panel; they are stored as wp_template posts.

Step 5: templates/index.html makes it a block theme

The last fallback of the hierarchy, templates/index.html, is the switch. From now on every request is served by a block template and PHP templates are ignored:

<!-- wp:template-part {"slug":"header","tagName":"div"} /-->

<!-- wp:group {"tagName":"main","layout":{"type":"constrained"}} -->
<main class="wp-block-group">
	<!-- wp:query {"queryId":1,"query":{"inherit":true}} -->
	<div class="wp-block-query">
		<!-- wp:post-template -->
			<!-- wp:post-title {"isLink":true} /-->
			<!-- wp:post-excerpt /-->
		<!-- /wp:post-template -->
	</div>
	<!-- /wp:query -->
</main>
<!-- /wp:group -->

<!-- wp:template-part {"slug":"footer","tagName":"div"} /-->

With the file added and functions.php unchanged:

$ wp eval-file /scripts/check-theme.php --user=admin
theme:                lumen-classic
wp_is_block_theme:    true
has theme.json:       true
block-templates:      true
block-template-parts: true
Appearance:           Themes (themes.php), Editor (site-editor.php), Fonts (font-library.php), Menus (nav-menus.php), Widgets (widgets.php)
$ wp eval --user=admin 'foreach ( rest_do_request( "/wp/v2/sidebars" )->get_data() as $s ) { printf( "%s %s [%s]\n", $s["id"], $s["name"], implode( ",", $s["widgets"] ) ); }'
wp_inactive_widgets Inactive widgets [block-2,block-3,block-4,block-5,block-6]
lumen-footer Footer [block-7]

Editor replaced Design, and the Theme File Editor moved to Tools. Customize is gone because nothing hooks customize_register any more (a plugin that does would bring it back). Menus and Widgets remain only because functions.php still registers a menu location and a sidebar, which lets Import widget area offer block-7. After the import, the classic leftovers go. functions.php, header.php, footer.php and index.php are deleted, and style.css keeps only its header. Core gives block themes post-thumbnails, html5, responsive-embeds, editor-styles and feed links, the block template canvas prints <title>, and lumen needs no functions.php. The theme holds style.css, theme.json, parts/header.html, parts/footer.html, patterns/footer-imprint.php, templates/index.html and templates/single-lp_book.html.

The owner had also saved Customizer Additional CSS (wp_update_custom_css_post() stores it as a custom_css post). The final check looks at that CSS too:

$ wp eval-file /scripts/check-theme.php --user=admin
theme:                lumen-classic
wp_is_block_theme:    true
has theme.json:       true
block-templates:      true
block-template-parts: false
Appearance:           Themes (themes.php), Editor (site-editor.php), Fonts (font-library.php)
$ php /scripts/fetch.php / '<title>[^<]*</title>|--wp--preset--color--lumen-red: [^;]+;|\.site-header \{ border-bottom[^}]*\}|<header class="wp-block-group site-header|<footer class="wp-block-group site-footer'
<title>Lumen Press</title>
--wp--preset--color--lumen-red: #8a1f18;
.site-header { border-bottom: 1px solid currentColor; }
<header class="wp-block-group site-header
<footer class="wp-block-group site-footer

The title tag, the migrated colour, the old Additional CSS (now printed inside the global stylesheet, editable under Styles > Additional CSS) and both parts are all there. With its sidebar gone, block-7 moves to Inactive widgets.

Renaming lumen-classic to lumen

Lumen Press ships the result as lumen (text domain lumen). Several records are keyed by the theme slug. On the test site, wp post list shows the custom_css post named lumen-classic and the wp_global_styles post wp-global-styles-lumen-classic, and wp option list shows theme_mods_lumen-classic:

Keyed by slug Effect of switching to lumen

theme_mods_<slug> option

Menu locations, Customizer values and the stored sidebars of the old theme stay behind. The migration script above reads the old option by name, so it still works.

wp_global_styles post (term in wp_theme)

User styles start empty for lumen. Run the global-styles migration again with lumen active.

custom_css post named after the slug

Additional CSS does not follow; copy it into Styles > Additional CSS.

wp_template / wp_template_part edits (term in wp_theme), IDs such as lumen-classic//header

Edited templates and parts no longer apply. Export them into the theme files first (see Create Block Theme and Version Control).

Pattern slugs lumen-classic/... and the text domain

Rename them in the files, and update wp:pattern references in templates, parts and saved content.

wp_navigation posts

Not tied to a theme; the imported menu survives the switch.

What older books get wrong or leave out

Older advice Current state (7.1)

A theme is either PHP templates with the Customizer or nothing, and block themes are left out (Pro WordPress, Ultimate WordPress Handbook)

Classic themes can adopt theme.json, block template parts and patterns one at a time. They become block themes when templates/index.html is added.

Full site editing arrived in 5.4 to 5.6 (Pro WordPress)

theme.json came in 5.8, block themes and block_template_part() in 5.9, and block-template-parts support in 6.1.

Customizer settings are output with get_theme_mod() and no sanitize_callback (Ultimate WordPress Handbook)

Every setting needs a sanitize_callback, and output is escaped. In a migration, the values move to theme.json and the user’s global styles.

Menus are managed only under Appearance > Menus, and widgets are the way to add content to sidebars and footers (Pro WordPress)

In a block theme, menus are Navigation blocks over wp_navigation posts (classic menus can be imported), and widget areas become template parts and patterns.

"Reusable blocks" (Ultimate WordPress Handbook)

Renamed synced patterns in 6.3. Patterns are available to classic themes through Appearance > Patterns (6.5) and Design (6.8).

Common errors and pitfalls

Symptom Cause and fix

The Site Editor does not appear although the theme has theme.json, parts/ and templates/

Only templates/index.html makes a block theme. Check with wp eval 'var_dump( wp_is_block_theme() );'.

block_template_part( 'header' ) prints nothing

The file is not parts/header.html, or the name passed includes .html. The function prints nothing when no part is found.

The header part cannot be edited in the Site Editor

add_theme_support( 'block-template-parts' ) is missing; without it the part comes from the file only. The part editor cannot create or delete parts, because the PHP template decides where they appear.

Colours changed in theme.json have no effect

The user layer (an earlier Customizer migration or Site Editor edit) overrides the theme. Check the theme’s wp_global_styles post, or reset the styles.

A new pattern file is not registered

The pattern list is cached until Version changes. Bump it, or set WP_DEVELOPMENT_MODE to theme on development sites.

Customize is still in the menu of the finished block theme

Something still hooks customize_register, such as leftover theme code or a plugin. Remove the theme’s Customizer code.

Import widget area offers nothing

The widget area is no longer registered or is empty. Keep register_sidebar() until the import is done.

A block template in a hybrid theme is not used

A more specific PHP template exists. For example, single-lp_book.php wins over templates/single.html. Only equal or more specific block templates replace a PHP template.

Security notes: patterns/*.php files run PHP whenever patterns are registered, so escape output (esc_html_e(), esc_url()) and never echo request data in them. Run the migration scripts with WP-CLI on a staging copy first (see Backups and Disaster Recovery), never as web-reachable files.