Metadata and Custom Fields

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.

Metadata attaches extra fields to WordPress objects without changing the database schema: a book’s ISBN, a series' planned volumes, an author’s pen name. Every value is a row in a key-value table. Registering a key turns it into a typed field with a default, a sanitizer, a permission check and a JSON schema, which the REST API, the block editor and Block Bindings rely on.

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, WP-CLI 2.12.0 and WPCS 3.4.1, and checked on 2026-10-28. Every output below comes from a 7.1.3 site running lumen-catalog on PHP 8.4 and MariaDB 11.8. WordPress 7.2 (in beta, due in December 2026) brings no change to the metadata API so far.

The four kinds of metadata

Object Table Functions Registration and WP-CLI

Post (any post type)

wp_postmeta

add_post_meta(), get_post_meta(), update_post_meta(), delete_post_meta()

register_post_meta(), wp post meta

Term (any taxonomy)

wp_termmeta (4.4+)

add_term_meta(), get_term_meta(), update_term_meta(), delete_term_meta()

register_term_meta(), wp term meta

User

wp_usermeta

add_user_meta(), get_user_meta(), update_user_meta(), delete_user_meta()

register_meta( 'user', ... ), wp user meta

Comment

wp_commentmeta

add_comment_meta(), get_comment_meta(), update_comment_meta(), delete_comment_meta()

register_meta( 'comment', ... ), wp comment meta

All of them wrap add_metadata() and its siblings, which take the object type first (Multisite adds wp_blogmeta). Storage and query cost are on Database Schema. Surprising behaviours:

  • One key, many rows. get_post_meta( $id, 'lp_isbn', true ) returns the first value; without true it returns an array of all values. add_post_meta() appends a row unless its $unique argument is true; update_post_meta() updates every row of the key, or only the one equal to its $prev_value argument.

  • Missing is not empty. A key with no row and no registered default reads as '' (single) or array(). metadata_exists() tells "never set" from "set to an empty string".

  • Everything is text. Values are longtext: integers come back as numeric strings, booleans as '1' or '', and arrays and objects are PHP-serialized. Registered types are applied by the REST API and to defaults, not by get_post_meta() reading an existing row.

  • Writes unslash. update_post_meta() and friends run wp_unslash() on the value because they expect $_POST-style data; an already-unslashed value that may contain a backslash must go through wp_slash().

  • No permission checks. The API functions never check capabilities. The REST API, the edit screen and the Custom Fields panel do, through meta capabilities (below); any other code must check for itself.

Registering meta

register_meta( $object_type, $meta_key, $args ) records a key in the global registry; register_post_meta() and register_term_meta() also set the post type or taxonomy as object_subtype. Register on init, for a specific subtype whenever there is one: a key registered without a subtype applies to every post type or taxonomy that does not register its own.

Argument Since Meaning

type

4.6

string (default), boolean, integer, number, array or object (the last two since 5.3). Used by REST to cast and validate.

label, description

6.7, 4.6

Human-readable name and description, exposed as the REST schema’s title and description; the Block Bindings UI also shows the label.

single

4.6

true for one value per object; false (default) exposes an array in REST.

default

5.5

Returned by get_metadata() and REST when no row exists. Must pass the schema, or the key is not registered and a "doing it wrong" notice is raised.

sanitize_callback

4.6

Runs on every add and update through sanitize_meta(), whoever the caller is: REST, a form, WP-CLI or plugin code.

auth_callback

4.6

Decides whether a user may add, edit or delete the key. Without it, unprotected keys are allowed and protected keys refused.

show_in_rest

4.7

true, or an array with schema (merged into the field’s JSON schema) and prepare_callback. Needed for REST, the editor’s data store and Block Bindings. An array type needs show_in_rest.schema.items.

revisions_enabled

6.4

Stores the value with each revision and restores it with the revision. Posts only, and the post type must support revisions.

REST also requires the post type to support custom-fields; without it, the meta field is missing even for registered keys. Core registers one such key itself: footnotes (6.3, for the Footnotes block), which is why it appears in the outputs below.

The Lumen Press meta

lumen-catalog registers three book fields, a protected field written by its stock-sync cron job, a field on the lp_series taxonomy and a user field. Only Editors and above may set the ISBN; anyone who can edit a book may change its page count and date. The class lives in src/Meta.php, is autoloaded by Composer (see Plugin Fundamentals), and Plugin::boot() hooks it with add_action( 'init', array( Meta::class, 'register' ) ), next to the post type.

