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

About this generator

Term Meta Generator Online

Add real fields to taxonomy terms — an accent colour on a category, a tagline on a genre, an archive layout on a region — and get the whole implementation: register_term_meta() with a type and sanitiser per field, the add-term form, the edit-term form, a nonce-checked save handler wired to both created_{taxonomy} and edited_{taxonomy}, and an optional column in the terms table.

A Preview tab renders the edit-term screen as WordPress will draw it, table row markup and all, so you can check labels and field order before pasting anything. Free, no account, nothing uploaded.

Reviewed Output tested on WordPress 6.8 · PHP 8.3

Why the term meta generator beats hand-wiring the taxonomy form hooks

Term fields need four hooks, not one, and every one of them is named after your taxonomy: the add form, the edit form, and the save handler on both created_ and edited_. Wire only the edit form and a value set at creation time is lost. Wire only the add form and it can never be changed. This generator emits the matched set and flags either half being missing.

Both forms, or the Checks tab objects

A missing edit-term form is an error, because values set when a term is created could never be corrected. A missing add-term form is a warning, because the term has to be saved and reopened before the fields appear. One Add both forms fix resolves either.

Hook names built from your taxonomy

Set the taxonomy to genre and the code hooks genre_add_form_fields, genre_edit_form_fields, created_genre and edited_genre. Set it to category and you get the core-specific created_category and edited_category names instead.

The right markup for each of the two screens

The add-term screen wants div.form-field wrappers; the edit-term screen wants tr rows with a th label cell. They are genuinely different templates, and the generator writes each one correctly rather than reusing the wrong markup on both.

Eight field types, each with its own sanitiser

Text, textarea, number, checkbox, select, hex colour, URL and attachment ID. Each maps to a sanitiser (sanitize_hex_color, esc_url_raw, absint, rest_sanitize_boolean and so on) and to a REST type, and selects save through an in_array() whitelist of the exact choices you entered.

A save handler that runs on create and edit

One function, hooked twice, opening with a nonce verification and a current_user_can( 'manage_categories' ) check. Empty values call delete_term_meta() rather than storing a blank row.

An honest note about the image field

The attachment-ID field is a number input, not a media picker, and the Checks tab says so — a real picker needs wp.media and an enqueued script. The generator tells you what it is not doing instead of pretending.

How does the term meta generator work?

Name the taxonomy, list the fields, choose where they appear, then export.

  1. 01

    Set the scope

    Enter the taxonomy the fields belong to and the prefix for the meta keys. Term meta keys are shared across every taxonomy on the site, so the prefix is not decoration — the Checks tab warns when it is missing.

  2. 02

    Add the fields

    Each field takes a key, a label, a type, an optional description shown under the input, whether it appears in REST, and a value:Label choice list for selects. Drag to reorder.

  3. 03

    Choose where they appear

    Toggle the add-term form, the edit-term form, and a column in the terms list table. The column shows the first field, using the manage_edit-{taxonomy}_columns and manage_{taxonomy}_custom_column filter pair.

  4. 04

    Check the preview, then export

    Compare the Preview tab with what you expect on the edit screen, clear the Checks tab, then copy the snippet or download it as a plugin file.

Worked example — an accent colour on category terms

One hex-colour field on the category taxonomy, exposed to REST. This is the registration block from the Snippet output, verbatim; the two form callbacks and the save handler follow it in the same file.

/**
 * 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' );
			},
		)
	);
}
add_action( 'init', 'acme_register_meta' );

sanitize_hex_color() returns null for anything that is not a valid hex colour, which REST reports as an invalid value rather than silently blanking the field. That is the behaviour you want, and it is why the save handler tests for null as well as an empty string.

Term meta — frequently asked questions

The questions that come up when a taxonomy needs to carry more than a name and a description.

Which hooks do I need to add a custom field to a taxonomy term?

Four, all named after the taxonomy. {$taxonomy}_add_form_fields prints the field on the add-term screen, {$taxonomy}_edit_form_fields prints it on the edit screen, and created_{$taxonomy} plus edited_{$taxonomy} both call the save handler. Register the key with register_term_meta() on init as well, so it is typed and sanitised.

Why is my term field not saving when I create a new term?

Almost always because only edited_{$taxonomy} is hooked. Creating a term fires created_{$taxonomy}, not edited_, so a handler attached to just the edit hook never runs on the first save. The generated code hooks both to the same function.

Why does my term field render as a plain row with no styling?

The add-term and edit-term screens use different markup. The add screen expects a div with the form-field class; the edit screen is a table and expects a tr with a th label cell and a td for the input. Reusing one template on both screens is what produces the misaligned result.

How do I show term meta in the block editor or over the REST API?

Register the key with show_in_rest => true and make sure the taxonomy itself has show_in_rest on, otherwise there is no /wp/v2/{taxonomy} route for the value to appear in. The generator flags a field set that exposes nothing to REST, because it usually means the block editor cannot see any of it.

Can I add a media picker for a term image?

Not with PHP alone. The media modal is wp.media, so it needs wp_enqueue_media() plus a small script that opens the frame and writes the chosen attachment ID into a hidden input. The generator emits the number input that stores the ID and says plainly that the picker is the part you still have to build.

Do I need a nonce on a term form?

Yes. created_ and edited_ fire on any request that reaches the term-edit endpoint, so the handler needs wp_nonce_field() in the form and wp_verify_nonce() in the save, plus a capability check — manage_categories for the built-in taxonomies. Both are always written into the generated save handler.

Where do I paste the code from the Term Meta generator?

You have three good options and one bad one. Best is a small site-specific plugin — the generator can output one for you, and it keeps working when you change theme. A code-snippets plugin is fine too. A child theme's functions.php works but ties the code to that theme. Never edit the parent theme: the next update overwrites it and your code is gone.

Can I use the generated code in client work or a commercial plugin?

Yes. The output is ordinary WordPress API calls that you configured — there is no licence attached to it and no attribution required. Use it in client sites, commercial plugins and themes, or products you sell. The generated file carries a short credit comment you are free to delete.

The other Content generators in the library, plus the WordPress tools most often used alongside this one.