Term Meta Generator

Term fields are three hooks, not one: the add form, the edit form, and a save that fires on both. All three generated together, so nothing silently fails to save.

category-fields.php
Saved just now
Scope
4 fields on category terms, 3 exposed to REST. Hooks: category_add_form_fields, category_edit_form_fields, created_category, edited_category.
Fields
4 fields
Accent colouracme_accent
REST
Taglineacme_tagline
REST
Archive layoutacme_layout
REST
Feature on the home pageacme_featured
REST
Where it appears
Add-term form
The div-based markup the new-term screen expects.
Edit-term form
The table-row markup the edit screen expects — different from the add form.
Terms-table column
Shows the first field as a column in the terms list.
<?php
// Generated with WP CodeKit — powered by GrowQuest (https://growquest.io).
/**
 * Plugin Name:       category term fields
 * Description:       Adds 4 fields to category terms.
 * Version:           1.0.0
 * Requires PHP:      7.4
 * Text Domain:       acme
 */

defined( 'ABSPATH' ) || exit;

/**
 * Register the term meta keys.
 */
function acme_register_meta() {
	register_term_meta(
		'category',
		'acme_accent',
		array(
			'type'              => 'string',
			'single'            => true,
			'show_in_rest'      => true,
			'sanitize_callback' => 'sanitize_hex_color',
			'description'       => __( 'Used for the archive header.', 'acme' ),
			'auth_callback'     => static function () {
				return current_user_can( 'manage_categories' );
			},
		)
	);

	register_term_meta(
		'category',
		'acme_tagline',
		array(
			'type'              => 'string',
			'single'            => true,
			'show_in_rest'      => true,
			'sanitize_callback' => 'sanitize_text_field',
			'description'       => __( 'Shown under the term name on its archive.', 'acme' ),
			'auth_callback'     => static function () {
				return current_user_can( 'manage_categories' );
			},
		)
	);

	register_term_meta(
		'category',
		'acme_layout',
		array(
			'type'              => 'string',
			'single'            => true,
			'show_in_rest'      => true,
			'sanitize_callback' => 'sanitize_key',
			'auth_callback'     => static function () {
				return current_user_can( 'manage_categories' );
			},
		)
	);

	register_term_meta(
		'category',
		'acme_featured',
		array(
			'type'              => 'boolean',
			'single'            => true,
			'show_in_rest'      => false,
			'sanitize_callback' => 'rest_sanitize_boolean',
			'auth_callback'     => static function () {
				return current_user_can( 'manage_categories' );
			},
		)
	);
}
add_action( 'init', 'acme_register_meta' );

/**
 * Fields on the add-term form.
 */
function acme_add_form_fields() {
	wp_nonce_field( 'acme_save_term_meta', 'acme_term_nonce' );

	echo '<div class="form-field term-accent-wrap">';
	printf(
		'<label for="%1$s">%2$s</label>',
		'acme_accent',
		esc_html( __( 'Accent colour', 'acme' ) )
	);
	printf(
		'<input type="color" id="%1$s" name="%1$s" value="%2$s" class="regular-text" />',
		'acme_accent',
		esc_attr( '' )
	);
	printf(
		'<p>%s</p>',
		esc_html( __( 'Used for the archive header.', 'acme' ) )
	);
	echo '</div>';

	echo '<div class="form-field term-tagline-wrap">';
	printf(
		'<label for="%1$s">%2$s</label>',
		'acme_tagline',
		esc_html( __( 'Tagline', 'acme' ) )
	);
	printf(
		'<input type="text" id="%1$s" name="%1$s" value="%2$s" class="regular-text" />',
		'acme_tagline',
		esc_attr( '' )
	);
	printf(
		'<p>%s</p>',
		esc_html( __( 'Shown under the term name on its archive.', 'acme' ) )
	);
	echo '</div>';

	echo '<div class="form-field term-layout-wrap">';
	printf(
		'<label for="%1$s">%2$s</label>',
		'acme_layout',
		esc_html( __( 'Archive layout', 'acme' ) )
	);
	echo '<select id="acme_layout" name="acme_layout">';

	foreach ( array(
		'grid' => __( 'Grid', 'acme' ),
		'list' => __( 'List', 'acme' ),
	) as $value => $label ) {
		printf(
			'<option value="%1$s"%2$s>%3$s</option>',
			esc_attr( $value ),
			selected( '', $value, false ),
			esc_html( $label )
		);
	}

	echo '</select>';
	echo '</div>';

	echo '<div class="form-field term-featured-wrap">';
	printf(
		'<label for="%1$s">%2$s</label>',
		'acme_featured',
		esc_html( __( 'Feature on the home page', 'acme' ) )
	);
	printf(
		'<input type="checkbox" id="%1$s" name="%1$s" value="1"%2$s />',
		'acme_featured',
		checked( (bool) '', true, false )
	);
	echo '</div>';
}
add_action( 'category_add_form_fields', 'acme_add_form_fields' );

/**
 * Fields on the edit-term form.
 *
 * @param WP_Term $term The term being edited.
 */
