WordPress Custom Taxonomy Registration
Register a custom taxonomy in WordPress using register_taxonomy() — hierarchical (category-like) or flat (tag-like), with full i18n labels, REST API support, and permalink slugs.
Register a custom taxonomy in WordPress using register_taxonomy() — hierarchical (category-like) or flat (tag-like), with full i18n labels, REST API support, and permalink slugs.
Use this when your custom post type needs grouping or filtering — Project Types for a portfolio, Service Categories for a services page, Resource Topics for a knowledge base. Copy the Step 5 file into inc/ and your taxonomy is live in under 5 minutes.
Building It Step by Step
Don't paste a 60-line registration and hope you understand it. We'll build this in 5 small steps — each one is a working checkpoint you can test before moving on.
We’ll build this in 5 progressive steps. Each one works on its own — paste it into functions.php, flush permalinks, and check your admin sidebar before moving on. We start with the absolute minimum and layer on one new thing each time.
The examples use a project post type. If you’re registering a different CPT — service, resource, team — swap 'project_type' and 'project' for your own slugs.
Step 1: The minimum viable taxonomy. register_taxonomy() takes three arguments: the taxonomy slug ('project_type'), the post type it attaches to ('project'), and an args array. The only required args are labels (at minimum name and singular_name) and public => true. Setting hierarchical => true makes it category-like — terms can have parents, and the editor shows checkboxes. Paste this into functions.php, go to Settings → Permalinks and click Save (this flushes rewrite rules), then open any Project post — you’ll see a Project Types box in the right sidebar. Add a term and publish.
<?php
add_action( 'init', function() {
register_taxonomy( 'project_type', 'project', array( // 'project' = the post type slug to attach this taxonomy to
'labels' => array(
'name' => 'Project Types',
'singular_name' => 'Project Type',
),
'public' => true,
'hierarchical' => true,
) );
} );
Step 2: Full i18n-ready labels. The labels array controls every piece of text WordPress shows for this taxonomy: the admin sidebar item (menu_name), list table headers (all_items, search_items), the term editor UI (edit_item, add_new_item, new_item_name), and empty-state messages (not_found). Hierarchical taxonomies get two extra labels flat ones don’t have: parent_item (shown in the parent term dropdown on the term edit screen) and its colon variant. Wrapping every string in __() makes the taxonomy immediately translatable — a translator adds a .po file and all labels switch to the new language without touching this code.
<?php
add_action( 'init', function() {
register_taxonomy( 'project_type', 'project', array( // 'project' = the post type slug to attach this taxonomy to
'labels' => array(
'name' => __( 'Project Types', 'textdomain' ),
'singular_name' => __( 'Project Type', 'textdomain' ),
'search_items' => __( 'Search Project Types', 'textdomain' ),
'all_items' => __( 'All Project Types', 'textdomain' ),
'parent_item' => __( 'Parent Project Type', 'textdomain' ),
'edit_item' => __( 'Edit Project Type', 'textdomain' ),
'add_new_item' => __( 'Add New Project Type', 'textdomain' ),
'new_item_name' => __( 'New Project Type Name', 'textdomain' ),
'menu_name' => __( 'Project Types', 'textdomain' ),
'not_found' => __( 'No project types found.', 'textdomain' ),
),
'public' => true,
'hierarchical' => true,
) );
} );
Step 3: Custom URL slug, REST API support, and admin column. Three new args: rewrite => array( 'slug' => 'project-type' ) sets the front-end URL prefix — without it, term archives sit at /project_type/web-design/ (the raw slug, underscores and all). With it you get clean URLs like /project-type/web-design/. show_in_rest => true enables the taxonomy panel in the Gutenberg block editor sidebar — without it, the block editor shows no taxonomy UI and editors can’t assign terms. show_admin_column => true adds a Project Types column to the CPT list table in wp-admin, so you can see assigned terms at a glance and filter by clicking them.
<?php
add_action( 'init', function() {
register_taxonomy( 'project_type', 'project', array( // 'project' = the post type slug to attach this taxonomy to
'labels' => array( /* full labels from Step 2 */ ),
'public' => true,
'hierarchical' => true,
'rewrite' => array( 'slug' => 'project-type' ),
'show_in_rest' => true,
'show_admin_column' => true,
) );
} );
Step 4: A flat (tag-like) taxonomy. Change hierarchical to false. That one boolean flips the entire editor UI from a checkbox list with a parent selector to a free-text tag input — the same UI as WordPress’s built-in Tags. Flat taxonomies have no parent/child term relationships, so the parent_item label is unused and removed. In this step we register a second taxonomy — project_tag — alongside the hierarchical one. A project can belong to a Project Type and carry Project Tags at the same time, pulling from two independent grouping systems. Use flat taxonomies for things that don’t have hierarchy: tech stack tags, industry labels, feature flags.
<?php
add_action( 'init', function() {
register_taxonomy( 'project_tag', 'project', array( // 'project' = the post type slug to attach this taxonomy to
'labels' => array(
'name' => __( 'Project Tags', 'textdomain' ),
'singular_name' => __( 'Project Tag', 'textdomain' ),
'search_items' => __( 'Search Project Tags', 'textdomain' ),
'all_items' => __( 'All Project Tags', 'textdomain' ),
'edit_item' => __( 'Edit Project Tag', 'textdomain' ),
'add_new_item' => __( 'Add New Project Tag', 'textdomain' ),
'new_item_name' => __( 'New Project Tag Name', 'textdomain' ),
'menu_name' => __( 'Project Tags', 'textdomain' ),
'not_found' => __( 'No project tags found.', 'textdomain' ),
),
'public' => true,
'hierarchical' => false,
'rewrite' => array( 'slug' => 'project-tag' ),
'show_in_rest' => true,
'show_admin_column' => true,
) );
} );
Step 5: Everything together — the production boilerplate. Steps 1–4 are teaching code — each introduces a single concept. This is the complete production file: registration moves into a named function, and three apply_filters() calls let child themes override labels, the rewrite slug, and the attached post types without editing this file. A child theme that wants the taxonomy on its case_study post type too adds one line: add_filter( 'theme_project_type_objects', function( $t ) { $t[] = 'case_study'; return $t; } ). Parent theme files stay unchanged — safe to update.
<?php
/**
* Full file: inc/taxonomy-project-type.php
* Steps 1–4 combined — copy this into your theme as the production version.
*/
function theme_register_project_type_taxonomy() {
// ── 1. Labels ────────────────────────────────────────────────────────
$labels = apply_filters( 'theme_project_type_labels', array(
'name' => __( 'Project Types', 'theme' ),
'singular_name' => __( 'Project Type', 'theme' ),
'search_items' => __( 'Search Project Types', 'theme' ),
'all_items' => __( 'All Project Types', 'theme' ),
'parent_item' => __( 'Parent Project Type', 'theme' ),
'edit_item' => __( 'Edit Project Type', 'theme' ),
'add_new_item' => __( 'Add New Project Type', 'theme' ),
'new_item_name' => __( 'New Project Type Name', 'theme' ),
'menu_name' => __( 'Project Types', 'theme' ),
'not_found' => __( 'No project types found.', 'theme' ),
) );
// ── 2. Register ──────────────────────────────────────────────────────
$slug = apply_filters( 'theme_project_type_slug', 'project-type' );
$objects = apply_filters( 'theme_project_type_objects', array( 'project' ) );
register_taxonomy( 'project_type', $objects, array(
'labels' => $labels,
'public' => true,
'hierarchical' => true,
'rewrite' => array( 'slug' => $slug ),
'show_in_rest' => true,
'show_admin_column' => true,
) );
}
add_action( 'init', 'theme_register_project_type_taxonomy' );
Where to put the code
Save Step 5 as inc/taxonomy-project-type.php and require it from functions.php:
require_once get_template_directory() . '/inc/taxonomy-project-type.php';
After requiring the file, visit Settings → Permalinks and click “Save Changes” to flush the rewrite rules. Without this step, term archive URLs like /project-type/web-design/ will return a 404.
How It Works — Why Each Decision Matters
Five decisions, each with a real reason. Below we break down the function signature and timing, the labels system, URL rewriting, the hierarchical switch, and child-theme-safe filter hooks.
Five decisions, each with a real reason. Below we break down the function signature and timing, the labels system, URL rewriting, the hierarchical switch, and child-theme-safe filter hooks.
register_taxonomy() — function signature, timing, and minimum args
register_taxonomy() is WordPress’s API for creating content grouping systems beyond the built-in Categories and Tags. It takes three arguments: the taxonomy slug (a lowercase string, max 32 characters, no spaces — 'project_type'), the post type it attaches to ('project' or an array of post types), and a configuration args array. WordPress stores the taxonomy in the global $wp_taxonomies object on every request and generates admin UI panels, URL rewrite rules, and database query capabilities from these args. The function must be called on or after the init hook — WordPress’s rewrite system and post type registry aren’t initialized until then. The minimum viable args are public => true (makes terms visible in admin and front-end archives) and labels with at least name and singular_name. Every other arg falls back to a WordPress default.
Labels — UI contexts, i18n, and hierarchical-only labels
The labels array maps to specific UI contexts. menu_name controls the admin sidebar item. all_items and search_items appear in the list table header. edit_item, add_new_item, and new_item_name appear on the term edit screen. not_found shows in the metabox when no terms match a search. Hierarchical taxonomies add two labels flat ones omit: parent_item appears in the parent dropdown on the term edit screen, and parent_item_colon is the same label with a colon appended, used in the quick-edit form. If you omit any label, WordPress falls back to a generic phrase from the built-in category or tag labels — which is why the editor might read “Add New Category” instead of “Add New Project Type” if add_new_item is missing. Wrapping every string in __() with your text domain makes every label translatable the moment a translator adds a .po file.
rewrite, show_in_rest, show_admin_column — what each unlocks
Rewrite rules translate your taxonomy into navigable URLs. Without rewrite, WordPress uses the raw taxonomy slug as the URL segment — /project_type/ — including underscores and matching the internal identifier exactly. The slug inside the rewrite array sets a human-friendly prefix: array( 'slug' => 'project-type' ) produces clean URLs like /project-type/web-design/. Like post type rewrites, taxonomy rewrites are stored in the rewrite_rules option in the database and require a flush when changed — visit Settings → Permalinks and click Save. show_in_rest => true registers REST endpoints at /wp-json/wp/v2/project_type and enables the Gutenberg sidebar panel. Without it, the block editor shows no taxonomy UI at all. show_admin_column => true adds a taxonomy column to the CPT list table — clicking a term in that column filters the list to posts assigned that term, which speeds up editorial workflows considerably.
hierarchical — four things that change when you flip the boolean
The hierarchical boolean changes four things at once. In the admin editor: true renders a scrollable checkbox list with a parent selector beneath it (identical to the Categories metabox); false renders a comma-delimited tag input that auto-suggests existing terms (identical to the Tags metabox). In the database: hierarchical terms store a parent column in wp_terms — flat terms always have parent = 0. In templates: get_the_terms() returns a flat array for both types, but hierarchical results can be passed to get_term_children() to walk the parent-child tree. In URL structure: hierarchical taxonomies support nested term slugs like /project-type/digital/web-design/; flat taxonomies produce single-level slugs only. Choose hierarchical when terms have natural groupings (Web → Front-end → Vue.js). Choose flat when terms are independent labels without a hierarchy (healthcare, retail, fintech).
Filter hooks — child-theme extensibility without editing parent files
Three apply_filters() calls expose the taxonomy’s configurable parts as extension points. A child theme overrides any of them with a single add_filter() call — no file editing, no update conflicts. The theme_project_type_labels filter wraps the entire labels array, so a multilingual setup can swap all labels at once. The theme_project_type_slug filter controls the URL rewrite — useful when a white-label client wants /work-category/ instead of /project-type/. The theme_project_type_objects filter controls which post types the taxonomy attaches to — a child theme that adds a case_study post type can register it under the same taxonomy without touching the parent theme: add_filter( 'theme_project_type_objects', function( $types ) { $types[] = 'case_study'; return $types; } ). The parent theme ships with sensible defaults; every client customization lives in the child theme’s filters.
Setup & Integration
One PHP file, one require line, one permalink flush. Here's the folder structure, integration steps, and how to confirm everything is working.
One PHP file, one require line, one permalink flush. Here’s the folder structure, integration steps, and testing checklist.
Place the file in inc/ and require it from functions.php
Place the file and require it. Copy the Step 5 code into inc/taxonomy-project-type.php. In functions.php, add this line near the top — after the theme setup function but before any template-specific includes:
require_once get_template_directory() . '/inc/taxonomy-project-type.php';
For multiple taxonomies, create one file per taxonomy (taxonomy-project-tag.php, taxonomy-service-type.php) and require each. This keeps each taxonomy’s registration isolated — easier to debug and easy to remove without affecting the others.
Flush the rewrite rules — the one step everyone forgets
Flush the rewrite rules. After adding the require line, visit Settings → Permalinks in the WordPress admin and click “Save Changes” — you don’t need to change any setting, just save. This calls flush_rewrite_rules() internally, rebuilding the URL routing table to include your new taxonomy slug. Without this step, term archive URLs like /project-type/web-design/ return a 404. On a staging-to-production deploy, trigger this manually from the Permalinks page after deploy, or add a one-time flush_rewrite_rules() call to your deploy script.
Test in the admin — add a term, assign it, and check the archive
Test in the admin. Go to Project Types in the wp-admin sidebar (it appears below the Projects menu item). Click “Add New Project Type”, enter a name (e.g. “Web Design”), and save. Open any published Project post and assign the term from the Project Types metabox on the right. Update the post, then visit the term archive at yoursite.com/project-type/web-design/ — you should see the post listed. If you get a 404, go back to step 2 and flush permalinks. For fast debugging, add var_dump( get_terms( array( 'taxonomy' => 'project_type' ) ) ); to a template to confirm the taxonomy is registered and the term was created.
Taxonomy slug ≠ rewrite slug ≠ term slug — three different things.
The taxonomy slug ('project_type') is the internal identifier — use it in register_taxonomy(), get_the_terms(), get_terms(), and tax_query. The rewrite slug ('project-type') is the URL prefix for term archives — it appears in URLs only. The term slug ('web-design') is the URL segment for each individual term, set when you create the term in wp-admin. In tax_query, always reference the taxonomy slug (internal), not the rewrite slug (URL). They’re easy to mix up because they look similar.
Making It Your Own
The taxonomy is registered — now make it do real work. Query posts by term, display assigned terms in templates, create a taxonomy archive, and attach to multiple post types.
Filter posts by taxonomy term using tax_query
Query posts by taxonomy term using tax_query. Pass a nested array to WP_Query. Set 'field' => 'slug' to match by term slug — slugs don’t change when a term is renamed, so they’re safer than matching by name. To match multiple terms, pass an array to terms and set 'operator' => 'IN' (post has any of these terms) or 'operator' => 'AND' (post must have all listed terms). For filtering by two taxonomies at once, add a second array inside tax_query and set 'relation' => 'AND' at the top level.
<?php
$args = array(
'post_type' => 'project',
'tax_query' => array(
array(
'taxonomy' => 'project_type',
'field' => 'slug',
'terms' => 'web-design',
),
),
);
$query = new WP_Query( $args );
if ( $query->have_posts() ) {
while ( $query->have_posts() ) {
$query->the_post();
?>
<h2><?php echo esc_html( get_the_title() ); ?></h2>
<?php
}
wp_reset_postdata();
}
Display assigned terms in a template with get_the_terms()
Display assigned terms in a template. Use get_the_terms() inside any post loop. It returns an array of term objects — each has name, slug, term_id, and description properties. get_term_link() returns the full URL to the term’s archive page. Always check for is_wp_error() — if the taxonomy slug is wrong or the taxonomy isn’t registered, get_the_terms() returns a WP_Error object, which crashes a foreach.
<?php
$terms = get_the_terms( get_the_ID(), 'project_type' );
if ( $terms && ! is_wp_error( $terms ) ) {
?>
<ul class="project-types">
<?php
foreach ( $terms as $term ) {
$url = get_term_link( $term );
?>
<li>
<a href="<?php echo esc_url( $url ); ?>">
<?php echo esc_html( $term->name ); ?>
</a>
</li>
<?php
}
?>
</ul>
<?php
}
Create a taxonomy archive template — taxonomy-project_type.php
Create a taxonomy archive template. WordPress checks for template files in this order: taxonomy-{taxonomy}-{term}.php → taxonomy-{taxonomy}.php → taxonomy.php → archive.php → index.php. Create taxonomy-project_type.php in your theme root and it loads automatically for every Project Type term archive — no routing code needed. Use get_queried_object() inside the template to get the current term and display its name or description in the archive header.
<?php
get_header();
$term = get_queried_object();
?>
<main class="taxonomy-archive">
<h1><?php echo esc_html( $term->name ); ?></h1>
<?php if ( $term->description ) { ?>
<p class="taxonomy-desc"><?php echo esc_html( $term->description ); ?></p>
<?php } ?>
<?php if ( have_posts() ) { ?>
<ul class="post-list">
<?php
while ( have_posts() ) {
the_post();
?>
<li><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></li>
<?php
}
?>
</ul>
<?php } ?>
</main>
<?php
get_footer();
Attach the taxonomy to multiple post types
Attach the taxonomy to multiple post types. Pass an array as the second argument to register_taxonomy(). Terms you create for project_type are shared across all attached post types — the same term entry in the database. The taxonomy metabox appears in the editor for all listed post types. You can also attach a taxonomy after registration using register_taxonomy_for_object_type( 'project_type', 'post' ) — useful when you want the registration in one file and the attachment in another (e.g. a plugin that adds a taxonomy to a theme’s CPT).
<?php
add_action( 'init', function() {
register_taxonomy( 'project_type', array( 'project', 'post' ), array(
'labels' => array(
'name' => __( 'Project Types', 'textdomain' ),
'singular_name' => __( 'Project Type', 'textdomain' ),
),
'public' => true,
'hierarchical' => true,
'rewrite' => array( 'slug' => 'project-type' ),
'show_in_rest' => true,
'show_admin_column' => true,
) );
} );
3 pitfalls that will silently break your taxonomy:
1. Forgetting to flush permalinks after registration. Rewrite rules are stored in the database and only rebuild when flush_rewrite_rules() runs. Adding a new taxonomy but skipping the Permalinks → Save step means every term archive URL returns 404. This is the most common taxonomy deployment bug — it looks completely broken but takes 10 seconds to fix.
2. Taxonomy slug collision with a built-in taxonomy. WordPress registers category, post_tag, nav_menu, link_category, and post_format as built-in taxonomy slugs. If your slug matches any of these, your register_taxonomy() call silently overwrites the built-in — category archives break, nav menus stop working. Always use a prefix or check first: taxonomy_exists( 'your_slug' ) returns true if the slug is already taken. Prefixed slugs like project_type or theme_category are safe.
3. Registering the taxonomy conditionally or after init. The registration must run on every request, unconditionally, on the init hook. If you wrap it in if ( is_admin() ), the taxonomy won’t exist on front-end requests — archive pages and REST API calls fail. If you call it inside a template file or a late-firing hook, WordPress builds its rewrite table before your registration runs and the taxonomy URL is never added. Always use add_action( 'init', 'your_function' ).
Performance — get_the_terms() is cached; get_terms() in a loop is not.
get_the_terms() uses WordPress’s object term cache. The first call per post per taxonomy hits the database; subsequent calls within the same request return the cached result. In a WP_Query loop, WordPress pre-populates the term cache for all queried posts in a single batch query before the loop starts — so calling get_the_terms() inside a loop of 12 posts costs 1 database query total, not 12.
get_terms() (fetches all terms for a taxonomy, not for a specific post) does NOT benefit from per-post caching. Calling it inside a post loop triggers a new query on every iteration. Pre-fetch it once outside the loop:
$all_types = get_terms( array( 'taxonomy' => 'project_type', 'hide_empty' => false ) );
// One query — then reference $all_types inside the loop.
No comments yet. Be the first.