Skip to content
wp-skills
Register
tools/meta-box-generator

WordPress MetaBox Generator

WordPress MetaBox Generator is a tool that allows WordPress users to easily create custom meta boxes.

The heading on the box, and what the Screen Options checkbox is labelled.

Printed above the fields inside the box. Optional.

Your theme or plugin's own domain, for the labels here and on each field.

Where the box sits. In the block editor 'advanced' and 'normal' both land below the content; 'side' is the sidebar.

Ordering within the context. It is a hint — a user dragging boxes around overrides it.

Adds register_post_meta() on init. Without it the box writes values WordPress knows nothing about: no declared type, no sanitizer for writers outside this form, and nothing in the REST API or the block editor.

Post Types

Comma separated keys, added to whatever is ticked above.

Fields1
  • #1

    Shown beside the input, and what the editor reads to know what to type.

    Decides both the input drawn and the sanitising applied when the value is saved.

    The meta key. A leading underscore hides it from the Custom Fields panel; without one the editor can change it there too.

class-wp-skills-metabox-my-custom-metabox.php
<?php
/**
 * Meta box: My Custom MetaBox
 *
 * Generated by wp-skills.com
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

class WP_Skills_MetaBox_My_Custom_MetaBox {

	/**
	 * Both halves of the nonce come from here, so the field that is written and
	 * the key that is verified can never drift apart.
	 */
	const NONCE_ACTION = 'my_custom_metabox_meta_box';
	const NONCE_NAME   = 'my_custom_metabox_meta_box_nonce';

	private $screens = array(
		'post',
		'page',
	);

	private $meta_fields = array(
		array(
			'label' => 'My Custom Field',
			'id' => 'my_custom_field',
			'type' => 'text',
			'default' => '',
		)
	);

	public function __construct() {
		add_action( 'add_meta_boxes', array( $this, 'add_meta_boxes' ) );
		add_action( 'save_post', array( $this, 'save_fields' ), 10, 2 );
		add_action( 'init', array( $this, 'register_meta_keys' ) );
	}

	public function add_meta_boxes() {
		foreach ( $this->screens as $screen ) {
			add_meta_box(
				'my_custom_metabox',
				__( 'My Custom MetaBox', 'text-domain' ),
				array( $this, 'render' ),
				$screen,
				'normal',
				'default'
			);
		}
	}

	public function render( $post ) {
		wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
		printf( '<p>%s</p>', esc_html__( 'Lorem ipsum dolor sit amet.', 'text-domain' ) );

		echo '<table class="form-table"><tbody>';
		foreach ( $this->meta_fields as $meta_field ) {
			$this->render_row( $post, $meta_field );
		}
		echo '</tbody></table>';
	}

	private function render_row( $post, $meta_field ) {
		$meta_value = get_post_meta( $post->ID, $meta_field['id'], true );

		// An unset key reads back as '', a saved unchecked box reads back as
		// '0'. Testing for '' rather than empty() is what tells those apart, so
		// the default cannot re-check a box the user just cleared.
		if ( '' === $meta_value && isset( $meta_field['default'] ) ) {
			$meta_value = $meta_field['default'];
		}

		// A radio group has no single input to point at, so it gets a plain
		// label rather than one referencing an id nothing carries.
		$label_for = 'radio' === $meta_field['type']
			? ''
			: ' for="' . esc_attr( $meta_field['id'] ) . '"';

		printf(
			'<tr><th scope="row"><label%1$s>%2$s</label></th><td>%3$s</td></tr>',
			$label_for,
			esc_html( $meta_field['label'] ),
			$this->render_field( $meta_field, $meta_value )
		);
	}

	private function render_field( $meta_field, $meta_value ) {
		switch ( $meta_field['type'] ) {
			default:
				return sprintf(
					'<input class="regular-text" id="%1$s" name="%1$s" type="%2$s" value="%3$s">',
					esc_attr( $meta_field['id'] ),
					esc_attr( $meta_field['type'] ),
					esc_attr( $meta_value )
				);
		}
	}


	public function save_fields( $post_id, $post ) {
		// save_post fires for every post type, so without this a box attached
		// to 'post' would write its meta onto pages and every custom type too.
		if ( ! in_array( $post->post_type, $this->screens, true ) ) {
			return;
		}

		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}

		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		if ( ! isset( $_POST[ self::NONCE_NAME ] ) ) {
			return;
		}

		if ( ! wp_verify_nonce( sanitize_key( wp_unslash( $_POST[ self::NONCE_NAME ] ) ), self::NONCE_ACTION ) ) {
			return;
		}

		// The nonce proves the request came from the edit screen. It does not
		// prove this user is allowed to edit this post.
		if ( ! current_user_can( 'edit_post', $post_id ) ) {
			return;
		}

		foreach ( $this->meta_fields as $meta_field ) {
			$key = $meta_field['id'];

			// An unchecked box posts nothing at all, so its absence is the
			// value. Skipping it would leave the previous '1' in place forever.
			if ( 'checkbox' === $meta_field['type'] ) {
				update_post_meta( $post_id, $key, isset( $_POST[ $key ] ) ? '1' : '0' );
				continue;
			}

			if ( ! isset( $_POST[ $key ] ) ) {
				continue;
			}

			// wp_unslash first: WordPress adds slashes to everything in $_POST,
			// so without it an apostrophe gains a backslash on every save.
			$value = $this->sanitize_field( $meta_field, wp_unslash( $_POST[ $key ] ) );
			update_post_meta( $post_id, $key, $value );
		}
	}

	/**
	 * WordPress stores whatever it is handed, so this is the only point where
	 * the value is constrained. Running sanitize_text_field over everything
	 * would strip exactly the markup a WYSIWYG field exists to keep, which is
	 * why each type is handled on its own terms.
	 */
	private function sanitize_field( $meta_field, $value ) {
		switch ( $meta_field['type'] ) {
			case 'email':
				return sanitize_email( $value );

			case 'url':
				return sanitize_url( $value );

			case 'number':
				return is_numeric( $value ) ? $value + 0 : '';

			case 'color':
				// Returns null for anything that is not a hex colour.
				return (string) sanitize_hex_color( $value );

			case 'textarea':
				return sanitize_textarea_field( $value );

			case 'wysiwyg':
				return wp_kses_post( $value );

			case 'categories':
			case 'users':
				return absint( $value );

			case 'media':
				return 'url' === $meta_field['returnvalue']
					? sanitize_url( $value )
					: absint( $value );

			case 'radio':
			case 'select':
				// Nothing stops a crafted request from posting a value that was
				// never offered, so the option list doubles as the allow-list.
				$allowed = array_map( 'strval', array_keys( $this->field_options( $meta_field ) ) );
				return in_array( (string) $value, $allowed, true ) ? $value : '';

			default:
				return sanitize_text_field( $value );
		}
	}

	/**
	 * Registers each field's key with WordPress.
	 *
	 * 'show_in_rest' needs the post type to support 'custom-fields', which
	 * register_post_type() does not add by default. Without that support the
	 * keys register cleanly and simply never appear in a REST response, which
	 * is the version of this that is hard to debug.
	 */
	public function register_meta_keys() {
		foreach ( $this->screens as $screen ) {
			foreach ( $this->meta_fields as $meta_field ) {
				register_post_meta(
					$screen,
					$meta_field['id'],
					array(
						'type'              => 'string',
						'single'            => true,
						'show_in_rest'      => true,
						'sanitize_callback' => array( $this, 'sanitize_meta_value' ),
						'auth_callback'     => array( $this, 'can_edit_this_post' ),
					)
				);
			}
		}
	}

	/**
	 * The sanitizer WordPress calls for any writer of these keys.
	 *
	 * register_meta() hands the callback the value and the key but not the
	 * field, so the field is looked up here before the type-specific rules in
	 * sanitize_field() can be applied.
	 */
	public function sanitize_meta_value( $value, $meta_key ) {
		foreach ( $this->meta_fields as $meta_field ) {
			if ( $meta_field['id'] === $meta_key ) {
				return $this->sanitize_field( $meta_field, $value );
			}
		}

		return sanitize_text_field( $value );
	}

	/**
	 * Who may write these keys over the REST API.
	 *
	 * Matches the capability the save routine already checks, so a value that
	 * cannot be edited on the post screen cannot be edited through the API
	 * either.
	 */
	public function can_edit_this_post( $allowed, $meta_key, $post_id ) {
		return current_user_can( 'edit_post', $post_id );
	}
}

new WP_Skills_MetaBox_My_Custom_MetaBox();