Himanshu SharmaSoftware engineer
Let’s talk

WordPress · Lesson 32 of 45

Custom taxonomies

Register your own taxonomies with register_taxonomy() for custom post types, hierarchical or tag-like, and work with terms in code and in queries.

13 minHooks and pluginsUpdated 23 October 2026

By the end of this lessonGroup custom content with a taxonomy such as Course or Ingredient, assign and read terms in PHP, and filter posts by term.

What taxonomies are for

A taxonomy is a way to group content. The built-in ones are categories and tags. Custom post types often need their own: recipes grouped by course (starter, main, dessert) and tagged by ingredient. A taxonomy belongs to one or more post types, and it is made of terms.

This lesson continues the Bakery Recipes plugin from Custom post types.

Registering taxonomies

Add two taxonomies in the same plugin, on init:

php
<?php
add_action( 'init', function () {
	// Like categories: hierarchical, can have parents.
	register_taxonomy( 'course', [ 'recipe' ], [
		'labels' => [
			'name'          => 'Courses',
			'singular_name' => 'Course',
		],
		'hierarchical' => true,
		'public'       => true,
		'show_in_rest' => true,
		'rewrite'      => [ 'slug' => 'course' ],
	] );

	// Like tags: flat, typed in freely.
	register_taxonomy( 'ingredient', [ 'recipe' ], [
		'labels' => [
			'name'          => 'Ingredients',
			'singular_name' => 'Ingredient',
		],
		'hierarchical' => false,
		'public'       => true,
		'show_in_rest' => true,
		'rewrite'      => [ 'slug' => 'ingredient' ],
	] );
} );

Load the file from the main plugin file, and rebuild the rewrite rules once so the new addresses work:

php
<?php
require_once WP_PLUGIN_DIR . '/bakery-recipes/taxonomies.php';
bash
wp rewrite flush
wp taxonomy list --object_type=recipe --fields=name,label,hierarchical
result
Success: Rewrite rules flushed.
+------------+-------------+--------------+
| name       | label       | hierarchical |
+------------+-------------+--------------+
| course     | Courses     | 1            |
| ingredient | Ingredients |              |
+------------+-------------+--------------+

The arguments are similar to a post type: labels, public, show_in_rest (needed for the block editor and the REST API), rewrite for the address, and hierarchical. The second argument of register_taxonomy() lists the post types the taxonomy attaches to.

Creating and assigning terms

bash
wp term create course Starters --slug=starters
wp term create course Mains --slug=mains
wp term create course Soups --slug=soups --parent=3
result
Success: Created course 2.
Success: Created course 3.
Success: Created course 4.

The recipe posts do not exist yet in this fresh site, so create them and assign terms in code:

php
<?php
$sourdough = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Sourdough loaf', 'post_status' => 'publish' ] );
$soup      = wp_insert_post( [ 'post_type' => 'recipe', 'post_title' => 'Leek soup', 'post_status' => 'publish' ] );

wp_set_object_terms( $sourdough, [ 'starters' ], 'course' );
wp_set_object_terms( $sourdough, [ 'flour', 'water', 'salt' ], 'ingredient' );   // created if missing
wp_set_object_terms( $soup, [ 'soups' ], 'course' );
wp_set_object_terms( $soup, [ 'leek', 'salt' ], 'ingredient' );

foreach ( [ $sourdough, $soup ] as $id ) {
	$courses     = wp_list_pluck( get_the_terms( $id, 'course' ), 'name' );
	$ingredients = wp_list_pluck( get_the_terms( $id, 'ingredient' ), 'name' );
	echo get_the_title( $id ), ' | ', implode( ', ', $courses ), ' | ', implode( ', ', $ingredients ), "\n";
}
result
Sourdough loaf | Starters | flour, salt, water
Leek soup | Soups | leek, salt

wp_set_object_terms( $post_id, $terms, $taxonomy ) replaces the post's terms in that taxonomy (pass true as the fourth argument to append instead). For hierarchical taxonomies, pass term IDs to be exact; names and slugs are matched or created. get_the_terms() returns term objects, or false if none, so check before looping in real code.

Term lists and archives

