Skip to content
ExponaBLOG
WORDPRESS DEVELOPMENT & PLUGINS · DAY 009 / 100

Custom taxonomies: WordPress best practices explained

Categorize custom content.

Expona Admin8 min read

When custom post types aren't enough

We've all hit that point in a WordPress project where standard categories and tags feel limiting. You've built a custom post type for your team's content, but now you need multiple ways to organize it—by client, by project status, by region, or by skill level. That's where custom taxonomies: WordPress best practices explained becomes essential knowledge. Custom taxonomies let you organize custom content with the same power and flexibility that WordPress gives you with posts and pages, and getting them right from the start saves painful refactoring later.

Registering taxonomies on the init hook

The foundation of any taxonomy is the register_taxonomy() function, and it must be called on the init hook. This timing matters—WordPress needs to know about your taxonomy before post types are queried, before REST requests are processed, and before the admin interface renders.

Here's the pattern we use in production:

php
add_action( 'init', 'register_client_taxonomy' );

function register_client_taxonomy() {
	$labels = array(
		'name'              => _x( 'Clients', 'taxonomy general name' ),
		'singular_name'     => _x( 'Client', 'taxonomy singular name' ),
		'search_items'      => __( 'Search Clients' ),
		'all_items'         => __( 'All Clients' ),
		'parent_item'       => __( 'Parent Client' ),
		'parent_item_colon' => __( 'Parent Client:' ),
		'edit_item'         => __( 'Edit Client' ),
		'update_item'       => __( 'Update Client' ),
		'add_new_item'      => __( 'Add New Client' ),
		'new_item_name'     => __( 'New Client Name' ),
		'menu_name'         => __( 'Clients' ),
	);

	$args = array(
		'labels'            => $labels,
		'description'       => 'Organization clients for project taxonomy',
		'hierarchical'      => false,
		'public'            => true,
		'show_ui'           => true,
		'show_admin_column' => true,
		'show_in_nav_menus' => true,
		'rewrite'           => array(
			'slug'       => 'client',
			'with_front' => false,
		),
	);

	register_taxonomy( 'client', 'project', $args );
}

