Data Layer and Editor Extensibility

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 block editor is a React application whose state lives in data stores. Plugins extend it by reading and writing those stores, by rendering components into named places of the interface (SlotFills), by filtering blocks, and, in PHP, by deciding which blocks and features the editor offers. This page is for plugin and block developers. It adds a "Book details" panel to the lp_book editor of the Lumen Press lumen-catalog plugin, editing the lp_isbn, lp_pages and lp_published_on meta registered in Metadata and Custom Fields, plus a stock sidebar backed by a custom store, a pre-publish check, a filter on the Quote block and editor curation in PHP.

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, @wordpress/scripts 37.0.0 and WP-CLI 2.12.0, and checked on 2026-10-28; WordPress 7.2, in beta and due in December 2026, changes nothing here so far. The script was built with wp-scripts build on Node.js 24 and passed wp-scripts lint-js. The PHP ran on a 7.1.3 site (PHP 8.4, MariaDB 11.8) with the users maria.editor (Editor, ID 2) and tom.author (Author, ID 3) and the books "The Lighthouse Ledger" (ID 4), "The Quiet Lighthouse" (ID 5, same series) and "The Glass Orchard" (ID 6, draft); the editor behaviour was checked by driving the post editor in headless Chromium. None of it needs the Gutenberg plugin. The toolchain is set up in Block Development Environment.

@wordpress/data

A store is a Redux store registered under a name: core/editor (the post being edited), core/block-editor (blocks, selection, settings), core (REST entities, from @wordpress/core-data), core/notices, core/preferences, core/edit-post and core/interface. Each exposes selectors (read), actions (change) and, for server data, resolvers: the first time a selector is called with given arguments, its resolver fetches the data, stores it, and every component that selected it renders again. The metadata selectors isResolving, hasStartedResolution and hasFinishedResolution tell "not loaded yet" from "empty".

  • useSelect( mapSelect, deps ) runs mapSelect with select, subscribes to the stores it reads and re-renders when the returned values change. Returning a new object or array on every call (a filter() inside mapSelect) re-renders on every store change; the development build warns "The useSelect hook returns different values when called with the same state and parameters".

  • useDispatch( store ) returns the store’s actions. Outside React, wp.data.select(), wp.data.dispatch() and wp.data.resolveSelect() (a promise that waits for the resolver) do the same. In the editor’s console, wp.data.select( 'core/editor' ).getEditedPostAttribute( 'meta' ) shows the unsaved meta and wp.data.select( 'core/editor' ).isPostSavingLocked() whether a plugin blocks saving.

core-data entities

@wordpress/core-data maps REST resources to entities named by kind and name: postType + lp_book is /wp/v2/lp_book (any type with show_in_rest), taxonomy + lp_series, root + user, root + site. The core store keeps each persisted record and the edits made to it, and saves them through REST.

The main APIs: getEntityRecord( kind, name, id ) and getEntityRecords( kind, name, query ) (selectors with resolvers; the query becomes REST arguments); editEntityRecord(), saveEditedEntityRecord(), saveEntityRecord() and deleteEntityRecord(); the hooks useEntityRecord() and useEntityRecords(), which return record/records, isResolving, hasResolved and status and skip the request with a fourth argument { enabled: false }; useEntityProp( kind, name, prop, id ), which returns [ value, setValue, fullValue ] for one property (without id, of the record the editor is editing); and useResourcePermissions() for canCreate, canUpdate and canDelete.

Two defaults matter. getEntityRecords asks for context=edit unless the query says otherwise: an Author listing books gets rest_forbidden_context (403), while context: 'view' returns the published books (both checked as tom.author). And edits to meta are merged with the record: changing one key makes the whole meta object the edit, so a save sends every registered meta key; Saving meta and protected keys shows what that breaks.

A custom store and apiFetch

Data that is not a REST entity goes in the plugin’s own store, made with createReduxStore() and register() (registerStore() is documented as deprecated). Lumen Press keeps stock counts from the lumen/v1 stock route of Roles and Capabilities API in lumen/catalog. The resolver loads a count the first time a component asks for it; updateStock is a thunk, a function that receives the store’s dispatch and select and may be async. Thunks replaced generator controls for asynchronous work.

// src/editor/store.js: the lumen/catalog store: stock counts read from and written to lumen/v1.
import apiFetch from '@wordpress/api-fetch';
import { createReduxStore, register } from '@wordpress/data';

const DEFAULT_STATE = { stock: {} };

const actions = {
	receiveStock( bookId, quantity ) {
		return { type: 'RECEIVE_STOCK', bookId, quantity };
	},
	// A thunk: it receives the store's dispatch and select, and may await.
	updateStock:
		( bookId, quantity ) =>
		async ( { dispatch } ) => {
			const response = await apiFetch( {
				path: `/lumen/v1/books/${ bookId }/stock`,
				method: 'PUT',
				data: { quantity },
			} );
			dispatch.receiveStock( bookId, response.quantity );
			return response;
		},
};

const selectors = {
	getStock( state, bookId ) {
		return state.stock[ bookId ];
	},
};

// Runs once per bookId, the first time getStock( bookId ) is selected.
const resolvers = {
	getStock:
		( bookId ) =>
		async ( { dispatch } ) => {
			const response = await apiFetch( {
				path: `/lumen/v1/books/${ bookId }/stock`,
			} );
			dispatch.receiveStock( bookId, response.quantity );
		},
};

function reducer( state = DEFAULT_STATE, action ) {
	if ( action.type === 'RECEIVE_STOCK' ) {
		return {
			...state,
			stock: { ...state.stock, [ action.bookId ]: action.quantity },
		};
	}
	return state;
}

export const store = createReduxStore( 'lumen/catalog', {
	reducer,
	actions,
	selectors,
	resolvers,
} );

register( store );

Store names are global and carry the plugin’s namespace. Reducers return new objects, or useSelect sees no change. A resolver runs once per argument list; fresh data needs invalidateResolution( 'getStock', [ id ] ) or an action that writes the value, as updateStock does.

@wordpress/api-fetch is the fetch wrapper the editor uses for every REST call. Given a path it prefixes the REST root, sends data as JSON, parses the response and rejects with the WordPress error (code, message, data.status). In wp-admin WordPress installs a nonce middleware that sends the wp_rest nonce as X-WP-Nonce, renewed by the Heartbeat API and refetched from the rest-nonce admin-ajax action when a request reports it invalid. A plain fetch() without the nonce is silently anonymous (user 0). parse: false returns the raw response.

The stock route accepts only PUT, so the plugin’s Editor class (below) adds a GET endpoint to the same route with the same permission callback. register_rest_route() merges endpoints into an existing route (its $override argument is false by default). Dispatched with wp eval-file, the route listed the methods PUT and GET; as maria.editor, GET returned {"book":4,"quantity":null}, PUT with 40 returned {"book":4,"quantity":40} and the next GET 40; as tom.author, GET returned the 403 lp_cannot_update_stock error of the shared check.

SlotFills and registerPlugin

A Slot is a named place in the interface; a Fill renders into it. Plugins call registerPlugin( name, { render, icon } ) from @wordpress/plugins and render fills exported by @wordpress/editor, each plugin inside its own error boundary. Every plugin renders on every editor screen; post-type checks are its job.

PluginDocumentSettingPanel adds a panel to the post tab of the settings sidebar (named after the type: "Books"); PluginSidebar adds a sidebar with a pinned toolbar button (PluginSidebarMoreMenuItem lists it in the Options menu); PluginPrePublishPanel and PluginPostPublishPanel add panels to the publish flow; PluginPostStatusInfo adds a row to the Summary panel; PluginMoreMenuItem, PluginBlockSettingsMenuItem and PluginPreviewMenuItem add menu entries. The fills moved from @wordpress/edit-post to @wordpress/editor in 6.6, which also made them work in the Site Editor. In 7.1.3 the wp.editPost.* names still render in the post editor but log "deprecated since version 6.6", pointing to wp.editor.*, and render nothing in the Site Editor.

Building and loading the script

The editor script is its own entry, built next to the blocks (the full package.json is in Block Development Environment):