<?php
// src/Meta.php: registered metadata of books, series and authors.

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Registers every meta key the plugin owns. The patterns accept '' because the default must pass the schema.
 */
final class Meta {

	const ISBN_PATTERN = '^(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])?$';
	const DATE_PATTERN = '^([0-9]{4}-[0-9]{2}-[0-9]{2})?$';

	/** Hooked to init. */
	public static function register(): void {
		register_post_meta(
			'lp_book',
			'lp_isbn',
			array(
				'type'              => 'string',
				'label'             => __( 'ISBN-13', 'lumen-catalog' ),
				'description'       => __( 'Hyphenated ISBN-13 of this edition.', 'lumen-catalog' ),
				'single'            => true,
				'default'           => '',
				'sanitize_callback' => array( self::class, 'sanitize_isbn' ),
				'auth_callback'     => array( self::class, 'can_edit_isbn' ),
				'revisions_enabled' => true,
				'show_in_rest'      => array(
					'schema' => array(
						'pattern'   => self::ISBN_PATTERN,
						'maxLength' => 17,
					),
				),
			)
		);

		register_post_meta(
			'lp_book',
			'lp_pages',
			array(
				'type'              => 'integer',
				'label'             => __( 'Pages', 'lumen-catalog' ),
				'single'            => true,
				'default'           => 0,
				'sanitize_callback' => 'absint',
				'auth_callback'     => array( self::class, 'can_edit_book' ),
				'show_in_rest'      => array(
					'schema' => array(
						'minimum' => 0,
						'maximum' => 5000,
					),
				),
			)
		);

		register_post_meta(
			'lp_book',
			'lp_published_on',
			array(
				'type'              => 'string',
				'label'             => __( 'Publication date', 'lumen-catalog' ),
				'single'            => true,
				'default'           => '',
				'sanitize_callback' => array( self::class, 'sanitize_date' ),
				'auth_callback'     => array( self::class, 'can_edit_book' ),
				'show_in_rest'      => array(
					'schema' => array( 'pattern' => self::DATE_PATTERN ),
				),
			)
		);

		// Written by the stock-sync cron job only: readable in REST, never writable.
		register_post_meta(
			'lp_book',
			'_lp_stock_synced_at',
			array(
				'type'         => 'integer',
				'single'       => true,
				'show_in_rest' => true,
			)
		);

		register_term_meta(
			'lp_series',
			'lp_series_volumes',
			array(
				'type'              => 'integer',
				'label'             => __( 'Planned volumes', 'lumen-catalog' ),
				'single'            => true,
				'default'           => 0,
				'sanitize_callback' => 'absint',
				'show_in_rest'      => true,
			)
		);

		register_meta(
			'user',
			'lp_pen_name',
			array(
				'type'              => 'string',
				'label'             => __( 'Pen name', 'lumen-catalog' ),
				'single'            => true,
				'default'           => '',
				'sanitize_callback' => 'sanitize_text_field',
				'show_in_rest'      => true,
			)
		);
	}

	/** Only users who may edit other people's books (Editors, Catalogue Managers and up) may set an ISBN. */
	public static function can_edit_isbn( $allowed, $meta_key, $post_id, $user_id ): bool {
		return user_can( $user_id, 'edit_others_lp_books' ) && user_can( $user_id, 'edit_post', $post_id );
	}

	/** Anyone who may edit the book may edit its page count and date. */
	public static function can_edit_book( $allowed, $meta_key, $post_id, $user_id ): bool {
		return user_can( $user_id, 'edit_post', $post_id );
	}

	/** Keeps a value only when it is a hyphenated ISBN-13; receives the unslashed value. */
	public static function sanitize_isbn( $value ): string {
		$value = trim( sanitize_text_field( (string) $value ) );

		return preg_match( '/' . self::ISBN_PATTERN . '/', $value ) ? $value : '';
	}

	/** Keeps a value only when it is a real calendar date. */
	public static function sanitize_date( $value ): string {
		$parts = explode( '-', trim( (string) $value ) );

		if ( 3 === count( $parts ) && checkdate( (int) $parts[1], (int) $parts[2], (int) $parts[0] ) ) {
			return sprintf( '%04d-%02d-%02d', $parts[0], $parts[1], $parts[2] );
		}

		return '';
	}
}

