Custom themes often need more than a single category name. Breadcrumbs, archive headers, and related-post filters all want the parent as well as the leaf term. WordPress stores categories as a hierarchy in the term_taxonomy table, but get_the_category() returns a flat array. You walk the parent property yourself.
This tutorial covers a one-level parent/child link, a full ancestor trail, primary-category selection with common SEO plugins, and optional BreadcrumbList JSON-LD. All output is escaped.
For broader theme and plugin work: WordPress developer.
Why get_the_category() is not enough alone
get_the_category() returns WP_Term objects for the current post (or a given post ID). Each object has term_id, name, slug, and parent. The array is unordered with respect to hierarchy - a child can appear before its parent. If a post sits in Technology → WordPress → Performance, you may get three terms and still not know which one is the deepest leaf for a breadcrumb.
Practical rules:
- Decide which category is “primary” when several are assigned.
- Walk
parentuntil it is0to build the trail for that primary term. - Never echo names or URLs without
esc_html()/esc_url().
Method 1: current category plus immediate parent
Paste into a child theme functions.php or a small site-specific plugin:
function wppoland_get_category_hierarchy() {
$categories = get_the_category();
if ( empty( $categories ) ) {
return false;
}
$category = $categories[0];
$output = '';
if ( $category->parent ) {
$parent = get_category( $category->parent );
if ( $parent && ! is_wp_error( $parent ) ) {
$output .= '<a href="' . esc_url( get_category_link( $parent->term_id ) ) . '">' . esc_html( $parent->name ) . '</a> » ';
}
}
$output .= '<a href="' . esc_url( get_category_link( $category->term_id ) ) . '">' . esc_html( $category->name ) . '</a>';
return $output;
}Usage in a template
<?php
$hierarchy = wppoland_get_category_hierarchy();
if ( $hierarchy ) {
echo '<nav class="category-breadcrumb" aria-label="Category">' . $hierarchy . '</nav>';
}
?>Example output: Technology » WordPress
This is enough for two-level trees. For deeper trees, use the full trail below.
Method 2: full ancestor trail with a while loop
function wppoland_get_full_category_trail( $category_id ) {
$trail = array();
while ( $category_id ) {
$category = get_category( $category_id );
if ( ! $category || is_wp_error( $category ) ) {
break;
}
array_unshift( $trail, $category );
$category_id = (int) $category->parent;
}
$parts = array();
foreach ( $trail as $cat ) {
$parts[] = '<a href="' . esc_url( get_category_link( $cat->term_id ) ) . '">' . esc_html( $cat->name ) . '</a>';
}
return implode( ' » ', $parts );
}Call it with the primary category ID:
$cats = get_the_category();
if ( ! empty( $cats ) ) {
echo wppoland_get_full_category_trail( $cats[0]->term_id );
}array_unshift keeps root → leaf order. The loop stops when parent is 0.
Method 3: get_category_parents() for quick markup
Core already ships a helper:
$cats = get_the_category();
if ( ! empty( $cats ) ) {
echo get_category_parents( $cats[0]->term_id, true, ' » ' );
}Arguments: term ID, whether to link names, separator. Good for classic themes. Less flexible when you need Schema, custom classes, or to skip the leaf.
Selecting a primary category
Editors often assign several categories. Breadcrumbs should follow one chain.
Yoast SEO
function wppoland_get_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
if ( class_exists( 'WPSEO_Primary_Term' ) ) {
$primary = new WPSEO_Primary_Term( 'category', $post_id );
$term_id = $primary->get_primary_term();
if ( $term_id && ! is_wp_error( $term_id ) ) {
$term = get_category( $term_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
}
$categories = get_the_category( $post_id );
return ! empty( $categories ) ? $categories[0] : null;
}Rank Math
function wppoland_get_rankmath_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$term_id = (int) get_post_meta( $post_id, 'rank_math_primary_category', true );
if ( $term_id ) {
$term = get_category( $term_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
$categories = get_the_category( $post_id );
return ! empty( $categories ) ? $categories[0] : null;
}Wire primary selection into the trail function so archives, related posts, and breadcrumbs agree on the same path.
BreadcrumbList JSON-LD (optional SEO)
Visible breadcrumbs help humans. Matching structured data helps search engines understand the path. Emit JSON-LD once in wp_head on singles:
function wppoland_schema_category_breadcrumbs() {
if ( ! is_singular( 'post' ) ) {
return;
}
$category = wppoland_get_primary_category();
if ( ! $category ) {
return;
}
$trail = array();
$current = $category;
while ( $current ) {
array_unshift( $trail, $current );
$current = $current->parent ? get_category( $current->parent ) : null;
if ( $current && is_wp_error( $current ) ) {
$current = null;
}
}
$items = array();
$position = 1;
$items[] = array(
'@type' => 'ListItem',
'position' => $position++,
'name' => 'Home',
'item' => home_url( '/' ),
);
foreach ( $trail as $cat ) {
$items[] = array(
'@type' => 'ListItem',
'position' => $position++,
'name' => $cat->name,
'item' => get_category_link( $cat->term_id ),
);
}
$items[] = array(
'@type' => 'ListItem',
'position' => $position,
'name' => get_the_title(),
'item' => get_permalink(),
);
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $items,
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema ) . '</script>' . "\n";
}
add_action( 'wp_head', 'wppoland_schema_category_breadcrumbs' );If your theme or SEO plugin already outputs BreadcrumbList, skip this to avoid duplicate graphs.
Custom taxonomies note
Categories are one hierarchical taxonomy. For a custom taxonomy, use get_the_terms( $post_id, 'your_tax' ) and get_term_link(). Parent walking is the same via $term->parent. Do not call get_category() on non-category terms.
Edge cases to test
- Post with no categories - return early; do not print empty nav.
- Orphaned parent ID (deleted parent) -
get_category()can return an error; checkis_wp_error(). - Multisite / language plugins - archive links must respect the current site URL (
get_category_linkalready does). - Block themes - prefer a shortcode or a small block that calls these helpers, rather than editing
single.phponly.
Performance
Category lookups are cheap and object-cached in normal WordPress requests. Avoid calling the full trail builder dozens of times in a loop of posts; compute once per post or store a transient if you do heavy Schema work on archive pages.
Working inside the Loop vs with an arbitrary post ID
All of the helpers above assume the global post is set (inside the Loop, or after setup_postdata). On widgets, REST callbacks, and CLI scripts, pass an explicit ID:
function wppoland_get_category_hierarchy_for_post( $post_id ) {
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return false;
}
$primary = $categories[0];
if ( function_exists( 'wppoland_get_primary_category' ) ) {
$maybe = wppoland_get_primary_category( $post_id );
if ( $maybe ) {
$primary = $maybe;
}
}
return wppoland_get_full_category_trail( $primary->term_id );
}This matters on archive cards where you loop many posts: never call get_the_category() without the current card’s ID after the Loop advances.
Styling and accessibility
Use a <nav> with an accessible name (aria-label="Category" or a translated string). Keep the visible separator (» or /) outside the link text so screen readers do not hear “Technology right-pointing double angle quotation mark WordPress” as one run-on phrase - putting separators between anchors (as in the implode version) is clearer.
Do not rely on color alone to show the current crumb. The last item can be plain text without a link if you are already on that archive; on single posts, linking the leaf category to its archive is usually what editors expect.
Polylang and WPML
On multilingual sites, get_category_link() returns the URL for the term in the current language when the translation plugin is configured correctly. Still verify that primary-category meta from Yoast/Rank Math is per-language (or duplicated) after a translation workflow. A Polish primary term ID on an English post is a common migration bug.
When not to build breadcrumbs from categories
If the site’s IA is driven by custom taxonomies, landing pages, or a manual breadcrumb plugin, do not also emit a second category trail. Duplicate BreadcrumbList JSON-LD and conflicting visible crumbs confuse both users and rich-result validators. Pick one source of truth per template.
Summary
get_the_category()gives terms; hierarchy lives inparent.- One parent level: fetch
$category->parentand link both. - Full trail: walk parents with a while loop or use
get_category_parents(). - Multiple categories: resolve primary via Yoast or Rank Math, then fall back.
- Escape every name and URL; optionally emit BreadcrumbList once.
Need this wired into a production theme or a block pattern? Contact WPPoland with the theme slug and whether you use classic or block templates.







