WordPress register_post_meta(): Type-Safe Meta with REST API Exposure and Block Editor Support

WordPress has two systems for attaching metadata to objects: the low-level add_post_meta()/get_post_meta() family (and equivalents for users, terms, and comments), and the higher-level register_meta()/register_post_meta() API. The registration API, stabilised in WordPress 4.6, allows you to declare the data type, default value, sanitize callback, authorization callback, and REST API visibility for a meta key in one place. Without registration, meta is stored and retrieved as plain strings with no type enforcement, no automatic sanitization, and no REST exposure. Registering meta also makes it available to the block editor’s useEntityProp() hook, enables automatic schema generation for the REST API, and centralises all meta configuration in one discoverable location rather than scattered sanitize calls across multiple hooks.

Problem: A plugin stores three meta fields on the post post type: a reading time integer, a featured flag boolean, and a source URL string. They need to be accessible via the REST API, editable in the block editor sidebar, and always return the correct PHP type (not the string "1" instead of true).

Solution: Register all three with register_post_meta(), specifying 'type', 'single', 'default', 'sanitize_callback', and 'show_in_rest' => true. WordPress casts values to the declared type on retrieval.

<?php
add_action( 'init', function () {

    // ── Integer meta ──────────────────────────────────────────────────
    register_post_meta( 'post', '_reading_time_minutes', [
        'type'              => 'integer',
        'description'       => 'Estimated reading time in minutes',
        'single'            => true,
        'default'           => 0,
        'sanitize_callback' => 'absint',
        'auth_callback'     => fn() => current_user_can( 'edit_posts' ),
        'show_in_rest'      => true,
    ] );

    // ── Boolean meta ──────────────────────────────────────────────────
    register_post_meta( 'post', '_is_featured', [
        'type'              => 'boolean',
        'single'            => true,
        'default'           => false,
        'sanitize_callback' => fn( $v ) => (bool) $v,
        'auth_callback'     => fn() => current_user_can( 'edit_posts' ),
        'show_in_rest'      => true,
    ] );

    // ── String meta with validation ────────────────────────────────────
    register_post_meta( 'post', '_source_url', [
        'type'              => 'string',
        'single'            => true,
        'default'           => '',
        'sanitize_callback' => 'esc_url_raw',  // stores clean URL, rejects javascript:
        'auth_callback'     => fn() => current_user_can( 'edit_posts' ),
        'show_in_rest'      => [
            'schema' => [
                'type'   => 'string',
                'format' => 'uri',       // REST API validates as URI format
            ],
        ],
    ] );
} );

// ── Reading — always returns the declared PHP type ────────────────────
$minutes = get_post_meta( $post_id, '_reading_time_minutes', true ); // int (not string)
$featured = get_post_meta( $post_id, '_is_featured', true );          // bool (not "1")

// ── Writing — sanitize_callback runs automatically ────────────────────
update_post_meta( $post_id, '_reading_time_minutes', 5 );
update_post_meta( $post_id, '_is_featured', true );
update_post_meta( $post_id, '_source_url', 'https://example.com/source' );

// ── Block editor: read and update via useEntityProp ───────────────────
/*
import { useEntityProp } from '@wordpress/core-data';
const [ meta, setMeta ] = useEntityProp( 'postType', 'post', 'meta' );

// Read
const readingTime = meta['_reading_time_minutes']; // 5

// Write
setMeta({ ...meta, '_is_featured': true });
*/

// ── REST API: GET /wp-json/wp/v2/posts/123?_fields=id,meta
// Response: { "id": 123, "meta": { "_reading_time_minutes": 5, "_is_featured": false, "_source_url": "..." } }

// ── Register for ALL post types ────────────────────────────────────────
// Pass empty string as post_type to apply to all registered post types:
register_meta( 'post', '_global_flag', [
    'object_subtype'    => '',           // all post types
    'type'              => 'boolean',
    'single'            => true,
    'sanitize_callback' => fn( $v ) => (bool) $v,
    'show_in_rest'      => true,
] );

NOTE: register_post_meta() is a convenience wrapper for register_meta() that automatically sets 'object_subtype' to the given post type. The 'auth_callback' is checked when a meta value is written via the REST API — it is not called when you use update_post_meta() in PHP. Without an 'auth_callback', any authenticated REST API user can write to the meta field regardless of their role. Always provide an explicit 'auth_callback' that checks the appropriate capability for the post type being edited.