function acme_edit_form_fields( $term ) {
	wp_nonce_field( 'acme_save_term_meta', 'acme_term_nonce' );

	$accent = get_term_meta( $term->term_id, 'acme_accent', true );

	echo '<tr class="form-field term-accent-wrap">';
	printf(
		'<th scope="row"><label for="%1$s">%2$s</label></th>',
		'acme_accent',
		esc_html( __( 'Accent colour', 'acme' ) )
	);
	echo '<td>';
	printf(
		'<input type="color" id="%1$s" name="%1$s" value="%2$s" class="regular-text" />',
		'acme_accent',
		esc_attr( $accent )
	);
	printf(
		'<p class="description">%s</p>',
		esc_html( __( 'Used for the archive header.', 'acme' ) )
	);
	echo '</td></tr>';

	$tagline = get_term_meta( $term->term_id, 'acme_tagline', true );

	echo '<tr class="form-field term-tagline-wrap">';
	printf(
		'<th scope="row"><label for="%1$s">%2$s</label></th>',
		'acme_tagline',
		esc_html( __( 'Tagline', 'acme' ) )
	);
	echo '<td>';
	printf(
		'<input type="text" id="%1$s" name="%1$s" value="%2$s" class="regular-text" />',
		'acme_tagline',
		esc_attr( $tagline )
	);
	printf(
		'<p class="description">%s</p>',
		esc_html( __( 'Shown under the term name on its archive.', 'acme' ) )
	);
	echo '</td></tr>';

	$layout = get_term_meta( $term->term_id, 'acme_layout', true );

	echo '<tr class="form-field term-layout-wrap">';
	printf(
		'<th scope="row"><label for="%1$s">%2$s</label></th>',
		'acme_layout',
		esc_html( __( 'Archive layout', 'acme' ) )
	);
	echo '<td>';
	echo '<select id="acme_layout" name="acme_layout">';

	foreach ( array(
		'grid' => __( 'Grid', 'acme' ),
		'list' => __( 'List', 'acme' ),
	) as $value => $label ) {
		printf(
			'<option value="%1$s"%2$s>%3$s</option>',
			esc_attr( $value ),
			selected( $layout, $value, false ),
			esc_html( $label )
		);
	}

	echo '</select>';
	echo '</td></tr>';

	$featured = get_term_meta( $term->term_id, 'acme_featured', true );

	echo '<tr class="form-field term-featured-wrap">';
	printf(
		'<th scope="row"><label for="%1$s">%2$s</label></th>',
		'acme_featured',
		esc_html( __( 'Feature on the home page', 'acme' ) )
	);
	echo '<td>';
	printf(
		'<input type="checkbox" id="%1$s" name="%1$s" value="1"%2$s />',
		'acme_featured',
		checked( (bool) $featured, true, false )
	);
	echo '</td></tr>';
}
add_action( 'category_edit_form_fields', 'acme_edit_form_fields', 10, 2 );

/**
 * Save the fields. Fires on both create and edit.
 *
 * @param int $term_id The term.
 */
function acme_save_term_meta( $term_id ) {
	if ( ! isset( $_POST['acme_term_nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['acme_term_nonce'] ), 'acme_save_term_meta' ) ) {
		return;
	}

	if ( ! current_user_can( 'manage_categories' ) ) {
		return;
	}

	if ( isset( $_POST['acme_accent'] ) ) {
		$value = sanitize_hex_color( wp_unslash( $_POST['acme_accent'] ) );

		if ( '' === $value || null === $value ) {
			delete_term_meta( $term_id, 'acme_accent' );
		} else {
			update_term_meta( $term_id, 'acme_accent', $value );
		}
	}

	if ( isset( $_POST['acme_tagline'] ) ) {
		$value = sanitize_text_field( wp_unslash( $_POST['acme_tagline'] ) );

		if ( '' === $value || null === $value ) {
			delete_term_meta( $term_id, 'acme_tagline' );
		} else {
			update_term_meta( $term_id, 'acme_tagline', $value );
		}
	}

	if ( isset( $_POST['acme_layout'] ) && in_array( sanitize_key( $_POST['acme_layout'] ), array( 'grid', 'list' ), true ) ) {
		update_term_meta( $term_id, 'acme_layout', sanitize_key( $_POST['acme_layout'] ) );
	}

	if ( ! empty( $_POST['acme_featured'] ) ) {
		update_term_meta( $term_id, 'acme_featured', 1 );
	} else {
		delete_term_meta( $term_id, 'acme_featured' );
	}
}
add_action( 'created_category', 'acme_save_term_meta' );
add_action( 'edited_category', 'acme_save_term_meta' );

/**
 * Add a column to the terms table.
 *
 * @param array $columns Existing columns.
 * @return array
 */
function acme_column_head( $columns ) {
	$columns['accent'] = __( 'Accent colour', 'acme' );

	return $columns;
}
add_filter( 'manage_edit-category_columns', 'acme_column_head' );

/**
 * Fill the column.
 *
 * @param string $content Current content.
 * @param string $column  Column name.
 * @param int    $term_id Term ID.
 * @return string
 */
function acme_column_content( $content, $column, $term_id ) {
	if ( 'accent' !== $column ) {
		return $content;
	}

	return esc_html( (string) get_term_meta( $term_id, 'acme_accent', true ) );
}
add_filter( 'manage_category_custom_column', 'acme_column_content', 10, 3 );