npm install --save-exact @wordpress/api-fetch@8.0.0 @wordpress/components@43.0.0 @wordpress/compose@9.0.0 \
  @wordpress/core-data@9.0.0 @wordpress/data@11.0.0 @wordpress/editor@16.0.0 @wordpress/element@10.0.0 \
  @wordpress/hooks@5.0.0 @wordpress/notices@6.0.0 @wordpress/plugins@8.0.0
npx wp-scripts build src/editor/index.js --output-path=build/editor
npx wp-scripts lint-js src/editor

The six files below built to a 4.6 KB index.js. Its index.asset.php lists react-jsx-runtime, wp-api-fetch, wp-block-editor, wp-components, wp-compose, wp-core-data, wp-data, wp-editor, wp-element, wp-hooks, wp-i18n, wp-notices and wp-plugins plus a content hash as version: every import became a script handle, and React is reached through react-jsx-runtime instead of being bundled (see @wordpress/dependency-extraction-webpack-plugin).

Lumen\Catalog\Editor, booted from Plugin::boot(), enqueues it on enqueue_block_editor_assets, which loads scripts into the editor’s own document (content styles go through block.json or enqueue_block_assets, since the canvas is an iframe). The same class holds the stock GET endpoint, the meta fix explained below and the curation filters:

<?php
// src/Editor.php: editor script, stock read route and editor curation.

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

// Wires the "Book details" panel and curates the editor for books.
final class Editor {

	/** Called from Plugin::boot(). */
	public static function boot(): void {
		add_action( 'enqueue_block_editor_assets', array( self::class, 'enqueue' ) );
		add_action( 'rest_api_init', array( self::class, 'register_routes' ) );
		add_filter( 'allowed_block_types_all', array( self::class, 'allowed_blocks' ), 10, 2 );
		add_filter( 'block_editor_settings_all', array( self::class, 'editor_settings' ), 10, 2 );
		add_filter( 'register_block_type_args', array( self::class, 'quote_attribute' ), 10, 2 );
		add_action( 'save_post_lp_book', array( self::class, 'seed_sync_time' ) );
	}

	/** Loads build/editor/index.js in the post editor only, never in the Site Editor. */
	public static function enqueue(): void {
		$screen = get_current_screen();
		if ( ! $screen || 'post' !== $screen->base ) {
			return;
		}
		$asset = require plugin_dir_path( LUMEN_CATALOG_FILE ) . 'build/editor/index.asset.php';
		wp_enqueue_script(
			'lumen-catalog-editor',
			plugins_url( 'build/editor/index.js', LUMEN_CATALOG_FILE ),
			$asset['dependencies'],
			$asset['version'],
			array( 'in_footer' => true )
		);
		wp_set_script_translations( 'lumen-catalog-editor', 'lumen-catalog' );
	}

	/** Adds a GET endpoint to the existing PUT-only stock route; both share one permission check. */
	public static function register_routes(): void {
		register_rest_route(
			'lumen/v1',
			'/books/(?P<id>\d+)/stock',
			array(
				'methods'             => \WP_REST_Server::READABLE,
				'callback'            => array( self::class, 'read_stock' ),
				'permission_callback' => array( StockController::class, 'can_update' ),
				'args'                => array(
					'id' => array(
						'type'     => 'integer',
						'required' => true,
					),
				),
			)
		);
	}

	// Returns the current quantity (the lp_stock post meta), or null when the book has never been synced.
	public static function read_stock( \WP_REST_Request $request ) {
		$book_id = (int) $request['id'];
		$stored  = get_post_meta( $book_id, 'lp_stock', true );
		return rest_ensure_response(
			array(
				'book'     => $book_id,
				'quantity' => '' === $stored ? null : (int) $stored,
			)
		);
	}

	// The editor sends every registered meta key on save. A protected key the user may not write is only skipped when a stored value equals the one sent, so every book gets a stored 0 before REST updates its meta.
	public static function seed_sync_time( $post_id ): void {
		add_post_meta( $post_id, '_lp_stock_synced_at', 0, true );
	}

	// Authors and Contributors write news posts with a short list of blocks; Editors keep every block.
	public static function allowed_blocks( $allowed, $context ) {
		if ( empty( $context->post ) || 'post' !== $context->post->post_type || current_user_can( 'edit_others_posts' ) ) {
			return $allowed;
		}
		return array(
			'core/paragraph',
			'core/heading',
			'core/list',
			'core/list-item',
			'core/quote',
			'core/image',
			'core/embed',
			'lumen/book-card',
			'lumen/latest-books',
		);
	}