Notice we pass three arguments: the taxonomy slug (client, which becomes the URL slug), the post type it attaches to (project

Hierarchical versus flat: choosing the right structure

One of the first decisions you'll make is whether your taxonomy should be hierarchical (like categories, with parent-child relationships) or flat (like tags, all at the same level). This choice shapes how users interact with your content and how you'll query it.

Hierarchical taxonomies work well when you have clear parent-child relationships. Think of a "Locations" taxonomy for a travel company: you might have North America → United States → California. Hierarchical taxonomies support nesting, appear with indentation in the admin UI, and let you organize large sets of terms logically.

To make a taxonomy hierarchical, set 'hierarchical' => true in your args:

php
$args = array(
	'hierarchical' => true,
	// ... other args
);

Flat taxonomies work when terms are independent of each other. A "Skills" taxonomy for freelancer profiles—JavaScript, React, Node.js—doesn't need nesting. Flat taxonomies feel more like tags; users type them in comma-separated or pick from a checklist. They're simpler to query and easier for content creators to manage when there are many terms.

In practice, most custom taxonomies we build are flat. Hierarchical taxonomies add cognitive load for both content editors and developers. If you think you'll nest terms more than two levels deep, consider whether you actually need two separate taxonomies instead.

Exposing taxonomies to the REST API with show_in_rest

If your team uses the block editor (Gutenberg) or builds headless applications, the REST API matters. The show_in_rest parameter exposes your taxonomy to the REST API, letting JavaScript in the editor fetch and assign terms dynamically.

Add this to your taxonomy args:

php
$args = array(
	'show_in_rest' => true,
	'rest_base'    => 'clients',  // optional: customize the endpoint
	// ... other args
);

With show_in_rest enabled, your block editor gets a native UI for selecting taxonomy terms. The endpoint becomes /wp-json/wp/v2/clients (or whatever your rest_base is), and your frontend JavaScript can query it.

Without show_in_rest, the block editor won't show a panel for that taxonomy, and headless consumers can't access the terms. We've seen teams forget this during initial development, then scramble to add REST support when the frontend team asks for it.

Attaching taxonomies to multiple post types

One of the most powerful aspects of WordPress taxonomies is that a single taxonomy can attach to multiple post types. Instead of duplicating your "Client" taxonomy logic for both the project and invoice post types, register once and attach to both.

Pass an array of post types instead of a single string:

php
add_action( 'init', 'register_shared_taxonomies' );

function register_shared_taxonomies() {
	$labels = array(
		'name'          => _x( 'Clients', 'taxonomy general name' ),
		'singular_name' => _x( 'Client', 'taxonomy singular name' ),
		// ... rest of labels
	);

	$args = array(
		'labels'            => $labels,
		'hierarchical'      => false,
		'public'            => true,
		'show_ui'           => true,
		'show_admin_column' => true,
		'show_in_rest'      => true,
		'rewrite'           => array( 'slug' => 'client' ),
	);

	// Attach to multiple post types
	register_taxonomy( 'client', array( 'project', 'invoice', 'contact' ), $args );
}

Now editors can assign "Client A" to both a project and an invoice, and your queries become more powerful. On the frontend, you can filter all client-related content with a single term query:

php
$args = array(
	'tax_query' => array(
		array(
			'taxonomy' => 'client',
			'field'    => 'slug',
			'terms'    => 'acme-corp',
		),
	),
	'post_type' => array( 'project', 'invoice', 'contact' ),
);

$query = new WP_Query( $args );

This is cleaner than having three separate taxonomies with identical names and configurations. You update the taxonomy once, and the change applies everywhere it's attached.

Practical example: a multi-project content hub

Let's walk through a real scenario. You're building a hub for an agency where projects contain deliverables (a custom post type). You need to organize deliverables by project, client, and project status. Here's the complete setup:

php
add_action( 'init', 'register_agency_taxonomies' );

function register_agency_taxonomies() {
	// Hierarchical: projects can have sub-projects
	register_taxonomy(
		'project_category',
		'deliverable',
		array(
			'labels'            => array(
				'name'          => 'Projects',
				'singular_name' => 'Project',
			),
			'hierarchical'      => true,
			'public'            => true,
			'show_ui'           => true,
			'show_admin_column' => true,
			'show_in_rest'      => true,
			'rewrite'           => array( 'slug' => 'project' ),
		)
	);

	// Flat: client list
	register_taxonomy(
		'client',
		'deliverable',
		array(
			'labels'            => array(
				'name'          => 'Clients',
				'singular_name' => 'Client',
			),
			'hierarchical'      => false,
			'public'            => true,
			'show_ui'           => true,
			'show_admin_column' => true,
			'show_in_rest'      => true,
			'rewrite'           => array( 'slug' => 'client' ),
		)
	);

	// Flat: status tags
	register_taxonomy(
		'delivery_status',
		'deliverable',
		array(
			'labels'            => array(
				'name'          => 'Delivery Status',
				'singular_name' => 'Status',
			),
			'hierarchical'      => false,
			'public'            => true,
			'show_ui'           => true,
			'show_admin_column' => true,
			'show_in_rest'      => true,
			'rewrite'           => array( 'slug' => 'status' ),
		)
	);
}

Now your editors can filter the deliverables list by any combination of these three taxonomies. On the frontend, you can build a report that shows all deliverables for Acme Corp in the Web Design project that are pending review.

Common mistakes we've seen

  • Registering on the wrong hook. Using plugins_loaded instead of init can cause the taxonomy to not fully register. Stick with init; it's the standard for a reason.
  • Forgetting show_in_rest for block editor projects. The block editor won't expose your taxonomy UI unless you explicitly enable REST support. This surprises teams working with newer WordPress setups.
  • Making everything hierarchical. Hierarchical taxonomies add complexity. Most use cases are flat. Only go hierarchical if you genuinely need parent-child relationships.
  • Using public=false without understanding the implications. Setting 'public' => false hides the taxonomy from the frontend entirely. Only do this for internal-only taxonomies. Most custom taxonomies should be public.
  • Duplicating taxonomy logic. If two post types need to categorize by the same concept (e.g., "Client"), register one taxonomy and attach it to both post types. Duplication leads to sync issues and maintenance headaches.

Key takeaways

  • Register all taxonomies on the init hook using register_taxonomy() with the taxonomy slug, post type(s), and args array.
  • Choose hierarchical only if you have genuine parent-child relationships; most taxonomies should be flat.
  • Always set 'show_in_rest' => true if your team uses the block editor or builds headless applications.
  • Attach a single taxonomy to multiple post types rather than duplicating similar taxonomies across post types.
  • Set 'show_admin_column' => true to make term filtering easier for editors in post lists.
  • Define clear, user-friendly labels; the singular and plural forms both appear in the UI.
  • Use a consistent, descriptive slug (e.g., client, not c) so your REST endpoints and rewrite rules are self-documenting.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles