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:
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 ( 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 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. If your team uses the block editor (Gutenberg) or builds headless applications, the REST API matters. The Add this to your taxonomy args: With Without 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 Pass an array of post types instead of a single string: 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: 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. 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: 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.client, which becomes the URL slug), the post type it attaches to (project
Hierarchical versus flat: choosing the right structure
'hierarchical' => true in your args:$args = array(
'hierarchical' => true,
// ... other args
);
Exposing taxonomies to the REST API with show_in_rest
show_in_rest parameter exposes your taxonomy to the REST API, letting JavaScript in the editor fetch and assign terms dynamically.$args = array(
'show_in_rest' => true,
'rest_base' => 'clients', // optional: customize the endpoint
// ... other args
);
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.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
project and invoice post types, register once and attach to both.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 );
}
$args = array(
'tax_query' => array(
array(
'taxonomy' => 'client',
'field' => 'slug',
'terms' => 'acme-corp',
),
),
'post_type' => array( 'project', 'invoice', 'contact' ),
);
$query = new WP_Query( $args );
Practical example: a multi-project content hub
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' ),
)
);
}
Common mistakes we've seen
plugins_loaded instead of init can cause the taxonomy to not fully register. Stick with init; it's the standard for a reason.'public' => false hides the taxonomy from the frontend entirely. Only do this for internal-only taxonomies. Most custom taxonomies should be public.Key takeaways
init hook using register_taxonomy() with the taxonomy slug, post type(s), and args array.'show_in_rest' => true if your team uses the block editor or builds headless applications.'show_admin_column' => true to make term filtering easier for editors in post lists.client, not c) so your REST endpoints and rewrite rules are self-documenting.






Comments
Loading comments…