	// Fewer escape hatches for people who are not administrators.
	public static function editor_settings( $settings, $context ) {
		$settings['canLockBlocks'] = current_user_can( 'edit_theme_options' );
		if ( ! empty( $context->post ) && 'lp_book' === $context->post->post_type ) {
			$settings['codeEditingEnabled']           = current_user_can( 'manage_options' );
			$settings['enableOpenverseMediaCategory'] = false;
		}
		return $settings;
	}

	// Declares the lpBookId attribute that the editor filter adds to core/quote.
	public static function quote_attribute( $args, $block_type ) {
		if ( 'core/quote' === $block_type ) {
			$args['attributes']['lpBookId'] = array( 'type' => 'integer' );
		}
		return $args;
	}
}

wp_set_script_translations() makes the script’s strings translatable (see Internationalization). Reading index.asset.php, rather than hard-coding array( 'wp-plugins', 'wp-edit-post' ), keeps the dependencies and the cache-busting version right after every build.

The entry point renders the book UI only when the edited post is a book, and loads the block filters:

// src/editor/index.js: entry point of build/editor/index.js.
import { useSelect } from '@wordpress/data';
import { store as editorStore } from '@wordpress/editor';
import { registerPlugin } from '@wordpress/plugins';
import BookDetailsPanel from './book-details-panel';
import CatalogueSidebar from './catalogue-sidebar';
import PrePublishCheck from './prepublish-check';
import './quote-source';

function LumenBookPlugin() {
	const postType = useSelect(
		( select ) => select( editorStore ).getCurrentPostType(),
		[]
	);
	if ( postType !== 'lp_book' ) {
		return null;
	}
	return (
		<>
			<BookDetailsPanel />
			<PrePublishCheck />
			<CatalogueSidebar />
		</>
	);
}

registerPlugin( 'lumen-catalog-book', {
	render: LumenBookPlugin,
	icon: 'book-alt',
} );

The "Book details" panel

useEntityProp( 'postType', 'lp_book', 'meta' ) returns the edited meta of the open book and a setter that records an edit; the editor’s Save button sends it with the post. The ISBN field also takes a post-saving lock of core/editor: while a named lock is held, Save and Publish are disabled. The rule repeats the schema pattern of Meta::ISBN_PATTERN; the server stays the authority and rejects an invalid value with rest_invalid_pattern.

// src/editor/book-details-panel.js: "Book details" panel in the post tab of the settings sidebar.
import { TextControl } from '@wordpress/components';
import { useEntityProp } from '@wordpress/core-data';
import { useDispatch } from '@wordpress/data';
import {
	PluginDocumentSettingPanel,
	store as editorStore,
} from '@wordpress/editor';
import { useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';

// Same rule as Meta::ISBN_PATTERN in PHP: empty, or a hyphenated ISBN-13.
const ISBN = /^(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])?$/;

export default function BookDetailsPanel() {
	// [ value, setter ] for the edited (unsaved) meta object of the current book.
	const [ meta, setMeta ] = useEntityProp( 'postType', 'lp_book', 'meta' );
	const { lockPostSaving, unlockPostSaving } = useDispatch( editorStore );

	const isbn = meta?.lp_isbn ?? '';
	const isbnIsValid = ISBN.test( isbn );

	useEffect( () => {
		if ( isbnIsValid ) {
			unlockPostSaving( 'lumen-catalog-isbn' );
		} else {
			lockPostSaving( 'lumen-catalog-isbn' );
		}
		return () => unlockPostSaving( 'lumen-catalog-isbn' );
	}, [ isbnIsValid, lockPostSaving, unlockPostSaving ] );

	if ( ! meta ) {
		return null;
	}

	return (
		<PluginDocumentSettingPanel
			name="lumen-book-details"
			title={ __( 'Book details', 'lumen-catalog' ) }
		>
			<TextControl
				__next40pxDefaultSize
				label={ __( 'ISBN-13', 'lumen-catalog' ) }
				help={
					! isbnIsValid &&
					__(
						'Not a hyphenated ISBN-13: saving is disabled.',
						'lumen-catalog'
					)
				}
				value={ isbn }
				onChange={ ( value ) => setMeta( { ...meta, lp_isbn: value } ) }
			/>
			<TextControl
				__next40pxDefaultSize
				type="number"
				min={ 0 }
				max={ 5000 }
				label={ __( 'Pages', 'lumen-catalog' ) }
				value={ meta.lp_pages || '' }
				onChange={ ( value ) =>
					setMeta( { ...meta, lp_pages: parseInt( value, 10 ) || 0 } )
				}
			/>
		</PluginDocumentSettingPanel>
	);
}