The patterns accept the empty string because the default is validated against the schema: with a stricter pattern the key silently stays unregistered. revisions_enabled works only because lp_book supports revisions.

What registration does and does not do

WP-CLI runs without a logged-in user and calls update_post_meta() directly, so the sanitize_callback runs but no auth_callback is involved. Before any row exists, reads return the default (and metadata_exists() returns false):

$ wp post meta get 4 lp_pages
0
$ wp post meta update 4 lp_isbn " 978-0-00-000001-9 "
Success: Updated custom field 'lp_isbn'.
$ wp post meta update 4 lp_pages 312
Success: Updated custom field 'lp_pages'.
$ wp post meta update 4 lp_published_on 2026-3-14
Success: Updated custom field 'lp_published_on'.
$ wp post meta update 4 _lp_stock_synced_at 1793145600
Success: Updated custom field '_lp_stock_synced_at'.
$ wp post meta list 4 --format=table
post_id	meta_key	meta_value
4	lp_isbn	978-0-00-000001-9
4	lp_pages	312
4	lp_published_on	2026-03-14
4	_lp_stock_synced_at	1793145600

The sanitizers trimmed the ISBN and normalized the date. Outside REST an invalid value is not rejected, only sanitized: wp post meta update 4 lp_isbn "not-an-isbn" reports success and stores an empty string. The JSON schema (pattern, minimum, maximum, maxLength) is enforced only by the REST API, so other write paths (the meta box below, wp lumen import) validate input themselves, which is why the ISBN sanitizer repeats the pattern. Registration never changes storage either: the rows are the same text rows an unregistered key produces.

Permissions: auth_callback and the meta capabilities

The REST API, the edit screen and the Custom Fields panel check current_user_can( 'edit_post_meta', $post_id, $meta_key ) (or add_post_meta, delete_post_meta, and the same for term, user and comment). map_meta_cap() resolves these meta capabilities in two steps:

  1. The user must be able to edit the object: edit_post_meta maps to whatever edit_post maps to for that post. Since 6.1, checking one of these capabilities without an object ID raises a notice and denies.

  2. The key must be allowed. The starting value is "not protected"; when the filter auth_{$object_type}_meta_{$meta_key}_for_{$object_subtype} exists, which is what auth_callback adds, its return value replaces it (keys registered without a subtype use auth_{$object_type}_meta_{$meta_key}).

The callback receives $allowed, $meta_key, $object_id, $user_id, $cap and $caps; its result is combined with step 1, so what matters in can_edit_isbn() is the extra edit_others_lp_books (the book capability from Custom Post Types and Taxonomies). Returning true for a protected key opens it to REST writes. See also Roles and Capabilities API.

Protected meta keys

A key whose first character is an underscore is protected: is_protected_meta() returns true, the Custom Fields panel hides it, Block Bindings refuse it, and REST writes are denied unless an auth_callback allows them. Core keeps its own bookkeeping in protected keys, such as _thumbnail_id, _edit_lock, _wp_page_template and _wp_attached_file. The is_protected_meta filter can protect other keys or unprotect one.

Protection is about editing, not secrecy. _lp_stock_synced_at has show_in_rest, so it is in every REST response for a book, anonymous ones included; it only cannot be changed there. Data that must stay private (internal notes, supplier prices, personal data) stays out of show_in_rest, or is exposed through a REST field with its own permission check (see REST API: Extending It).

Meta in the REST API

Keys with show_in_rest appear in the meta object of the posts, terms, users and comments endpoints. The site is the one from Local Development Environments, with maria.editor (Editor, ID 2) and tom.author (Author, ID 3, the author of book 4). Application passwords require HTTPS unless WP_ENVIRONMENT_TYPE is local:

SITE=https://lumenpress.test
MARIA_APP_PASSWORD=$(wp user application-password create maria.editor "meta-demo" --porcelain)
TOM_APP_PASSWORD=$(wp user application-password create tom.author "meta-demo" --porcelain)

An anonymous read shows every registered key, the protected one included, with its registered type. The Editor may change the ISBN and page count in one request; the Author of the same book may change the page count but not the ISBN:

