Your PHP meta box probably is not broken. The architecture around it is.
For years, WordPress plugin developers added custom fields with add_meta_box(), rendered a form in PHP, and saved the submitted values on save_post. That pattern is still supported. It is also increasingly disconnected from the way the block editor loads, edits, autosaves, revises, and eventually synchronizes post data.
WordPress 7.x makes that gap harder to ignore. WordPress 7.0 changed the conditions under which the post editor uses an iframe. WordPress 7.1 completes the transition to an always-iframed post editor, including on sites that register legacy meta boxes. A plugin that assumes the editor canvas shares the admin page’s global window, document, CSS scope, or DOM tree can now fail in ways that are difficult to reproduce on older installations.
Version note: This guide covers released WordPress 7.0 behavior and the WordPress 7.1 behavior documented in Core’s 7.1 Field Guide. If you are testing before the final 7.1 release, use the latest release candidate and repeat the matrix against the final build.
The right response is not to rename every meta key, run a risky database migration, or rebuild a mature plugin as a collection of blocks. In most cases, the safest modernization is much smaller:
- Keep the existing post meta keys and stored values.
- Register those keys with
register_post_meta()so WordPress knows their types, defaults, REST behavior, sanitization rules, and permissions. - Replace the block editor’s PHP form with a native JavaScript interface, usually a
PluginSidebarorPluginDocumentSettingPanel. - Read and update the registered values through WordPress’s entity data store.
- Retain the old
add_meta_box()implementation only where it still has a job, such as a Classic Editor fallback.
That approach modernizes the editing experience without putting production data at risk.
The short answer: how should a classic WordPress meta box be modernized?
To modernize a classic PHP meta box for WordPress 7.x, keep its current database keys, register each key with register_post_meta() using an explicit type, single setting, show_in_rest, sanitization callback, authorization callback, and revision policy, then build the block editor UI with PluginSidebar and useEntityProp. If the plugin must still support the Classic Editor, keep add_meta_box() as a fallback and mark it with __back_compat_meta_box so WordPress does not display both interfaces in the block editor.
The most important detail is that add_meta_box() and register_post_meta() are not competing versions of the same API. One creates an interface. The other registers data.
Why WordPress 7.x changes the urgency
The block editor has been moving toward an isolated canvas for years. The iframe separates theme and content styles from the editor’s administrative interface, which makes the editing environment more predictable. It also exposes extensions that were relying on undocumented DOM access.
According to the official WordPress 7.0 Field Guide, the post editor is iframed when every block inserted in the current post uses Block API version 3 or newer. A lower-versioned block can still trigger the compatibility path in 7.0.
The WordPress 7.1 iframe developer note removes that variability: the post editor is always iframed, regardless of theme type, registered blocks, blocks in the post, or legacy meta boxes. Core specifically advises developers to stop reaching for the global document or window when they mean the editor canvas.
For a wider view of the release, see WPBay’s overview of the WordPress 7.1 roadmap. This tutorial stays focused on the editor-extension changes that affect post meta and plugin sidebars.
This does not mean that WordPress 7.x removes classic meta boxes. Core continues to support them. It means a legacy integration deserves scrutiny when it does any of the following:
- injects scripts that query or mutate editor-canvas DOM nodes;
- expects theme styles, admin styles, and editor styles to share one document;
- uses jQuery selectors against the entire edit screen;
- maintains its own JavaScript state separately from the editor’s post entity;
- sends a second AJAX request to save data after WordPress saves the post;
- renders the same field in a classic box and a sidebar without a single source of truth;
- stores complex values without a declared REST schema;
- depends on incidental markup or class names in the editor UI.
A basic server-rendered field may survive the transition. The surrounding assumptions are where most regressions live.
add_meta_box() vs register_post_meta(): the distinction that prevents bad migrations
Developers often ask whether they should replace add_meta_box() with register_post_meta(). The question mixes two layers of the plugin.
| Concern | add_meta_box() | register_post_meta() |
|---|---|---|
| Primary purpose | Registers a server-rendered admin UI box | Registers a post-meta data contract |
| Produces visible controls | Yes, through a PHP callback | No |
| Defines a value type | No | Yes |
| Exposes data to the block editor’s REST workflow | No | Yes, with show_in_rest |
| Centralizes sanitization | Not by itself | Yes, through sanitize_callback |
| Centralizes write authorization | Not by itself | Yes, through auth_callback and meta capabilities |
| Supports an explicit default | UI code must provide it | Yes |
| Supports meta revisions | Not by itself | Yes, through revisions_enabled |
| Best modern use | Classic Editor fallback or genuinely server-rendered admin workflow | Canonical schema for post meta used by modern editor interfaces |
Think of the modern implementation as three layers:
- Data layer:
register_post_meta()defines what the field is and who may change it. - Editor layer:
PluginSidebar,PluginDocumentSettingPanel, or a block gives authors the controls. - Compatibility layer:
add_meta_box()remains available when a classic editing screen still needs it.
Deleting the PHP box without registering the data leaves the block editor with no reliable REST contract. Registering the data without adding a UI leaves authors with nothing to edit. Keeping both UIs active creates duplication and conflicting state. A safe migration handles all three layers deliberately.
Choose the right block editor surface before writing code
Not every legacy meta box belongs in a dedicated sidebar. Match the interface to the field’s role.
| Modern surface | Best for | Trade-off |
PluginSidebar | A plugin-specific workflow with several related settings | Creates a separate toolbar icon and sidebar |
PluginDocumentSettingPanel | A small group of document-level settings that belong beside status, taxonomy, excerpt, and featured image | Less visual separation from Core settings |
| Meta-backed block | Data that authors should see and position in the content canvas | Can be removed or moved unless the template is controlled |
| Block inspector controls | Settings that belong to one selected block | Not suitable for post-wide fields |
| Classic PHP meta box | Classic Editor support or a temporary migration bridge | Does not provide a native block-editor experience |
The official PluginSidebar documentation describes a dedicated panel opened from a toolbar icon. WordPress also creates the associated Options-menu entry automatically. You do not need to register a separate PluginSidebarMoreMenuItem for the normal case.
For two or three document properties, PluginDocumentSettingPanel may be less intrusive. For a larger editorial workflow—SEO fields, product attributes, compliance checks, or marketplace listing data—a dedicated PluginSidebar is usually easier to discover and maintain.
The implementation below uses a dedicated sidebar because it demonstrates the complete migration pattern clearly.
A production-shaped plugin structure
Keep PHP registration, source JavaScript, and generated build files separate:
wpbay-editor-fields/
├── wpbay-editor-fields.php
├── package.json
├── src/
│ ├── index.js
│ └── editor.scss
└── build/
├── index.js
├── index.css
└── index.asset.phpThe build directory should be generated, not edited by hand. The index.asset.php file is especially important because it tells WordPress which bundled package handles must load before your compiled script.
Install the official build tooling in the plugin directory:
npm install --save-dev @wordpress/scriptsThen add the build commands to package.json:
{
"scripts": {
"start": "wp-scripts start",
"build": "wp-scripts build"
}
}Use npm run start while developing and npm run build before packaging a release. The production ZIP needs the generated build files, but it does not need node_modules.
Step 1: register the existing post meta in PHP
Assume a mature plugin already stores these values:
_wpbay_editor_subtitle: a one-line string;_wpbay_featured_listing: a boolean flag.
Do not change those keys simply because the UI is changing. Register them as they are.
<?php
/**
* Plugin Name: WPBay Modern Editor Fields
* Description: Example migration from a classic meta box to a block editor sidebar.
* Text Domain: wpbay-editor-fields
*/
defined( 'ABSPATH' ) || exit;
final class WPBay_Modern_Editor_Fields {
private const POST_TYPES = array( 'post', 'page' );
private const META_SUBTITLE = '_wpbay_editor_subtitle';
private const META_FEATURED = '_wpbay_featured_listing';
public static function boot(): void {
add_action( 'init', array( __CLASS__, 'register_meta' ) );
add_action(
'enqueue_block_editor_assets',
array( __CLASS__, 'enqueue_editor_assets' )
);
add_action( 'add_meta_boxes', array( __CLASS__, 'add_classic_fallback' ) );
add_action( 'save_post', array( __CLASS__, 'save_classic_fallback' ) );
}
public static function register_meta(): void {
foreach ( self::POST_TYPES as $post_type ) {
register_post_meta(
$post_type,
self::META_SUBTITLE,
array(
'label' => __( 'Editorial subtitle', 'wpbay-editor-fields' ),
'description' => __( 'A short internal subtitle.', 'wpbay-editor-fields' ),
'type' => 'string',
'single' => true,
'default' => '',
'show_in_rest' => true,
'sanitize_callback' => 'sanitize_text_field',
'auth_callback' => array( __CLASS__, 'can_edit_meta' ),
'revisions_enabled' => true,
)
);
register_post_meta(
$post_type,
self::META_FEATURED,
array(
'label' => __( 'Featured listing', 'wpbay-editor-fields' ),
'description' => __( 'Marks the item as featured.', 'wpbay-editor-fields' ),
'type' => 'boolean',
'single' => true,
'default' => false,
'show_in_rest' => true,
'sanitize_callback' => 'rest_sanitize_boolean',
'auth_callback' => array( __CLASS__, 'can_edit_meta' ),
'revisions_enabled' => true,
)
);
}
}
/**
* Meta authorization callback.
*
* WordPress passes additional capability arguments after $post_id.
*/
public static function can_edit_meta(
$allowed,
$meta_key,
$post_id,
$user_id = 0,
$cap = '',
$caps = array()
): bool {
return current_user_can( 'edit_post', (int) $post_id );
}
}
WPBay_Modern_Editor_Fields::boot();The register_meta() reference documents the contract used by register_post_meta(). Every option deserves an intentional choice:
typemust match what the REST API will receive and return.singledetermines whether one value or a list of values belongs to the post.defaultstabilizes first-load behavior when no row exists inwp_postmeta.show_in_restmakes the field available to the editor’s REST-backed entity record.sanitize_callbackvalidates the value on the server, regardless of which UI sent it.auth_callbackprotects writes at the data boundary.revisions_enabledlets supported post types include the value in revisions.
The custom-fields support requirement
A registered meta field will not become available through the REST API for a post type unless that post type supports custom-fields. The official Meta Boxes guide calls this out directly.
Posts and pages normally have the relevant Core support. For a custom post type, declare it when registering the type:
register_post_type(
'wpbay_product',
array(
'label' => __( 'Products', 'wpbay-editor-fields' ),
'show_in_rest' => true,
'supports' => array(
'title',
'editor',
'custom-fields',
'revisions',
),
)
);The post type itself also needs show_in_rest => true to use the block editor. If another plugin owns the post type registration, coordinate the support change rather than silently assuming it.
Do not expose secrets as ordinary post meta
show_in_rest => true is necessary for this editor pattern, but it should prompt a data review. API keys, private credentials, raw payment data, and other secrets do not belong in a standard post-meta field exposed to the editor’s REST response. Store secrets in an appropriate protected system and expose only the minimum state the author needs.
A leading underscore makes a key protected in some classic WordPress interfaces. It does not turn a REST-registered value into a secret.
Step 2: enqueue the sidebar bundle correctly
Use enqueue_block_editor_assets for scripts and styles that extend the editor interface. Do not enqueue the sidebar bundle across every admin screen or on the public site.
Add these methods inside the PHP class:
public static function enqueue_editor_assets(): void {
$screen = get_current_screen();
if (
! $screen ||
! $screen->is_block_editor() ||
! in_array( $screen->post_type, self::POST_TYPES, true )
) {
return;
}
$asset_path = plugin_dir_path( __FILE__ ) . 'build/index.asset.php';
if ( ! file_exists( $asset_path ) ) {
return;
}
$asset = require $asset_path;
wp_enqueue_script(
'wpbay-editor-fields',
plugins_url( 'build/index.js', __FILE__ ),
$asset['dependencies'],
$asset['version'],
true
);
wp_set_script_translations(
'wpbay-editor-fields',
'wpbay-editor-fields'
);
$style_path = plugin_dir_path( __FILE__ ) . 'build/index.css';
if ( file_exists( $style_path ) ) {
wp_enqueue_style(
'wpbay-editor-fields',
plugins_url( 'build/index.css', __FILE__ ),
array(),
$asset['version']
);
}
}When @wordpress/scripts builds the source, it generates index.asset.php with the correct WordPress package dependencies and a cache-busting version. The official wp-scripts setup guide documents this pattern.
Avoid copying a dependency array from a tutorial. Imports change as the extension evolves. The generated asset file should remain the source of truth.
Step 3: build the PluginSidebar with the entity data store
Create src/index.js:
import { useEntityProp } from '@wordpress/core-data';
import { PanelBody, TextControl, ToggleControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
import { PluginSidebar, store as editorStore } from '@wordpress/editor';
import { __ } from '@wordpress/i18n';
import { registerPlugin } from '@wordpress/plugins';
import './editor.scss';
const SUPPORTED_POST_TYPES = [ 'post', 'page' ];
const META_SUBTITLE = '_wpbay_editor_subtitle';
const META_FEATURED = '_wpbay_featured_listing';
function MetaFields( { postType } ) {
const [ meta, setMeta ] = useEntityProp(
'postType',
postType,
'meta'
);
const values = meta ?? {};
const updateMeta = ( key, value ) => {
setMeta( {
...values,
[ key ]: value,
} );
};
return (
<PluginSidebar
name="wpbay-editor-fields"
title={ __( 'Editorial fields', 'wpbay-editor-fields' ) }
icon="edit"
className="wpbay-editor-fields"
>
<PanelBody
title={ __( 'Listing details', 'wpbay-editor-fields' ) }
initialOpen={ true }
>
<p className="wpbay-editor-fields__intro">
{ __(
'These values are saved with the post and included in revisions.',
'wpbay-editor-fields'
) }
</p>
<TextControl
label={ __( 'Editorial subtitle', 'wpbay-editor-fields' ) }
value={ values[ META_SUBTITLE ] ?? '' }
onChange={ ( value ) =>
updateMeta( META_SUBTITLE, value )
}
help={ __(
'Use a short, plain-text summary.',
'wpbay-editor-fields'
) }
/>
<ToggleControl
label={ __( 'Featured listing', 'wpbay-editor-fields' ) }
checked={ Boolean( values[ META_FEATURED ] ) }
onChange={ ( value ) =>
updateMeta( META_FEATURED, value )
}
/>
</PanelBody>
</PluginSidebar>
);
}
function EditorExtension() {
const postType = useSelect(
( select ) => select( editorStore ).getCurrentPostType(),
[]
);
if ( ! SUPPORTED_POST_TYPES.includes( postType ) ) {
return null;
}
return <MetaFields postType={ postType } />;
}
registerPlugin( 'wpbay-editor-fields', {
render: EditorExtension,
icon: 'edit',
} );There are several important decisions in this example.
useEntityProp is the source of truth
The useEntityProp hook reads and updates the meta property on the current post entity. The setter updates the editor’s in-memory record. The normal editor save operation then persists that record through the REST API.
This is preferable to maintaining a separate useState copy and pushing it to a custom endpoint later. Two state systems create drift: the control can display one value while WordPress thinks the post contains another.
Spread the complete meta object
The setter receives the new value of the meta property, not just one key. Preserve unrelated registered meta values:
setMeta( {
...values,
[ key ]: value,
} );Calling setMeta( { [ key ]: value } ) can discard other keys from the edited entity state. The server may preserve values it never receives, but the editor’s working record is no longer complete. Do not build that ambiguity into the plugin.
Split the components to obey hook rules
EditorExtension checks the post type before rendering MetaFields. That keeps useEntityProp inside a component that always calls it, instead of conditionally calling a hook after an early return.
Let WordPress save the post
The sidebar does not call fetch, apiFetch, update_post_meta(), or a custom AJAX action on every field change. It edits the current entity. Draft saves, publishes, autosaves, failures, and dirty-state indicators remain coordinated by WordPress.
Step 4: keep editor CSS small and scoped
Create src/editor.scss:
.wpbay-editor-fields {
.wpbay-editor-fields__intro {
margin: 0 0 16px;
color: #50575e;
font-size: 12px;
line-height: 1.5;
}
.components-base-control {
margin-bottom: 16px;
}
}Plugin sidebar controls live in the editor interface, outside the content canvas iframe, so editor-interface styles loaded through enqueue_block_editor_assets are appropriate here.
Do not use broad selectors such as .components-panel, input, or .editor-styles-wrapper input. They can restyle Core controls and other plugins. Give the sidebar a stable class and scope every rule beneath it.
If the plugin also styles content inside the canvas, that is a separate asset problem. The official editor asset guide distinguishes editor-interface assets from editor-content assets. Block styles should normally be declared in block.json or loaded through the appropriate block/content mechanism, not smuggled into the canvas through sidebar CSS.
Step 5: retain a Classic Editor fallback without duplicating the block editor UI
If the plugin promises Classic Editor support, keep the existing PHP interface. Mark it as a backward-compatibility meta box after the new block editor UI is available.
Add the following methods to the PHP class:
public static function add_classic_fallback(): void {
add_meta_box(
'wpbay-editor-fields',
__( 'Editorial fields', 'wpbay-editor-fields' ),
array( __CLASS__, 'render_classic_fallback' ),
self::POST_TYPES,
'side',
'default',
array(
'__back_compat_meta_box' => true,
)
);
}
public static function render_classic_fallback( WP_Post $post ): void {
$subtitle = get_post_meta( $post->ID, self::META_SUBTITLE, true );
$featured = (bool) get_post_meta( $post->ID, self::META_FEATURED, true );
wp_nonce_field(
'wpbay_save_editor_fields',
'wpbay_editor_fields_nonce'
);
?>
<p>
<label for="wpbay-editor-subtitle">
<?php esc_html_e( 'Editorial subtitle', 'wpbay-editor-fields' ); ?>
</label>
</p>
<p>
<input
type="text"
class="widefat"
id="wpbay-editor-subtitle"
name="wpbay_editor_subtitle"
value="<?php echo esc_attr( $subtitle ); ?>"
>
</p>
<p>
<label>
<input
type="checkbox"
name="wpbay_featured_listing"
value="1"
<?php checked( $featured ); ?>
>
<?php esc_html_e( 'Featured listing', 'wpbay-editor-fields' ); ?>
</label>
</p>
<?php
}
public static function save_classic_fallback( int $post_id ): void {
if (
! isset( $_POST['wpbay_editor_fields_nonce'] ) ||
! wp_verify_nonce(
sanitize_text_field(
wp_unslash( $_POST['wpbay_editor_fields_nonce'] )
),
'wpbay_save_editor_fields'
)
) {
return;
}
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return;
}
if ( wp_is_post_revision( $post_id ) ) {
return;
}
if ( ! in_array( get_post_type( $post_id ), self::POST_TYPES, true ) ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$subtitle = isset( $_POST['wpbay_editor_subtitle'] )
? sanitize_text_field( wp_unslash( $_POST['wpbay_editor_subtitle'] ) )
: '';
if ( '' === $subtitle ) {
delete_post_meta( $post_id, self::META_SUBTITLE );
} else {
update_post_meta( $post_id, self::META_SUBTITLE, $subtitle );
}
update_post_meta(
$post_id,
self::META_FEATURED,
isset( $_POST['wpbay_featured_listing'] )
);
}The __back_compat_meta_box argument is the crucial part. The official WordPress meta-box compatibility guidance explains that a box marked this way is hidden in the block editor but remains available in the classic editor.
That gives each editor one interface and both interfaces the same storage keys.
What about __block_editor_compatible_meta_box?
Use __block_editor_compatible_meta_box => false only when an existing box genuinely cannot work in the block editor and a modern interface is not ready. WordPress can replace the box with an incompatibility notice that directs users toward a classic editing experience.
It is an emergency compatibility declaration, not the end state of a modernization project.
Once the sidebar is ready, __back_compat_meta_box => true expresses the intended architecture more accurately: the PHP box exists for older editing flows, while the block editor uses the modern interface.
Why this migration needs no database rewrite
The safest migration is often a UI migration, not a data migration.
Both interfaces above use _wpbay_editor_subtitle and _wpbay_featured_listing. Existing records remain in wp_postmeta. Templates can continue calling get_post_meta(). Queries can continue using the same meta_query. Imports, exports, webhooks, and integrations do not need a coordinated key change.
Registering a key does not move its values into a new table. It tells WordPress how to interpret and expose the values already stored under that key.
Before release, audit the historical data anyway:
SELECT meta_key, COUNT(*) AS rows
FROM wp_postmeta
WHERE meta_key IN (
'_wpbay_editor_subtitle',
'_wpbay_featured_listing'
)
GROUP BY meta_key;Use a read-only copy or staging database for exploratory SQL. The purpose is to discover surprises, not to normalize production data casually.
Look for:
- multiple rows for a key that the new schema declares as
single; - booleans stored as
1,0, empty strings,yes, orno; - serialized arrays mixed with scalar values;
- invalid values that an old save handler allowed;
- keys written with inconsistent capitalization;
- orphaned rows belonging to deleted posts;
- post types that reuse the same key with different meanings.
If the old values are already compatible, ship the registration and UI without touching the database. If normalization is required, write an idempotent, resumable migration with version tracking and backups. Do not perform a full-table rewrite on every request or plugin activation.
Data types are part of the API, not documentation decoration
Classic post meta is permissive. The REST API is not.
get_post_meta() returns non-serialized scalar values as strings in many PHP contexts. A stored 1 may arrive through the registered REST schema as true when the field type is boolean. An integer field should be sent as a number, not a numeric string. A field registered as an object needs a complete object schema.
Strings
Use type => 'string' for plain text, identifiers, URLs, and select values. Match the sanitizer to the content:
sanitize_text_fieldfor one line of plain text;sanitize_textarea_fieldfor multiline plain text;esc_url_rawfor a stored URL;- a custom allowlist callback for status or layout choices.
Sanitization and output escaping solve different problems. Sanitize on write, then escape for the output context with esc_html, esc_attr, esc_url, or another appropriate function.
Booleans
Use a real JavaScript boolean in the sidebar and type => 'boolean' in PHP. Avoid sending 'yes', 'no', 'on', or 'off'. Historical rows can remain string-backed in the database; the registered schema gives the REST layer a consistent representation.
Integers and numbers
Convert component values before updating meta when a control emits strings:
onChange={ ( value ) => updateMeta( '_wpbay_priority', Number( value ) ) }Decide how the field represents “not set.” Zero, an empty string, a missing row, and null are not interchangeable. The REST API schema guide notes that nullable values require a schema that permits null.
Arrays and objects
Complex meta requires show_in_rest to include a schema, not just true. For example:
register_post_meta(
'post',
'_wpbay_review_settings',
array(
'type' => 'object',
'single' => true,
'default' => array(),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'additionalProperties' => false,
'properties' => array(
'priority' => array(
'type' => 'integer',
'minimum' => 0,
'maximum' => 10,
),
'note' => array(
'type' => 'string',
),
),
),
),
)
);For a handful of independently queried values, separate scalar keys are often easier to validate, revise, and query than one serialized object. Use a complex field because the values form one domain object, not because it reduces the visible row count.
Security: move enforcement to the data boundary
A JavaScript control can hide an option from an author. It cannot secure the option.
Users can call REST endpoints directly, modify browser requests, or trigger writes from another integration. The server must remain authoritative.
A secure registered field has at least four controls:
- Authentication: WordPress identifies the current user for the REST request.
- Authorization: the meta registration checks whether that user may edit the target object.
- Validation and sanitization: the submitted value must match the schema and be cleaned on the server.
- Output escaping: every later rendering context escapes the stored value correctly.
The example’s auth_callback delegates to current_user_can( 'edit_post', $post_id ). That is a sensible baseline for ordinary editorial metadata. A marketplace approval flag, financial field, or compliance override may require a narrower custom capability.
Avoid a role-name check such as current_user_can( 'administrator' ). Capabilities are portable across custom roles and multisite configurations; hard-coded role names are not.
The Classic Editor save handler still verifies a nonce, rejects autosave and revision requests, checks the post type, verifies capabilities, unslashes input, and sanitizes it. Registration does not make a form-post handler safe automatically.
Iframes: what actually breaks in WordPress 7.x
The dedicated PluginSidebar lives in the editor shell. It normally does not need direct access to the content iframe. Problems appear when an extension reaches into the canvas or assumes canvas and shell share globals.
This is fragile:
const canvasHeading = document.querySelector( '.editor-styles-wrapper h1' );In an iframed editor, the global document belongs to the editor shell, not necessarily the content canvas. A selector may return null, target an unrelated element, or behave differently depending on the post’s blocks in WordPress 7.0.
Prefer WordPress data and block APIs whenever possible. If a legitimate canvas integration needs a DOM reference, start from an element inside that document and use its ownerDocument and defaultView. The 7.1 developer note also recommends useRefEffect for attaching and cleaning up listeners on canvas elements.
The same boundary applies to styles:
- sidebar and toolbar styles target the editor shell;
- block/content styles target the canvas;
- front-end styles target the public document.
One stylesheet should not try to guess all three contexts.
Autosaves, revisions, and editor state
A modern sidebar should participate in the same lifecycle as the post.
Using useEntityProp makes field edits part of the post’s edited entity. WordPress can mark the post dirty and include the registered meta in its normal save operation. With revisions_enabled => true, supported post types can include those registered fields in revisions.
Confirm that the post type supports revisions; otherwise the registration flag has no useful revision store to work with. Also test autosave behavior rather than assuming revision and autosave requests treat every custom configuration identically.
Avoid these parallel-save patterns:
- saving the field immediately through custom AJAX while the post remains unsaved;
- persisting on every keystroke with a custom REST route;
- updating post meta in both an entity setter and a
subscribe()callback; - copying entity meta into local state and synchronizing it in an effect;
- triggering a second save after Core finishes its own save.
They create race conditions. A slower request can overwrite a newer value, the post can appear clean while custom data is still in flight, or the author can navigate away between two saves.
Use local state only for genuinely transient UI state—for example, whether a help section is expanded. Post data belongs in the post entity.
A staged migration plan for an established plugin
Do not replace a mature meta box and its save path in one unobserved release. Use a sequence that can be tested and reversed.
Phase 1: inventory the current contract
Document:
- every meta key and the post types that use it;
- expected type, default, and empty-state behavior;
- current sanitization and capability checks;
- templates, shortcodes, REST consumers, imports, exports, and queries that read it;
- Classic Editor support commitments;
- plugins that add fields to or manipulate the existing box;
- any direct DOM selectors or jQuery behaviors attached to the box.
The save callback often reveals more truth than the render callback. Follow the data all the way to storage.
Phase 2: register the data without changing the UI
Add register_post_meta() and verify REST responses in a development environment. Keep the PHP box active temporarily. This isolates schema and permission problems from interface problems.
Test every role that can edit the post type. An administrator-only test misses capability mapping defects.
Phase 3: add the sidebar behind a development flag
Build the PluginSidebar, load it only for supported post types, and compare its values with the classic box. During internal testing, do not allow both interfaces to write independently for long periods; duplication is a diagnostic step, not a production design.
Phase 4: switch the block editor to one interface
Mark the PHP box with __back_compat_meta_box => true. The sidebar becomes the block editor interface, while the Classic Editor retains the PHP form.
Phase 5: release to a controlled cohort
If the plugin has a settings framework or feature flag, roll out by site or environment. Log schema rejections and REST errors without logging sensitive field contents.
Phase 6: remove obsolete compatibility code only when support policy allows
The old render and save callbacks can be removed when the plugin no longer supports classic editing and no extension hooks depend on the box. Removing code is the final step, not proof that the migration worked.
The test matrix that catches real regressions
“The field saved once on my admin account” is not a compatibility test.
| Area | Cases to test | Expected result |
| WordPress versions | Lowest supported release, 7.0, 7.1 | One working interface per editor; no console errors |
| Editor modes | Block Editor, Classic Editor if supported | Sidebar in block editor; fallback box only in classic editor |
| Themes | Classic and block themes | Identical field behavior |
| Post types | Every supported and unsupported type | Sidebar appears only where registration and UI both apply |
| Blocks | API v3 content and legacy lower-version blocks on 7.0 | Data behavior remains consistent if iframe mode changes |
| Roles | Administrator, editor, author, custom roles | Writes follow capabilities; unauthorized updates fail |
| Post lifecycle | New draft, update, publish, scheduled post, trash/restore | Values persist correctly |
| Recovery | Autosave, browser refresh, revision restore | No silent data loss or stale sidebar state |
| Values | Empty, zero, false, long text, Unicode, invalid input | Schema, defaults, and sanitizers behave as designed |
| REST | Read and update with allowed and denied users | Correct status codes and no unintended exposure |
| UI | Narrow viewport, zoom, keyboard navigation, screen reader labels | Controls remain usable and labelled |
| Conflicts | SEO, custom-fields, permissions, cache, and editor plugins | No duplicate key ownership or save races |
| Build | Production ZIP installed without node_modules | Compiled assets and index.asset.php are present |
Use WordPress Playground, a local matrix, or automated end-to-end tests to cover combinations. At minimum, add PHP tests for registration and sanitization, REST tests for permission behavior, and an editor test that changes a field, saves, reloads, and verifies the value.
Common migration failures and their fixes
| Symptom | Likely cause | Fix |
| Sidebar loads but fields are missing | Meta is not registered with show_in_rest, or post type lacks custom-fields support | Register the key for the exact subtype and update post type supports |
| REST save returns 400 | JavaScript value does not match registered type/schema | Normalize values before calling setMeta and correct the schema |
| REST save returns 403 | Meta capability or post-type capability mapping denies the user | Test auth_callback, custom capabilities, and map_meta_cap behavior |
| Other meta disappears from editor state | Setter replaced the whole meta object with one key | Spread existing meta when updating |
| Classic box and sidebar disagree | Both use separate state or different keys | Use the same keys and one interface per editor |
| Sidebar appears on the wrong screen | Bundle is loaded globally or render is not post-type gated | Gate both PHP enqueue and JavaScript render |
| Styles affect Core controls | Unscoped selectors | Scope CSS beneath the plugin sidebar class |
Canvas query returns null in 7.x | Code uses the shell’s global document | Use WordPress APIs or an element’s ownerDocument |
| Changes vanish after refresh | Local component state is never written to the entity | Use useEntityProp for post data |
| Old posts show inconsistent toggles | Historical values use mixed boolean formats | Audit, normalize if necessary, and use a boolean schema |
| Production sidebar is blank | Built files or asset manifest are missing from the release ZIP | Add a release check for build/index.js and build/index.asset.php |
| Translation calls show English only | Script translations or text domain are not configured | Call wp_set_script_translations and preserve the domain in source strings |
Performance and usability details worth keeping
A sidebar can be technically correct and still be unpleasant.
Load only where the feature exists
Gate the bundle in PHP and gate the rendered component in JavaScript. The second check is not redundant: PHP protects performance, while JavaScript protects behavior when editor context changes or another environment loads the extension.
Group related controls
Use PanelBody sections with descriptive titles. Avoid a 40-field uninterrupted form. Progressive disclosure is useful, but do not hide required fields behind several collapsed panels.
Use Core components
WordPress components provide consistent labeling, keyboard behavior, focus treatment, and spacing. Recreating toggles and selects from raw divs increases accessibility and maintenance work.
Keep destructive or expensive actions explicit
A text change can update entity state immediately. An operation that calls an external service, regenerates files, or deletes derived data should have a clear button, progress state, error state, and retry path. Do not tie expensive side effects to every onChange event.
Do not manufacture save buttons
If a control edits post meta, the editor’s Save, Update, and Publish flows should save it. A second “Save settings” button makes authors wonder which save is authoritative.
Make empty states intentional
Explain whether an empty value inherits a global setting, disables a feature, or removes an override. Placeholder text is not a substitute for help text because it disappears as soon as the field contains a value.
When should the legacy meta box stay?
Modernization does not require erasing every PHP interface.
Keep the classic box when:
- the plugin officially supports Classic Editor users;
- the same field appears on a non-block-editor admin screen;
- a gradual rollout needs a reversible fallback;
- third-party integrations use documented hooks inside the existing box and need a deprecation period.
Do not keep it active in the block editor merely because it still renders. A duplicate box below the canvas competes with the native sidebar, splits the save model, and leaves developers maintaining two primary experiences.
If third parties extend the old box, publish a migration notice and provide a new extension surface. That may be a JavaScript filter, a SlotFill, a server-side meta-registration filter, or a documented API. Removing an undocumented DOM target is still a compatibility change for users, even if the old dependency was fragile.
Frequently asked questions
Is add_meta_box() deprecated in WordPress 7.x?
No. add_meta_box() remains a supported API. The issue is architectural fit: a PHP meta box is not a native block-editor sidebar and does not register the underlying data for REST-backed editing.
Does register_post_meta() replace add_meta_box()?
No. register_post_meta() registers a field’s schema and behavior. It does not render a control. A modern plugin normally combines registered meta with a JavaScript editor UI and may keep add_meta_box() for classic compatibility.
Why is show_in_rest required?
The block editor loads and saves the current post through REST-backed data stores. Without show_in_rest, the registered key is not present in the entity’s editable meta property.
Why is my registered meta missing from the editor?
Check all three requirements: register the key for the correct post subtype, set show_in_rest, and make sure the post type supports custom-fields. For a custom post type, it must also use show_in_rest => true to run in the block editor.
Should I use PluginSidebar or PluginDocumentSettingPanel?
Use PluginSidebar for a distinct plugin workflow with several settings. Use PluginDocumentSettingPanel for a small set of document properties that belong in the main settings sidebar. Both can edit the same registered meta through useEntityProp.
Can I keep the same meta keys?
Yes, and that is usually the safest choice. Registering existing keys lets the new interface use current data without a database migration. Change keys only when the data model itself needs to change.
Will leading-underscore meta keys work in a PluginSidebar?
Yes, if they are registered correctly and exposed through REST. The underscore affects classic protected-meta conventions; it does not prevent an explicitly registered field from being used by the editor.
Does show_in_rest make the field publicly writable?
No. REST visibility and write authorization are separate concerns. The endpoint, post permissions, meta capabilities, and auth_callback still govern updates. Nevertheless, developers should inspect read visibility carefully and never place secrets in ordinary editor-facing post meta.
How do I preserve Classic Editor support?
Keep the PHP box and its secure save_post handler, use the same keys as the modern sidebar, and add __back_compat_meta_box => true so the box is not duplicated in the block editor.
Do I need a custom REST endpoint?
Usually not for ordinary post properties. Registered post meta already travels with the post entity. A custom endpoint is justified when the operation is not a post update—for example, remote validation, a long-running job, or access to a separate domain resource.
Will a PluginSidebar work with the iframed post editor?
Yes. The sidebar is part of the editor interface, not the content canvas. Problems arise when extension code directly reaches across the document boundary or loads styles into the wrong context.
Final takeaway
The safest WordPress 7.x meta box migration is not a rewrite of stored data. It is a separation of responsibilities.
Use register_post_meta() to define the field. Use PluginSidebar, PluginDocumentSettingPanel, or a meta-backed block to edit it. Use the Core entity store so the value participates in the post’s normal save lifecycle. Keep add_meta_box() only where a classic interface still needs it.
That architecture gives plugin authors something rare in a platform transition: a modern experience for new WordPress releases without abandoning the data and integrations that existing customers already depend on.
If you are preparing a commercial plugin for this transition, audit its editor integration before the support tickets arrive. Test against both WordPress 7.0’s conditional iframe behavior and WordPress 7.1’s enforced iframe, package the compiled assets, and make the release reversible. You can also browse WordPress development tools on WPBay or continue with more practical guides on the WPBay blog.