As maria.editor on "The Lighthouse Ledger", typing 1-23456-789-X made isPostSavingLocked() return true and the Save button aria-disabled; entering 978-1-23456-789-7 and 312 pages released the lock, and Save stored both values (wp post meta list 4). lp_published_on takes a third TextControl with type="date", written the same way. The fields exist only because the keys are registered with show_in_rest and lp_book supports custom-fields; without either, meta lacks the key.

Saving meta and protected keys

The request the editor sends for that save carries more than the edited keys: {"meta":{"lp_isbn":…,"lp_pages":312,"lp_published_on":"","_lp_stock_synced_at":0,"footnotes":""}}. _lp_stock_synced_at is registered with show_in_rest but is protected (leading underscore, no auth_callback), so nobody may write it. REST skips a key whose value equals the stored one before checking the capability, but only when a row exists; for a book the stock sync never touched there is no row, the default 0 comes back, and the save fails with "Sorry, you are not allowed to edit the _lp_stock_synced_at custom field." (403). The error is collected after the other keys are written, so lp_pages is stored although the response is an error. Editor::seed_sync_time() stores 0 on save_post_lp_book, which runs before REST updates the meta. The same request as maria.editor on book 5, with the row deleted:

without seed_sync_time: 403 Sorry, you are not allowed to edit the _lp_stock_synced_at custom field. | lp_pages stored: '288'
with seed_sync_time: 200 ok | lp_pages stored: '288'

Keeping such a key out of /wp/v2, or an auth_callback for the editing roles, are the alternatives.

Pre-publish check and sidebar

src/editor/prepublish-check.js reads the same meta with useEntityProp() and renders <PluginPrePublishPanel title="Catalogue check" initialOpen={ missing.length > 0 }> with the missing fields; the panel joins the checklist that opens on the first click on Publish (see PluginPrePublishPanel). On the draft "The Glass Orchard" it read "Missing: ISBN-13, Pages". It only warns: blocking publication needs a saving lock, and users who turn off pre-publish checks in Preferences never see the panel.

The Catalogue sidebar reads the custom store; hasFinishedResolution() separates "loading" from a book never synced (null), and the update is reported with a snackbar from core/notices:

// src/editor/catalogue-sidebar.js: "Catalogue" sidebar: the stock count from the lumen/catalog store.
import { Button, PanelBody, Spinner } from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { PluginSidebar, store as editorStore } from '@wordpress/editor';
import { __, sprintf } from '@wordpress/i18n';
import { store as noticesStore } from '@wordpress/notices';
import { store as catalogStore } from './store';

export default function CatalogueSidebar() {
	const { postId, stock, loaded } = useSelect( ( select ) => {
		const id = select( editorStore ).getCurrentPostId();
		return {
			postId: id,
			stock: select( catalogStore ).getStock( id ),
			loaded: select( catalogStore ).hasFinishedResolution( 'getStock', [
				id,
			] ),
		};
	}, [] );
	const { updateStock } = useDispatch( catalogStore );
	const { createSuccessNotice, createErrorNotice } =
		useDispatch( noticesStore );

	const restock = () =>
		updateStock( postId, ( stock ?? 0 ) + 10 )
			.then( () =>
				createSuccessNotice( __( 'Stock updated.', 'lumen-catalog' ), {
					type: 'snackbar',
				} )
			)
			.catch( ( error ) => createErrorNotice( error.message ) );

	// translators: %d: copies in stock.
	const inStock = __( '%d copies in stock.', 'lumen-catalog' );

	return (
		<PluginSidebar
			name="lumen-catalogue"
			title={ __( 'Catalogue', 'lumen-catalog' ) }
		>
			<PanelBody title={ __( 'Stock', 'lumen-catalog' ) }>
				{ ! loaded && <Spinner /> }
				{ loaded && (
					<p>
						{ stock === null
							? __( 'Never synced.', 'lumen-catalog' )
							: sprintf( inStock, stock ) }
					</p>
				) }
				<Button
					variant="secondary"
					onClick={ restock }
					disabled={ ! loaded }
				>
					{ __( 'Add 10 copies', 'lumen-catalog' ) }
				</Button>
			</PanelBody>
		</PluginSidebar>
	);
}