$ curl -s "$SITE/wp-json/wp/v2/lp_book/4?_fields=id,meta"
{"id":4,"meta":{"lp_isbn":"978-0-00-000001-9","lp_pages":312,"lp_published_on":"2026-03-14",
"_lp_stock_synced_at":1793145600,"footnotes":""}}

$ curl -s -u "maria.editor:$MARIA_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
    -d '{"meta":{"lp_isbn":"978-0-00-000003-3","lp_pages":320}}' "$SITE/wp-json/wp/v2/lp_book/4?_fields=id,meta"
{"id":4,"meta":{"lp_isbn":"978-0-00-000003-3","lp_pages":320,"lp_published_on":"2026-03-14",
"_lp_stock_synced_at":1793145600,"footnotes":""}}

$ curl -s -u "tom.author:$TOM_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
    -d '{"meta":{"lp_isbn":"978-0-00-000004-0"}}' "$SITE/wp-json/wp/v2/lp_book/4"
{"code":"rest_cannot_update","message":"Sorry, you are not allowed to edit the lp_isbn custom field.",
"data":{"key":"lp_isbn","status":403}}

$ curl -s -u "tom.author:$TOM_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
    -d '{"meta":{"lp_pages":324}}' "$SITE/wp-json/wp/v2/lp_book/4?_fields=id,meta"
{"id":4,"meta":{"lp_isbn":"978-0-00-000003-3","lp_pages":324,"lp_published_on":"2026-03-14",
"_lp_stock_synced_at":1793145600,"footnotes":""}}

Schema violations are rejected with 400 before anything is stored, the protected key is refused even to the Editor, null deletes a value (reads return the default), and an unauthenticated write fails the post permission check before meta is looked at (the same requests as above, as maria.editor, except the last):

-d '{"meta":{"lp_isbn":"0-00-000001-X"}}'
{"code":"rest_invalid_pattern","message":"meta.lp_isbn does not match pattern
^(97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9])?$.","data":{"status":400}}
-d '{"meta":{"lp_pages":9000}}'
{"code":"rest_out_of_bounds","message":"meta.lp_pages must be between 0 (inclusive) and 5000 (inclusive)",
"data":{"status":400}}
-d '{"meta":{"_lp_stock_synced_at":1}}'
{"code":"rest_cannot_update","message":"Sorry, you are not allowed to edit the _lp_stock_synced_at custom field.",
"data":{"key":"_lp_stock_synced_at","status":403}}
-d '{"meta":{"lp_published_on":null}}'
{"id":4,"meta":{"lp_isbn":"978-0-00-000003-3","lp_pages":324,"lp_published_on":"",
"_lp_stock_synced_at":1793145600,"footnotes":""}}
-d '{"meta":{"lp_pages":1}}'   (no credentials)
{"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this post.","data":{"status":401}}

Three more rules matter in practice. A value equal to the stored one is skipped before the capability check, so the block editor, which resends all meta on every save, does not fail for an Author who cannot edit lp_isbn. A request that cannot authenticate (a wrong application password, or a cookie request without the wp_rest nonce) continues as anonymous and fails on the permission check, as the last call shows. And because lp_isbn has revisions_enabled, every ISBN change creates a revision carrying the value: GET /wp/v2/lp_book/4/revisions returns it in each revision’s meta, and restoring a revision restores the ISBN while the other keys, not revisioned, are left alone.

Term, user and comment meta

The same registration, permission and REST rules apply to the other object types:

$ wp term create lp_series "Harbour Lights" --porcelain
2
$ wp term meta update 2 lp_series_volumes 3
Success: Updated custom field 'lp_series_volumes'.
$ curl -s "$SITE/wp-json/wp/v2/lp_series/2?_fields=id,name,meta"
{"id":2,"name":"Harbour Lights","meta":{"lp_series_volumes":3}}

