Skip to content
wp-skills
Register
tools/wordpress-term-meta-generator

WordPress Term Meta Generator

Add custom fields to a taxonomy's term screens: the Add Term form, the Edit Term form, a sanitized save routine, registered meta keys for the REST API, and a column in the terms list.

The taxonomy slug. Every hook name is built from it, so 'category' gives category_add_form_fields.

Adds register_term_meta() on init, so the value has a declared type and a sanitizer every writer goes through.

Needs the taxonomy itself to be in the REST API — there is no route to expose the meta on otherwise.

Shows each field's value in the table on the taxonomy's admin screen.

Fields1
  • #1

    Lowercase letters, numbers and underscores. This is the key get_term_meta() reads.

snippet.php
<?php
/**
 * Term meta for the category taxonomy
 *
 * Generated by wp-skills.com
 */

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

class WP_Skills_Term_Meta_Category {

	const TAXONOMY = 'category';

	/**
	 * 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 = 'category_term_meta';
	const NONCE_NAME   = 'category_term_meta_nonce';

	private $meta_fields = array(
		array(
			'label'       => 'Subtitle',
			'id'          => 'category_subtitle',
			'type'        => 'text',
			'description' => 'Shown under the term name on archive pages.',
			'default'     => '',
		)
	);

	public function __construct() {
		/*
		 * Two render hooks rather than one, because the two screens are not the
		 * same shape: the Add Term form is a stack of <div class="form-field">
		 * blocks, while the Edit Term form is inside core's
		 * <table class="form-table"> and needs <tr> rows. Reusing one callback
		 * for both — the common shortcut — produces a table row loose in a div
		 * on one screen and a bare div inside a table on the other.
		 */
		add_action( self::TAXONOMY . '_add_form_fields', array( $this, 'render_add_fields' ) );
		add_action( self::TAXONOMY . '_edit_form_fields', array( $this, 'render_edit_fields' ), 10, 2 );

		add_action( 'created_' . self::TAXONOMY, array( $this, 'save_fields' ) );
		add_action( 'edited_' . self::TAXONOMY, array( $this, 'save_fields' ) );
		add_action( 'init', array( $this, 'register_meta_keys' ) );
		add_filter( 'manage_edit-' . self::TAXONOMY . '_columns', array( $this, 'add_columns' ) );
		add_filter( 'manage_' . self::TAXONOMY . '_custom_column', array( $this, 'render_column' ), 10, 3 );
	}

	public function render_add_fields( $taxonomy ) {
		wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );

		foreach ( $this->meta_fields as $meta_field ) {
			$meta_value = isset( $meta_field['default'] ) ? $meta_field['default'] : '';

			printf(
				'<div class="form-field term-%1$s-wrap"><label for="%1$s">%2$s</label>%3$s%4$s</div>',
				esc_attr( $meta_field['id'] ),
				esc_html( $meta_field['label'] ),
				$this->render_field( $meta_field, $meta_value ),
				$this->render_description( $meta_field )
			);
		}
	}

	public function render_edit_fields( $term, $taxonomy ) {
		// The hook fires inside core's <table class="form-table">, so the nonce
		// needs a row of its own: a bare hidden input between rows is not valid
		// table markup and the parser moves it out of the table. 'hidden' is a
		// core admin class.
		echo '<tr class="hidden"><td colspan="2">';
		wp_nonce_field( self::NONCE_ACTION, self::NONCE_NAME );
		echo '</td></tr>';

		foreach ( $this->meta_fields as $meta_field ) {
			$meta_value = $this->stored_value( $term->term_id, $meta_field );

			// 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 class="form-field term-%1$s-wrap"><th scope="row"><label%2$s>%3$s</label></th><td>%4$s%5$s</td></tr>',
				esc_attr( $meta_field['id'] ),
				$label_for,
				esc_html( $meta_field['label'] ),
				$this->render_field( $meta_field, $meta_value ),
				$this->render_description( $meta_field )
			);
		}
	}

	/**
	 * The stored value, or the default when nothing has been stored.
	 *
	 * An unset key reads back as '' and a saved unchecked box also 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.
	 */
	private function stored_value( $term_id, $meta_field ) {
		$meta_value = get_term_meta( $term_id, $meta_field['id'], true );

		if ( '' === $meta_value && isset( $meta_field['default'] ) ) {
			return $meta_field['default'];
		}

		return $meta_value;
	}

	private function render_description( $meta_field ) {
		if ( '' === $meta_field['description'] ) {
			return '';
		}

		return sprintf(
			'<p class="description">%s</p>',
			esc_html( $meta_field['description'] )
		);
	}

	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 )
				);
		}
	}

	/**
	 * Saves the fields for a term that has just been created or edited.
	 *
	 * created_{$taxonomy} and edited_{$taxonomy} fire for every term written
	 * anywhere — wp_insert_term() in a migration, an importer, the REST API —
	 * not only for a submission of these two forms. The nonce check is what
	 * makes that safe: a term created outside the form posts no nonce, so this
	 * returns before it can overwrite meta with values nobody submitted.
	 */
	public function save_fields( $term_id ) {
		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 term form. It does not
		// prove this user is allowed to edit this term.
		if ( ! current_user_can( 'edit_term', $term_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_term_meta( $term_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_term_meta( $term_id, $key, $value );
		}
	}

	/**
	 * WordPress stores whatever it is handed, so this is the only point where
	 * the value is constrained.
	 */
	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 'checkbox':
				return '1' === (string) $value ? '1' : '0';

			case 'select':
			case 'radio':
				// 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 key so WordPress knows its type and sanitizer.
	 *
	 * 'show_in_rest' only exposes the meta on a taxonomy that is itself in the
	 * REST API. A taxonomy registered with 'show_in_rest' => false has no route
	 * to expose it on, and the key simply never appears.
	 */
	public function register_meta_keys() {
		foreach ( $this->meta_fields as $meta_field ) {
			register_term_meta(
				self::TAXONOMY,
				$meta_field['id'],
				array(
					'type'              => isset( $meta_field['rest_type'] ) ? $meta_field['rest_type'] : 'string',
					'description'       => $meta_field['description'],
					'single'            => true,
					'show_in_rest'      => true,
					'sanitize_callback' => array( $this, 'sanitize_meta_value' ),
					'auth_callback'     => array( $this, 'can_edit_terms' ),
				)
			);
		}
	}

	/**
	 * 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.
	 *
	 * 'manage_categories' is the capability the default taxonomy arguments map
	 * 'edit_term' onto, so this matches what the admin screens already allow.
	 */
	public function can_edit_terms() {
		return current_user_can( 'manage_categories' );
	}

	public function add_columns( $columns ) {
		foreach ( $this->meta_fields as $meta_field ) {
			$columns[ $meta_field['id'] ] = $meta_field['label'];
		}

		return $columns;
	}

	/**
	 * Returns the cell content; it must not echo. See the note on the filter in
	 * the constructor.
	 */
	public function render_column( $content, $column_name, $term_id ) {
		foreach ( $this->meta_fields as $meta_field ) {
			if ( $meta_field['id'] !== $column_name ) {
				continue;
			}

			$value = get_term_meta( $term_id, $meta_field['id'], true );

			if ( '' === $value ) {
				return '&mdash;';
			}

			return 'checkbox' === $meta_field['type']
				? ( '1' === $value ? esc_html__( 'Yes', 'text-domain' ) : esc_html__( 'No', 'text-domain' ) )
				: esc_html( $value );
		}

		return $content;
	}
}

new WP_Skills_Term_Meta_Category();