php
<?php
$terms = get_terms( [ 'taxonomy' => 'ingredient', 'hide_empty' => true, 'orderby' => 'count', 'order' => 'DESC', 'number' => 3 ] );
foreach ( $terms as $term ) {
	echo $term->name, ' (', $term->count, ') ', get_term_link( $term ), "\n";
}
result
salt (2) http://localhost:8080/ingredient/salt/
flour (1) http://localhost:8080/ingredient/flour/
water (1) http://localhost:8080/ingredient/water/

Every term gets its own archive page, at the address you set in rewrite:

http
GET /ingredient/salt/
result
Sourdough loaf
Leek soup

In a classic theme you control the layout with taxonomy-ingredient.php or taxonomy.php (see the template hierarchy); in a block theme, taxonomy-ingredient.html.

Filtering by terms

Use tax_query in WP_Query to find posts by their terms:

php
<?php
$query = new WP_Query( [
	'post_type'     => 'recipe',
	'fields'        => 'ids',
	'no_found_rows' => true,
	'tax_query'     => [
		'relation' => 'AND',
		[ 'taxonomy' => 'ingredient', 'field' => 'slug', 'terms' => 'salt' ],
		[ 'taxonomy' => 'course', 'field' => 'slug', 'terms' => 'mains' , 'include_children' => true ],
	],
] );
echo "mains with salt: ", count( $query->posts ), "\n";

$query = new WP_Query( [
	'post_type' => 'recipe', 'fields' => 'ids', 'no_found_rows' => true,
	'tax_query' => [ [ 'taxonomy' => 'course', 'field' => 'slug', 'terms' => 'mains', 'include_children' => true ] ],
] );
echo "in mains (and its children): ", implode( ', ', array_map( 'get_the_title', $query->posts ) ), "\n";
result
mains with salt: 1
in mains (and its children): Leek soup

Leek soup is in the child term "Soups", and include_children (on by default) makes a search for its parent "Mains" find it too.

Seeing taxonomies in the dashboard

With show_in_rest enabled, each taxonomy appears as a panel in the block editor's sidebar when you edit a recipe: a checklist for the hierarchical one, and a free-text box with suggestions for the flat one. A menu entry under Recipes opens a screen where editors add, rename and delete terms, and the Terms screen shows how many posts use each. Because terms are shared by all posts of the type, renaming one in a single place updates every recipe at once.

Taxonomy or post meta?

Use a taxonomy for values shared by many posts that visitors browse by: course, ingredient, difficulty. Use post meta for values that belong to one post: cooking time, a price. A taxonomy gives you archive pages, term lists and fast filtering for free.

Common mistakes

  • Forgetting show_in_rest, so the block editor shows no panel.
  • Registering the taxonomy without the post type (second argument), so it appears nowhere.
  • Not flushing rewrite rules, which gives 404 on term pages.
  • Using a taxonomy for one-off values that really are meta.
  • Using names that clash with built-in query variables (year, name, page...).

Exercise

Register a non-hierarchical difficulty taxonomy for recipes with the terms Easy, Medium and Hard, and assign "Easy" to the sourdough loaf.

Show one possible answer
php
add_action( 'init', function () {
	register_taxonomy( 'difficulty', [ 'recipe' ], [
		'labels'       => [ 'name' => 'Difficulty', 'singular_name' => 'Difficulty' ],
		'public'       => true,
		'show_in_rest' => true,
	] );
} );

// Once, after the taxonomy exists:
foreach ( [ 'Easy', 'Medium', 'Hard' ] as $name ) {
	wp_insert_term( $name, 'difficulty' );
}
wp_set_object_terms( $sourdough_id, 'easy', 'difficulty' );

Key points

  • A taxonomy groups content: categories and tags are the built-in ones; register_taxonomy() adds your own.
  • Hierarchical taxonomies behave like categories (parents and children); non-hierarchical ones behave like tags.
  • Register taxonomies on init with the post types they attach to, and set show_in_rest so the block editor shows them.
  • Use wp_set_object_terms(), get_the_terms() and the tax_query argument of WP_Query to assign, read and filter by terms.

Check yourself

1. What is the difference between hierarchical and non-hierarchical taxonomies?
2. Which function attaches terms to a post?
3. What do you need so a custom taxonomy shows in the block editor?
4. How do you list recipes that are in the 'dessert' term of a 'course' taxonomy?