$ wp user meta update 3 lp_pen_name "T. A. Marlowe"
Success: Updated custom field 'lp_pen_name'.
$ curl -s "$SITE/wp-json/wp/v2/users/3?_fields=id,name,meta"
{"id":3,"name":"tom.author","meta":{"lp_pen_name":"T. A. Marlowe"}}
$ curl -s -u "tom.author:$TOM_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
    -d '{"meta":{"lp_pen_name":"Tom Ash"}}' "$SITE/wp-json/wp/v2/users/me?_fields=id,meta"
{"id":3,"meta":{"persisted_preferences":[],"lp_pen_name":"Tom Ash"}}
$ curl -s -u "tom.author:$TOM_APP_PASSWORD" -X POST -H 'Content-Type: application/json' \
    -d '{"meta":{"lp_pen_name":"X"}}' "$SITE/wp-json/wp/v2/users/2?_fields=id,meta"
{"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this user.","data":{"status":403}}

The anonymous user read works because the users endpoint publicly lists authors of published REST-enabled posts, with their show_in_rest meta. Register user meta for REST only when it is meant to be public, and never put personal data in such a key (see Privacy and Personal Data); persisted_preferences is core’s own user meta for editor preferences. Comments have no subtypes: a reader rating is stored with wp comment meta add 2 lp_rating 5 (or add_comment_meta()) and registered with register_meta( 'comment', 'lp_rating', ... ).

Classic meta boxes

A meta box is a PHP-rendered panel on the post edit screen; in the block editor it appears below the content (normal, advanced) or in the sidebar (side). lumen-catalog keeps one for the classic editor and for sites without the sidebar panel from Data Layer and Editor Extensibility; Plugin::boot() calls MetaBox::register() only when is_admin() is true.

<?php
// src/MetaBox.php: the classic "Book details" meta box.

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/** Renders and saves the book meta in a meta box (classic editor and block editor alike). */
final class MetaBox {

	const NONCE_ACTION = 'lumen_catalog_save_book_details';
	const NONCE_FIELD  = 'lumen_catalog_book_details_nonce';
	const FIELDS       = array( 'lp_isbn', 'lp_pages', 'lp_published_on' );

	/** Called from Plugin::boot() on admin requests only. */
	public static function register(): void {
		add_action( 'add_meta_boxes_lp_book', array( self::class, 'add' ) );
		add_action( 'save_post_lp_book', array( self::class, 'save' ), 10, 2 );
	}

	/** Adds the box to the sidebar of the book edit screen. */
	public static function add(): void {
		add_meta_box(
			'lumen-catalog-book-details',
			__( 'Book details', 'lumen-catalog' ),
			array( self::class, 'render' ),
			'lp_book',
			'side',
			'default'
		);
	}

	/** Prints the fields; every value is escaped late, on output. */
	public static function render( \WP_Post $post ): void {
		wp_nonce_field( self::NONCE_ACTION, self::NONCE_FIELD );

		$isbn      = get_post_meta( $post->ID, 'lp_isbn', true );
		$pages     = (int) get_post_meta( $post->ID, 'lp_pages', true );
		$published = get_post_meta( $post->ID, 'lp_published_on', true );
		$can_isbn  = current_user_can( 'edit_post_meta', $post->ID, 'lp_isbn' );
		?>
		<p><label for="lp_isbn"><?php esc_html_e( 'ISBN-13', 'lumen-catalog' ); ?></label>
			<input type="text" id="lp_isbn" name="lp_isbn" class="widefat" value="<?php echo esc_attr( $isbn ); ?>"
				pattern="97[89]-[0-9]{1,5}-[0-9]{1,7}-[0-9]{1,7}-[0-9]" <?php wp_readonly( $can_isbn, false ); ?>></p>
		<p><label for="lp_pages"><?php esc_html_e( 'Pages', 'lumen-catalog' ); ?></label>
			<input type="number" id="lp_pages" name="lp_pages" class="widefat" min="0" max="5000"
				value="<?php echo esc_attr( (string) $pages ); ?>"></p>
		<p><label for="lp_published_on"><?php esc_html_e( 'Publication date', 'lumen-catalog' ); ?></label>
			<input type="date" id="lp_published_on" name="lp_published_on" class="widefat"
				value="<?php echo esc_attr( $published ); ?>"></p>
		<?php
	}