The sidebar opens from a toolbar button with the plugin’s book-alt icon (area name lumen-catalog-book/lumen-catalogue). On "The Lighthouse Ledger" it showed "40 copies in stock."; Add 10 copies showed "50 copies in stock." and "Stock updated.", and the book’s lp_stock post meta read 50, with one new row in the stock history table.

Block filters

Block filters change blocks the plugin does not own. They are @wordpress/hooks filters added with addFilter( hookName, namespace, callback ) before blocks register (an editor script is early enough). blocks.registerBlockType changes a block type’s settings (its PHP counterparts are register_block_type_args and block_type_metadata); editor.BlockEdit wraps the edit component, usually to add InspectorControls or BlockControls; editor.BlockListBlock wraps the block in the canvas. blocks.getSaveContent.extraProps and blocks.getSaveElement change saved markup, which invalidates every block saved before the filter existed, so they are best avoided on core blocks.

Lumen Press news posts quote books, and the editors record which book a Quote cites. An attribute without a source lives in the block comment, so the saved HTML does not change and no existing quote turns invalid:

// src/editor/quote-source.js: block filters: an lpBookId attribute on core/quote and a control to set it.
import { InspectorControls } from '@wordpress/block-editor';
import { ComboboxControl, PanelBody } from '@wordpress/components';
import { createHigherOrderComponent } from '@wordpress/compose';
import { useEntityRecords } from '@wordpress/core-data';
import { addFilter } from '@wordpress/hooks';
import { __ } from '@wordpress/i18n';

addFilter(
	'blocks.registerBlockType',
	'lumen-catalog/quote-book-attribute',
	( settings, name ) =>
		name !== 'core/quote'
			? settings
			: {
					...settings,
					attributes: {
						...settings.attributes,
						lpBookId: { type: 'integer' },
					},
				}
);

const withQuotedBook = createHigherOrderComponent(
	( BlockEdit ) => ( props ) => {
		const isQuote = props.name === 'core/quote' && props.isSelected;
		const { records } = useEntityRecords(
			'postType',
			'lp_book',
			// Authors may not read lp_book in the default 'edit' context.
			{ context: 'view', per_page: 100, _fields: [ 'id', 'title' ] },
			{ enabled: isQuote }
		);
		return (
			<>
				<BlockEdit { ...props } />
				{ isQuote && (
					<InspectorControls>
						<PanelBody
							title={ __( 'Lumen Press', 'lumen-catalog' ) }
						>
							<ComboboxControl
								__next40pxDefaultSize
								label={ __( 'Quoted book', 'lumen-catalog' ) }
								value={ props.attributes.lpBookId ?? null }
								options={ ( records ?? [] ).map( ( book ) => ( {
									value: book.id,
									label: book.title.rendered,
								} ) ) }
								onChange={ ( id ) =>
									props.setAttributes( {
										lpBookId: id ? Number( id ) : undefined,
									} )
								}
							/>
						</PanelBody>
					</InspectorControls>
				) }
			</>
		);
	},
	'withQuotedBook'
);

addFilter( 'editor.BlockEdit', 'lumen-catalog/quote-book', withQuotedBook );

As tom.author, choosing "The Lighthouse Ledger" in the control produced <!-- wp:quote {"lpBookId":4} -->. Editor::quote_attribute() declares the attribute on the server too, so PHP treats it as a real attribute (the registry reports {"type":"integer"}) and a render_block_core/quote filter can link the book; without it, the server renderer does not know the attribute. The canvas is an iframe (always, since 7.1), so code in the wrapper that needs the document uses the element’s ownerDocument, not window.document.

