WordPress Term Meta: add_term_meta, get_term_meta, and Custom Fields for Categories

WordPress taxonomy terms — categories, tags, and custom taxonomy terms — can carry their own metadata in exactly the same way posts carry post meta. Since WordPress 4.4, add_term_meta(), get_term_meta(), update_term_meta(), and delete_term_meta() store arbitrary data in the wp_termmeta table. Before 4.4, developers stored term-level data in wp_options with a mangled key, which was fragile and hard to query. Term meta is the right place for information that belongs to a term rather than to posts that use the term: a category banner image, a product category sort order, a tag colour code, or a brand logo URL. Combined with register_term_meta(), term meta can also be exposed through the REST API and sanitized automatically.

Problem: A WooCommerce product category needs a custom banner image and a sort order integer that controls its position on the shop page. The data should appear as extra fields on the category edit screen and be available in templates.

Solution: Register the meta with register_term_meta(), render the fields using the product_cat_edit_form_fields action, save with the edited_product_cat hook, and read in templates with get_term_meta().

<?php
// ── Register term meta ────────────────────────────────────────────────
add_action( 'init', function () {
    register_term_meta( 'product_cat', 'banner_image_id', [
        'type'              => 'integer',
        'single'            => true,
        'sanitize_callback' => 'absint',
        'show_in_rest'      => true,
    ] );
    register_term_meta( 'product_cat', 'sort_order', [
        'type'              => 'integer',
        'single'            => true,
        'sanitize_callback' => 'absint',
        'default'           => 0,
        'show_in_rest'      => true,
    ] );
} );

// ── Add fields to the term edit form ─────────────────────────────────
// product_cat_edit_form_fields fires on the Edit Category screen
// For "Add new" form: product_cat_add_form_fields
add_action( 'product_cat_edit_form_fields', function ( WP_Term $term ) {
    $image_id   = (int) get_term_meta( $term->term_id, 'banner_image_id', true );
    $sort_order = (int) get_term_meta( $term->term_id, 'sort_order', true );
    $image_url  = $image_id ? wp_get_attachment_image_url( $image_id, 'medium' ) : '';
    wp_nonce_field( 'save_product_cat_meta_' . $term->term_id, '_cat_meta_nonce' );
    ?>
    <tr class="form-field">
        <th scope="row"><?php esc_html_e( 'Banner Image ID', 'textdomain' ); ?></th>
        <td>
            <input type="number" name="banner_image_id"
                   value="<?php echo esc_attr( $image_id ); ?>" min="0">
            <?php if ( $image_url ) : ?>
                <br><img src="<?php echo esc_url( $image_url ); ?>" style="max-width:120px;margin-top:6px;">
            <?php endif; ?>
        </td>
    </tr>
    <tr class="form-field">
        <th scope="row"><?php esc_html_e( 'Sort Order', 'textdomain' ); ?></th>
        <td>
            <input type="number" name="cat_sort_order"
                   value="<?php echo esc_attr( $sort_order ); ?>" min="0">
        </td>
    </tr>
    <?php
} );

// ── Save term meta ────────────────────────────────────────────────────
add_action( 'edited_product_cat', function ( int $term_id ) {
    if ( ! isset( $_POST['_cat_meta_nonce'] )
        || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['_cat_meta_nonce'] ) ),
            'save_product_cat_meta_' . $term_id ) ) {
        return;
    }
    if ( ! current_user_can( 'manage_product_terms' ) ) {
        return;
    }
    update_term_meta( $term_id, 'banner_image_id',
        absint( $_POST['banner_image_id'] ?? 0 ) );
    update_term_meta( $term_id, 'sort_order',
        absint( $_POST['cat_sort_order'] ?? 0 ) );
} );

// ── Read in templates ─────────────────────────────────────────────────
$term = get_queried_object(); // on a taxonomy archive page
if ( $term instanceof WP_Term ) {
    $image_id   = (int) get_term_meta( $term->term_id, 'banner_image_id', true );
    $sort_order = (int) get_term_meta( $term->term_id, 'sort_order', true );
    if ( $image_id ) {
        echo wp_get_attachment_image( $image_id, 'full', false, [ 'class' => 'cat-banner' ] );
    }
}

NOTE: Term meta supports the same $unique flag as post meta — pass true as the fourth argument to add_term_meta() to prevent duplicate keys, or use update_term_meta() which always enforces uniqueness. For the "Add New" form use the {taxonomy}_add_form_fields action (no WP_Term argument) and the created_{taxonomy} save hook. When building admin UI for term meta, always verify a nonce; taxonomy forms do not include one by default, making them a CSRF target without it.