	/** Saves the submitted fields after the nonce, autosave and capability checks. */
	public static function save( int $post_id, \WP_Post $post ): void {
		$nonce = isset( $_POST[ self::NONCE_FIELD ] )
			? sanitize_text_field( wp_unslash( $_POST[ self::NONCE_FIELD ] ) )
			: '';

		if ( ! wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
			return; // Not our form: a REST save, a cron job, an import or a forged request.
		}

		if ( ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) || wp_is_post_autosave( $post_id ) ) {
			return;
		}

		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		foreach ( self::FIELDS as $key ) {
			if ( ! isset( $_POST[ $key ] ) || ! current_user_can( 'edit_post_meta', $post_id, $key ) ) {
				continue;
			}

			// The registered sanitize_callback turns invalid input into the empty value.
			$raw   = sanitize_text_field( wp_unslash( $_POST[ $key ] ) );
			$clean = sanitize_meta( $key, $raw, 'post', $post->post_type );

			if ( '' === $clean || 0 === $clean ) {
				delete_post_meta( $post_id, $key ); // Reads fall back to the registered default.
			} else {
				update_post_meta( $post_id, $key, wp_slash( $clean ) ); // The function unslashes.
			}
		}
	}
}

Each check in the save handler has a reason:

  • Nonce. save_post fires for every save (REST, autosave, cron, importers, WP-CLI); the nonce proves the request came from this form for this user. It is not authorization, so capability checks follow.

  • Autosave. Autosaves must not overwrite the real values. save_post_lp_book fires only for lp_book, so revisions (type revision) never reach it; a handler on the generic save_post also needs wp_is_post_revision().

  • Capabilities. edit_post for the book, then edit_post_meta per key, which runs the auth_callback: an Author’s lp_isbn is ignored even if the browser sends it, and the field is rendered readonly with wp_readonly() (5.9+, which replaced readonly() when readonly became a PHP 8.1 keyword).

  • Sanitize, store, escape. Values are unslashed, sanitized and slashed again for update_post_meta(); an empty or invalid value deletes the row. render() escapes every value late, even values the plugin saved itself.

Two add_meta_box() callback arguments matter only in the block editor: __block_editor_compatible_meta_box set to false replaces a box known to break with a notice, and __back_compat_meta_box set to true hides a box that a block or panel has replaced, while keeping it in the classic editor.

The block editor saves the post through REST first, then submits the meta box areas as a separate form POST to post.php?post=4&action=edit&meta-box-loader=1, which runs save_post again with the box fields in $_POST (without an extra revision). A key edited both in a sidebar panel and in a meta box therefore ends with the meta box value, so each key should have one editing UI. Scripts that hook the classic form’s submit event, the classic title and content fields or TinyMCE do not work there. Before 7.1, a meta box also kept the post editor out of the iframe; since 7.1 the editor is always iframed and meta boxes stay outside the canvas.

A simulated form POST tests the handler without a browser: it fills $_POST as post.php would, with or without a fresh nonce, and saves the book as different users. Run it with wp eval-file metabox-save.php:

<?php
// metabox-save.php: simulates the meta box form POST that post.php receives.

use Lumen\Catalog\MetaBox;

MetaBox::register(); // Plugin::boot() does this only when is_admin() is true.

function lumen_demo_submit( int $user_id, array $fields, bool $with_nonce = true ): void {
	$book = 4;
	wp_set_current_user( $user_id );
	$_POST = wp_slash( $fields ); // Superglobals arrive slashed.
	if ( $with_nonce ) {
		$_POST[ MetaBox::NONCE_FIELD ] = wp_create_nonce( MetaBox::NONCE_ACTION );
	}
	wp_update_post( array( 'ID' => $book ) ); // Fires save_post_lp_book.
	$meta = get_post_meta( $book ); // All keys of the book, each as an array of values.
	printf( "%-14s %-7s isbn=%s pages=%s published=%s\n", wp_get_current_user()->user_login, $with_nonce ? 'nonce' : 'none',
		$meta['lp_isbn'][0] ?? '', $meta['lp_pages'][0] ?? '0', $meta['lp_published_on'][0] ?? '' );
}

lumen_demo_submit( 2, array( 'lp_isbn' => '978-0-00-000005-7', 'lp_pages' => '330', 'lp_published_on' => '2026-03-14' ) );
lumen_demo_submit( 2, array( 'lp_isbn' => '978-0-00-000009-5' ), false );
lumen_demo_submit( 3, array( 'lp_isbn' => '978-0-00-000009-5', 'lp_pages' => '336' ) );
lumen_demo_submit( 2, array( 'lp_isbn' => 'ISBN 978', 'lp_published_on' => '2026-02-30' ) );
$ wp eval-file metabox-save.php
maria.editor   nonce   isbn=978-0-00-000005-7 pages=330 published=2026-03-14
maria.editor   none    isbn=978-0-00-000005-7 pages=330 published=2026-03-14
tom.author     nonce   isbn=978-0-00-000005-7 pages=336 published=2026-03-14
maria.editor   nonce   isbn= pages=336 published=