Curating the editor

Tool Effect

allowed_block_types_all (5.8, replaces allowed_block_types)

true, false or a list of block names, per WP_Block_Editor_Context ($context->post, $context->name such as core/edit-post or core/edit-site).

block_editor_settings_all (5.8, replaces block_editor_settings)

Editor settings such as codeEditingEnabled, canLockBlocks, enableOpenverseMediaCategory, bodyPlaceholder, and responsiveEditingEnabled/blockStatesEditingEnabled for the responsive and state style controls.

template and template_lock of a post type

Initial blocks of new items; insert forbids adding or removing blocks, all also moving them, contentOnly hides design tools.

The lock attribute

"lock":{"move":true,"remove":true} on a block in templates, patterns or markup.

Others

theme.json settings (see theme.json Settings); unregisterBlockType(), unregisterBlockVariation(), unregisterBlockStyle(), unregisterFormatType() in domReady; remove_theme_support( 'core-block-patterns' ).

The Editor class applies three rules. Authors and Contributors get nine blocks in news posts, while users with edit_others_posts keep everything; books need no allow list, because their template_lock of insert (see Custom Post Types and Taxonomies) already removes the inserter. Locking and unlocking is reserved to edit_theme_options (by default anyone who can edit the post can unlock a block, which makes a lock a suggestion). On books, the Code editor is limited to manage_options and the Openverse media category is removed so covers come from the publisher’s library. get_block_editor_settings() for book 4 and get_allowed_block_types() for news post 7, per user, in wp eval-file (after wp_styles() and wp_scripts(), without which the iframe asset collector warns under WP-CLI):

admin         book: canLockBlocks=true codeEditingEnabled=true openverse=false | news blocks=all
maria.editor  book: canLockBlocks=false codeEditingEnabled=false openverse=false | news blocks=all
tom.author    book: canLockBlocks=false codeEditingEnabled=false openverse=false | news blocks=9
core/quote lpBookId: {"type":"integer"}

In the browser tom.author got the same nine names and canLockBlocks false; unregistered names are ignored. Curation shapes the interface only: blocks already in the content stay, and REST accepts other blocks from a user with the capability. The security boundary is the capabilities of Roles and Capabilities API.

Meta boxes in the block editor

Classic meta boxes (add_meta_box()) still render below the content, and the editor posts their form to post.php in a second request after each REST save. Outside React, they miss unsaved edits and saving locks; the Block Editor Handbook recommends registered meta with a panel or a block instead. During a migration, '__back_compat_meta_box' => true in the add_meta_box() arguments hides a box in the block editor (it stays in the classic one), and '__block_editor_compatible_meta_box' => false replaces it with an incompatibility message linking to the Classic Editor plugin.

Errors and pitfalls

Symptom Cause and fix

wp.editPost.PluginDocumentSettingPanel is deprecated since version 6.6

Import fills from @wordpress/editor.

"Sorry, you are not allowed to edit the _key custom field." on save

A protected or restricted meta key with no stored row is resent (meta edits are merged). Store a value first; other keys may already be written.

An Author’s list stays empty, 403 rest_forbidden_context

core-data uses context=edit; add context: 'view'.

"This block contains unexpected or invalid content" after adding a filter

The filter changed saved markup; keep extra data in comment attributes.

Corrections to older books and tutorials

The older books do not cover editor extensibility. The developer-oriented one adds fields with classic meta boxes and registers a post type without show_in_rest, which keeps it in the classic editor and rules out this page; its Writing setting for choosing the editor does not exist in core (the Classic Editor plugin adds the switch). Tutorials of the 5.x and early 6.x era need updating: fills come from @wordpress/editor, not @wordpress/edit-post (6.6); the useSelect/useDispatch hooks replace withSelect/withDispatch; createReduxStore(), register() and thunks replace registerStore() and generator controls; getEditedPostAttribute( 'meta' ) with editPost( { meta } ) still works and useEntityProp() does both; allowed_block_types_all and block_editor_settings_all (5.8, with a WP_Block_Editor_Context) replace the old filter names; and dependency arrays come from index.asset.php, while editor-wide CSS on admin_enqueue_scripts no longer reaches the iframed canvas.