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) |
|
|
|
Term (any taxonomy) |
|
|
|
User |
|
|
|
Comment |
|
|
|
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; withouttrueit returns an array of all values.add_post_meta()appends a row unless its$uniqueargument istrue;update_post_meta()updates every row of the key, or only the one equal to its$prev_valueargument. -
Missing is not empty. A key with no row and no registered default reads as
''(single) orarray().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 byget_post_meta()reading an existing row. -
Writes unslash.
update_post_meta()and friends runwp_unslash()on the value because they expect$_POST-style data; an already-unslashed value that may contain a backslash must go throughwp_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 |
|---|---|---|
|
4.6 |
|
|
6.7, 4.6 |
Human-readable name and description, exposed as the REST schema’s |
|
4.6 |
|
|
5.5 |
Returned by |
|
4.6 |
Runs on every add and update through |
|
4.6 |
Decides whether a user may add, edit or delete the key. Without it, unprotected keys are allowed and protected keys refused. |
|
4.7 |
|
|
6.4 |
Stores the value with each revision and restores it with the revision. Posts only, and the post type must support |
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 '';
}
}
Source: Code Reference: register_meta()
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
Source: WP-CLI: wp post meta
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:
-
The user must be able to edit the object:
edit_post_metamaps to whateveredit_postmaps to for that post. Since 6.1, checking one of these capabilities without an object ID raises a notice and denies. -
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 whatauth_callbackadds, its return value replaces it (keys registered without a subtype useauth_{$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}}
Source: WP-CLI: wp term meta, WP-CLI: wp user meta
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_postfires 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_bookfires only forlp_book, so revisions (typerevision) never reach it; a handler on the genericsave_postalso needswp_is_post_revision(). -
Capabilities.
edit_postfor the book, thenedit_post_metaper key, which runs theauth_callback: an Author’slp_isbnis ignored even if the browser sends it, and the field is renderedreadonlywithwp_readonly()(5.9+, which replacedreadonly()whenreadonlybecame 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' ) );
Source: WP-CLI: wp eval-file
$ 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 withget_field()and written withupdate_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 |
|
The key is protected, or its |
|
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 |
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. |
Related pages
Custom Post Types and Taxonomies, Security for Developers, REST API: Using It, Posts and Pages (revisions), WP-CLI and Importing, Exporting and Migrating (meta in WXR files). WooCommerce product data is covered in Products and Product Types.