The Editor’s save is stored, the same request without a nonce is ignored, the Author’s ISBN is skipped while the page count is saved, and an invalid ISBN and an impossible date (30 February) delete the rows.

Editing meta in the block editor and binding it to blocks

In the block editor, meta with show_in_rest is part of the post entity in the core data store. A PluginDocumentSettingPanel (from @wordpress/editor) edits it with useEntityProp( 'postType', 'lp_book', 'meta' ) and saves it in the post’s REST request, under the same auth_callback; the lumen-catalog panel is on Data Layer and Editor Extensibility.

Block Bindings (6.5+) connect core block attributes to data without a custom block: a Paragraph bound to the core/post-meta source with the key lp_isbn shows each book’s ISBN, and since 6.7 the value can be edited in place. The source reads only keys registered with show_in_rest and not protected, on posts the visitor may read. Bindings for book meta, including a custom source, are on Block Bindings API.

The classic Custom Fields panel

The Custom Fields panel is the generic key-value editor that predates registered meta. It lists every unprotected key of the post, registered or not, as free text, and offers earlier keys in a drop-down. In the block editor it is off by default: Options menu (three dots), Preferences, General, turn on Custom fields under Advanced and reload the page. It exists only for post types that support custom-fields. It is handy for inspection, but a poor editor for structured data (plain strings, no validation beyond the sanitizer) and it submits after the REST save like a meta box, so sites that edit books through a dedicated panel or box keep it off.

Field plugins: Advanced Custom Fields and alternatives

Field plugins add a no-code interface for defining field groups, rich field types (relationships, repeaters, galleries, flexible layouts), location rules and options pages, and store values as ordinary metadata:

  • Advanced Custom Fields (ACF), free and PRO: field groups are defined in the admin, in JSON files or in PHP with acf_add_local_field_group(); values are read with get_field() and written with update_field() by field name or key. PRO adds blocks rendered by PHP templates.

  • Secure Custom Fields (SCF), a fork of ACF that took over its WordPress.org listing in October 2024 and keeps the same functions.

  • Meta Box and Pods (which also manages post types and tables).

The trade-off: faster building and a friendlier UI, against a data model owned by the plugin and a migration if it is dropped. A distributed plugin like lumen-catalog registers its own meta; a site-specific build may reasonably use a field plugin, checking that fields reach REST when a headless front end or Block Bindings need them.

Where older books are outdated

Older books teach meta boxes in a theme’s functions.php; content fields belong in a plugin, or a theme switch leaves the data uneditable. Pro WordPress registers post types without 'show_in_rest' => true, so those posts open in the classic editor and their meta never reaches REST, the editor’s data store or Block Bindings. Book-era meta box examples typically check a nonce or a capability but not both, skip the autosave check and store $_POST values without unslashing and sanitizing. None of the three books covers register_post_meta() arguments, REST schemas, auth_callback, revisions_enabled (6.4) or Block Bindings (6.5+), and the Custom Fields panel they show is now hidden in the editor’s preferences (full list: Where Older Books and Tutorials Are Outdated).

Common errors and pitfalls

Symptom Cause and fix

A registered key is missing from the REST response

The post type lacks custom-fields support, show_in_rest is missing, the key was registered for another subtype, or registration failed because default did not match the schema. Check with registered_meta_key_exists( 'post', 'lp_isbn', 'lp_book' ).

rest_cannot_update "Sorry, you are not allowed to edit the …​ custom field"

The key is protected, or its auth_callback returned false for that user.

rest_invalid_stored_value when sending null

The stored value does not match the schema (say, imported before the pattern existed). Fix it with WP-CLI.

Meta box values are not saved

Wrong nonce action or field name, a handler on the wrong post type, or edit_post_meta denied.

Values from a sidebar panel are overwritten on save

A meta box or the Custom Fields panel edits the same key and is submitted after the REST save. Keep one UI per key.