A category archive can be more useful than a reverse-chronological wall of posts. When the current category has meaningful children—Linux distributions, Android subsystems, cloud providers—showing those paths first gives the visitor a choice instead of making them scroll and guess.
Direct children and all descendants are different menus
Use
parent => $term_idfor one navigation level directly beneath the current category.Use
child_of => $term_idwhen a flat result containing grandchildren and deeper descendants is genuinely desired.A recursive tree needs hierarchy-aware rendering; a flat alphabetical list can hide parent/child relationships.
hide_empty => trueomits terms with no published objects according to WordPress term counts.Choose the hierarchy based on the visitor’s task, not simply the number of terms available.
Classic theme: keep the query in a reusable function
<?php
/**
* Renders direct child categories for the current category archive.
*/
function lynxbee_render_child_category_nav(): void {
if ( ! is_category() ) {
return;
}
$current_term = get_queried_object();
if (
! $current_term instanceof WP_Term ||
'category' !== $current_term->taxonomy
) {
return;
}
$children = get_terms(
array(
'taxonomy' => 'category',
'parent' => $current_term->term_id,
'hide_empty' => true,
'orderby' => 'name',
'order' => 'ASC',
)
);
if ( is_wp_error( $children ) || empty( $children ) ) {
return;
}
echo '<nav class="child-category-nav" aria-labelledby="child-category-title">';
echo '<h2 id="child-category-title">' .
esc_html__( 'Explore this topic', 'your-theme' ) .
'</h2><ul>';
foreach ( $children as $child ) {
$url = get_term_link( $child );
if ( is_wp_error( $url ) ) {
continue;
}
printf(
'<li><a href="%1$s">%2$s</a></li>',
esc_url( $url ),
esc_html( $child->name )
);
}
echo '</ul></nav>';
}Every early return protects a real boundary
is_category()prevents the renderer from appearing on unrelated archives or singular pages.get_queried_object()returns the archive subject; the loop’s global$postis a different object.The
WP_Termand taxonomy checks protect code reused in broader templates.get_terms()can returnWP_Error; checking onlyempty()is insufficient.get_term_link()can also return an error and must be validated before escaping/output.
Load the helper and render it in category.php
<?php
require_once get_theme_file_path( '/inc/category-navigation.php' );Use the active theme path intentionally
get_theme_file_path()resolves a file from the active theme and supports child-theme overrides.require_onceavoids duplicate function declarations.Do not paste the same query into several templates; fixes and accessibility changes will drift.
Prefix global PHP functions to reduce collisions with themes and plugins.
<?php get_header(); ?>
<main id="primary" class="site-main">
<?php get_template_part( 'template-parts/archive/header' ); ?>
<?php lynxbee_render_child_category_nav(); ?>
<?php if ( have_posts() ) : ?>
<?php while ( have_posts() ) : ?>
<?php the_post(); ?>
<?php get_template_part( 'template-parts/content', 'excerpt' ); ?>
<?php endwhile; ?>
<?php the_posts_pagination(); ?>
<?php else : ?>
<?php get_template_part( 'template-parts/content', 'none' ); ?>
<?php endif; ?>
</main>
<?php get_footer(); ?>Place taxonomy navigation outside the post loop
The child-category menu describes the archive, not an individual post.
Keeping it before the loop prevents repetition for every result.
The main query and pagination remain untouched.
A theme-specific template part preserves the existing visual system.
Edit a child theme or maintained custom theme so parent-theme updates do not overwrite the change.
Limit the feature to one selected parent archive
$featured_parent = get_category_by_slug( 'engineering' );
if (
! $featured_parent instanceof WP_Term ||
$current_term->term_id !== $featured_parent->term_id
) {
return;
}A slug is clearer than a database-specific ID
Term IDs differ between development, staging, imports, and production.
A stable, controlled slug makes configuration portable and readable.
Verify the lookup result because a deleted/renamed category returns false.
If editors should configure the category, store a validated term ID in an option instead of editing PHP.
If the requirement includes every archive beneath the parent, use
term_is_ancestor_of()rather than equality.
Show counts without corrupting accessible names
printf(
'<li><a href="%1$s"><span>%2$s</span> <span aria-hidden="true">(%3$s)</span><span class="screen-reader-text"> %4$s</span></a></li>',
esc_url( $url ),
esc_html( $child->name ),
esc_html( number_format_i18n( $child->count ) ),
esc_html( sprintf(
_n( '%s post', '%s posts', $child->count, 'your-theme' ),
number_format_i18n( $child->count )
) )
);Counts are context, not decoration
number_format_i18n()formats numbers for the site locale._n()selects singular or plural translation based on the count.The visual parentheses are hidden from assistive technology and replaced with readable text.
Term counts may include only directly assigned posts unless counts are padded/configured differently.
Do not make sparse categories look broken; omit counts when they do not help decisions.
Include empty categories only with a reason
hide_empty => truekeeps navigation from leading to empty archives in ordinary editorial sites.Use
falsefor a curriculum, product taxonomy, or planned information architecture where empty destinations have purposeful content.A custom taxonomy’s object types and term-count callbacks affect what “empty” means.
Cached/object-count changes may lag during imports or unusual integrations; recount terms when operational evidence shows stale counts.
If empty archive pages are indexable, ensure they offer unique value instead of thin search landing pages.
Custom hierarchical taxonomies
$current_term = get_queried_object();
if ( $current_term instanceof WP_Term && 'topic' === $current_term->taxonomy ) {
$children = get_terms(
array(
'taxonomy' => 'topic',
'parent' => $current_term->term_id,
'hide_empty' => true,
)
);
}Use the taxonomy registered by the application
The core category taxonomy name is
category; custom taxonomy names are registration-specific.A taxonomy must be hierarchical for parent/child navigation to make semantic sense.
Custom taxonomy classic templates follow
taxonomy-{taxonomy}-{term}.php, then broader fallbacks.Core category archives use the separate category template hierarchy.
Share rendering logic by accepting the validated taxonomy and term as parameters.
Block themes need a block-aware integration
Block themes resolve category templates from the database, child theme, or
/templates/category*.htmlhierarchy.A PHP call cannot simply be pasted into an HTML block template.
For a reusable feature, register a dynamic server-rendered block whose render callback performs the validated term query.
A shortcode block can bridge an existing site, but a purpose-built block gives editors clearer controls and better preview behavior.
Keep output escaping and archive-context validation inside the server render callback; block attributes are untrusted input.
A minimal dynamic-block render callback
register_block_type(
__DIR__ . '/build/child-category-navigation',
array(
'render_callback' => function (): string {
if ( ! is_category() ) {
return '';
}
ob_start();
lynxbee_render_child_category_nav();
return (string) ob_get_clean();
},
)
);Server rendering keeps archives current
The block renders against the queried archive term on each page request/cache generation.
ob_start()captures the existing echo-based renderer; returning markup is the block API contract.A production plugin should register on
init, includeblock.json, translations, styles, and an editor preview/placeholder.Full-page caches still need normal invalidation when terms change.
Do not cache one archive’s rendered HTML under a key shared by every category.
Performance and cache behavior
WordPress term queries participate in object caching; avoid premature custom transients for a small taxonomy.
Avoid N+1 queries such as fetching children separately for every term when one structured query can serve the page.
Large taxonomies may need pagination, curated navigation, or cached trees instead of rendering thousands of links.
Term creation, deletion, edits, and object assignment must invalidate any custom cache keys.
Measure database/object-cache behavior in the actual hosting stack before adding complexity.
Style the list without erasing semantics
.child-category-nav ul {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
gap: 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.child-category-nav a {
display: block;
padding: 0.875rem 1rem;
border: 1px solid currentColor;
border-radius: 0.5rem;
}
.child-category-nav a:focus-visible {
outline: 0.2rem solid currentColor;
outline-offset: 0.2rem;
}Responsive grid, ordinary links
The
<nav>, heading, list, and anchors remain intact for assistive technology.auto-fitlets cards wrap without viewport-specific column counts.Visible keyboard focus is essential; do not remove outlines without an equivalent.
Check long translations and category names at narrow widths.
Use theme design tokens/colors where available so contrast and dark mode stay consistent.
Troubleshooting
No children appear: confirm the current term actually has direct children and reconsider
hide_empty.Grandchildren are missing:
parentintentionally returns one level; use a recursive hierarchy orchild_ofdeliberately.Wrong archive is detected: use
get_queried_object(), not$postinside the loop.Links print an error or break markup: handle
WP_Error, then applyesc_urlandesc_html.Changes vanish after theme update: move customizations to a child theme, custom plugin, or supported hook/block.
Block template shows raw PHP: block HTML templates do not execute arbitrary PHP; use a dynamic block.
Counts look stale: inspect imports/object assignments, term recounting, persistent object cache, and full-page cache invalidation.
Every archive shows the menu: keep
is_category, taxonomy, and selected-parent checks inside the renderer.
Verification checklist
Direct-child versus descendant behavior matches the information architecture.
Empty-term policy, ordering, count meaning, and selected parent are explicit.
Every query/link error path is handled and every dynamic value is context-escaped.
The navigation has a label, semantic list, visible focus, responsive layout, and meaningful link names.
Classic or block-theme placement follows the active theme architecture and survives updates.
Large-taxonomy performance, page/object caches, multilingual terms, and SEO treatment are tested.
Official WordPress references
get_queried_object() documents the archive subject returned by the main query.
get_terms() documents taxonomy queries and
WP_Errorreturn behavior.WP_Term_Query parameters defines
parent,child_of, ordering, and empty-term options.get_term_link() documents term URL generation and error returns.
WordPress template hierarchy explains category handling in classic and block themes.
Dynamic blocks explains server-rendered block output.
Comments and corrections