En temas WordPress a medida, casi siempre acabas mostrando más que el nombre de una sola categoría. Las migas de pan del single, el encabezado del archivo y los bloques de “entradas relacionadas” necesitan el término hoja y también sus padres. WordPress guarda la jerarquía en term_taxonomy.parent, pero get_the_category() te entrega un array plano. Tú decides qué término es la hoja y cómo subes por los ancestros.
Este tutorial cubre los fallos habituales de get_the_category(), el atajo get_category_parents(), el control fino con get_ancestors(), la categoría primaria (Yoast y Rank Math), el JSON-LD de BreadcrumbList, taxonomías personalizadas jerárquicas y cache. Todo el HTML de ejemplo va escapado.
Si trabajas en proyectos de tema o plugin a escala: desarrollador WordPress.
Por qué get_the_category() no basta sola
get_the_category() (y su hermano get_the_category( $post_id )) devuelve un array de objetos WP_Term asociados a la entrada. Cada término trae term_id, name, slug y parent. Lo que no trae es un orden jerárquico garantizado: un hijo puede aparecer antes que su padre en el array. Si una entrada vive en Tecnología → WordPress → Rendimiento, puedes recibir tres términos y aún así no saber cuál es la hoja más profunda para la miga de pan.
Otro fallo clásico: asumir que la entrada tiene exactamente una categoría. El editor permite muchas. Si tomas $categories[0] sin más, el orden depende de cómo WordPress las devolvió en esa petición, no de la intención editorial. En sitios españoles con redacción grande (medios, revistas sectoriales, blogs de producto) eso se nota enseguida: la miga salta entre ramas distintas según la entrada.
Tercer fallo: llamar a get_the_category() fuera del Loop sin pasar el ID. En widgets, REST callbacks o bloques renderizados en el pie, get_the_ID() puede ser cero o apuntar a otra entrada. Pasa siempre el $post_id explícito cuando no estés en single.php / the_post().
Cuarto fallo: olvidar que un término con parent = 0 es raíz. No es un error. Si construyes el rastro con un while ( $current->parent ) y el término ya es raíz, el bucle no entra y eso es correcto. El bug aparece cuando alguien fuerza un padre inventado o concatena separadores vacíos.
Quinto fallo: no escapar la salida. esc_html() en el nombre y esc_url() en el enlace no son opcionales. Un nombre de categoría malicioso (poco habitual, pero posible con usuarios colaboradores) rompe el markup o abre XSS en el front.
Documentación de referencia: get_the_category().
Método 1: categoría actual y padre inmediato
Para un rastro corto (padre » hoja) basta con leer el primer término (o el primario, ver más abajo) y, si parent no es cero, cargar el padre con get_category().
function wppoland_get_category_hierarchy( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return '';
}
$category = $categories[0];
$parts = array();
if ( $category->parent ) {
$parent = get_category( $category->parent );
if ( $parent && ! is_wp_error( $parent ) ) {
$parts[] = sprintf(
'<a href="%s">%s</a>',
esc_url( get_category_link( $parent->term_id ) ),
esc_html( $parent->name )
);
}
}
$parts[] = sprintf(
'<a href="%s">%s</a>',
esc_url( get_category_link( $category->term_id ) ),
esc_html( $category->name )
);
return implode( ' <span class="separator" aria-hidden="true">»</span> ', $parts );
}Uso en plantilla:
$hierarchy = wppoland_get_category_hierarchy();
if ( $hierarchy ) {
echo '<nav class="category-breadcrumb" aria-label="Migas de pan">' . $hierarchy . '</nav>';
}Esto produce algo como: Tecnología » WordPress. Sirve para cabeceras simples. No cubre abuelos ni bisabuelos; para eso pasan a los métodos siguientes.
Método 2: get_category_parents() para markup rápido
get_category_parents() es la función de núcleo pensada exactamente para esto: dada una categoría, devuelve una cadena con todos los ancestros hasta la raíz, opcionalmente con enlaces y un separador. Ideal para prototipos y temas clásicos donde el HTML del rastro no necesita Schema ni clases por nivel.
function wppoland_quick_category_trail( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return '';
}
$leaf_id = (int) $categories[0]->term_id;
// true = enlaces, ' » ' = separador, false = no incluir el propio término al final si quieres solo padres
return get_category_parents( $leaf_id, true, ' » ', false );
}Limitaciones prácticas:
- Solo funciona con la taxonomía
category. En taxonomías custom no existe un equivalente con el mismo nombre. - Devuelve HTML (o texto) ya ensamblado. Si necesitas JSON-LD, microdatos por ítem o filtrar el nivel raíz, tendrás que parsear la cadena o, mejor, no usarla.
- El separador y el escape interno siguen las reglas de núcleo; personalizar clases CSS por cada eslabón es incómodo.
Documentación: get_category_parents().
Si el diseño debe omitir el nivel superior (algunas cabeceras empiezan en el hijo, no en “Inicio » Tecnología”), esta función tampoco es la mejor herramienta. Ahí gana get_ancestors() más un filtro explícito del primer ID.
Método 3: get_ancestors() para control total
get_ancestors() es la vía genérica. Funciona con cualquier taxonomía jerárquica. Devuelve un array de IDs de ancestros, del padre más cercano hacia arriba. Tú inviertes el array, cargas cada término y decides el markup.
function wppoland_ancestor_trail( $term_id, $taxonomy = 'category' ) {
$term_id = (int) $term_id;
if ( ! $term_id ) {
return array();
}
$ancestor_ids = get_ancestors( $term_id, $taxonomy );
$ancestor_ids = array_reverse( $ancestor_ids ); // raíz primero
$ancestor_ids[] = $term_id; // incluir la hoja
$trail = array();
foreach ( $ancestor_ids as $id ) {
$term = get_term( $id, $taxonomy );
if ( $term && ! is_wp_error( $term ) ) {
$trail[] = $term;
}
}
return $trail;
}
function wppoland_render_ancestor_trail( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return '';
}
$trail = wppoland_ancestor_trail( $categories[0]->term_id, 'category' );
$parts = array();
foreach ( $trail as $term ) {
$parts[] = sprintf(
'<a href="%s">%s</a>',
esc_url( get_category_link( $term->term_id ) ),
esc_html( $term->name )
);
}
return implode( ' <span class="separator" aria-hidden="true">»</span> ', $parts );
}Ventajas frente a un while manual sobre $term->parent:
- Una sola llamada obtiene todos los IDs; el núcleo ya conoce el grafo.
- El mismo patrón sirve para
product_cat,documento_tipou otras taxonomías jerárquicas. - Puedes calcular profundidad con
count( get_ancestors( $id, $taxonomy ) )para elegir la hoja más profunda cuando hay varias categorías asignadas.
Documentación: get_ancestors().
Elegir la hoja más profunda cuando hay varias categorías
Cuando la entrada tiene tres categorías en ramas distintas, $categories[0] es frágil. Una heurística razonable (no perfecta) es elegir el término con más ancestros:
function wppoland_deepest_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$categories = get_the_category( $post_id );
if ( empty( $categories ) ) {
return null;
}
$deepest = $categories[0];
$max = count( get_ancestors( $deepest->term_id, 'category' ) );
foreach ( $categories as $cat ) {
$depth = count( get_ancestors( $cat->term_id, 'category' ) );
if ( $depth > $max ) {
$max = $depth;
$deepest = $cat;
}
}
return $deepest;
}Esto no sustituye una categoría primaria editorial. Solo evita que la miga use un término raíz cuando existe un hijo más específico en la misma entrada.
Selección de la categoría primaria
Los plugins SEO populares guardan qué categoría debe representar la entrada en migas, Open Graph y a veces en el permalink de categoría. Léelos antes de caer al primer elemento del array.
Con Yoast SEO
function wppoland_get_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
if ( class_exists( 'WPSEO_Primary_Term' ) ) {
$primary_term = new WPSEO_Primary_Term( 'category', $post_id );
$primary_cat_id = $primary_term->get_primary_term();
if ( $primary_cat_id && ! is_wp_error( $primary_cat_id ) ) {
$term = get_category( (int) $primary_cat_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
}
return wppoland_deepest_category( $post_id );
}Con Rank Math
function wppoland_get_rankmath_primary_category( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$primary_cat_id = (int) get_post_meta( $post_id, 'rank_math_primary_category', true );
if ( $primary_cat_id ) {
$term = get_category( $primary_cat_id );
if ( $term && ! is_wp_error( $term ) ) {
return $term;
}
}
return wppoland_deepest_category( $post_id );
}En redacciones que usan ambos plugins en entornos distintos (staging con Yoast, producción con Rank Math, o al revés tras una migración), encapsula la lectura detrás de una sola función que pruebe Yoast, luego Rank Math, luego la heurística de profundidad. No hardcodees el meta key en plantillas repartidas por el tema.
Sin plugin SEO, la categoría primaria no existe en el núcleo. WordPress no define “primary” nativo para category. O guardas tu propio post meta, o aceptas la heurística.
BreadcrumbList JSON-LD
Para SEO, el rastro visible y el rastro estructurado deben contar la misma historia. Schema.org define BreadcrumbList con itemListElement de tipo ListItem, cada uno con position, name e item (URL).
function wppoland_schema_breadcrumbs( $post_id = null ) {
if ( ! is_singular( 'post' ) ) {
return;
}
$post_id = $post_id ?: get_the_ID();
$primary = wppoland_get_primary_category( $post_id );
if ( ! $primary ) {
return;
}
$trail = wppoland_ancestor_trail( $primary->term_id, 'category' );
$items = array();
$pos = 1;
$items[] = array(
'@type' => 'ListItem',
'position' => $pos++,
'name' => 'Inicio',
'item' => home_url( '/' ),
);
foreach ( $trail as $cat ) {
$items[] = array(
'@type' => 'ListItem',
'position' => $pos++,
'name' => $cat->name,
'item' => get_category_link( $cat->term_id ),
);
}
$items[] = array(
'@type' => 'ListItem',
'position' => $pos,
'name' => get_the_title( $post_id ),
'item' => get_permalink( $post_id ),
);
$schema = array(
'@context' => 'https://schema.org',
'@type' => 'BreadcrumbList',
'itemListElement' => $items,
);
echo '<script type="application/ld+json">' . wp_json_encode( $schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) . '</script>' . "\n";
}
add_action( 'wp_head', 'wppoland_schema_breadcrumbs', 20 );Notas de producción:
- Emite el bloque solo en
is_singular( 'post' )(o el CPT que corresponda). En archivos y la portada el rastro es otro grafo. - Usa las mismas URLs que el HTML visible. Si el nav enlaza a
/categoria/wordpress/y el JSON-LD apunta a un permalink con?cat=12, Google ve inconsistencia. wp_json_encodeevita romper el<script>con comillas en títulos. No concatenes a mano.- Si ya emites BreadcrumbList desde Yoast o Rank Math, no dupliques el nodo. Dos BreadcrumbList en la misma página confunden más de lo que ayudan.
Taxonomías personalizadas jerárquicas
La misma lógica aplica a taxonomías registradas con 'hierarchical' => true. Sustituye get_the_category / get_category / get_category_link por get_the_terms / get_term / get_term_link. get_category_parents() no existe para custom taxonomies: usa get_ancestors().
function wppoland_get_custom_taxonomy_trail( $taxonomy, $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$terms = get_the_terms( $post_id, $taxonomy );
if ( ! $terms || is_wp_error( $terms ) ) {
return '';
}
// Preferir el término más profundo si hay varios
$leaf = $terms[0];
$max = count( get_ancestors( $leaf->term_id, $taxonomy ) );
foreach ( $terms as $term ) {
$depth = count( get_ancestors( $term->term_id, $taxonomy ) );
if ( $depth > $max ) {
$max = $depth;
$leaf = $term;
}
}
$trail = wppoland_ancestor_trail( $leaf->term_id, $taxonomy );
$parts = array();
foreach ( $trail as $t ) {
$link = get_term_link( $t, $taxonomy );
if ( is_wp_error( $link ) ) {
continue;
}
$parts[] = sprintf(
'<a href="%s">%s</a>',
esc_url( $link ),
esc_html( $t->name )
);
}
return implode( ' <span class="separator" aria-hidden="true">»</span> ', $parts );
}Si registraste la taxonomía con 'hierarchical' => false, parent siempre es 0 y get_ancestors() devuelve un array vacío. El rastro tendrá un solo eslabón. Eso no es un bug: es el modelo de etiquetas, no de carpetas.
En proyectos WooCommerce en España, product_cat es el caso típico. El patrón anterior funciona sin tocar get_category_*. Solo pasa 'product_cat' como taxonomía y valida is_wp_error() en cada get_term_link().
Cache y rendimiento
En archivos con muchas entradas, llamar a get_ancestors() y get_term() por cada post suma. El object cache de WordPress (Redis o Memcached vía drop-in) ya cachea términos individuales, pero puedes memoizar el HTML del rastro por entrada:
function wppoland_cached_breadcrumbs( $post_id = null ) {
$post_id = $post_id ?: get_the_ID();
$cache_key = 'wppoland_cat_bc_' . $post_id;
$output = get_transient( $cache_key );
if ( false !== $output ) {
return $output;
}
$primary = wppoland_get_primary_category( $post_id );
if ( ! $primary ) {
return '';
}
$trail = wppoland_ancestor_trail( $primary->term_id, 'category' );
$parts = array();
foreach ( $trail as $term ) {
$parts[] = sprintf(
'<a href="%s">%s</a>',
esc_url( get_category_link( $term->term_id ) ),
esc_html( $term->name )
);
}
$output = implode( ' <span class="separator" aria-hidden="true">»</span> ', $parts );
set_transient( $cache_key, $output, DAY_IN_SECONDS );
return $output;
}
add_action( 'save_post_post', function( $post_id ) {
if ( wp_is_post_revision( $post_id ) ) {
return;
}
delete_transient( 'wppoland_cat_bc_' . $post_id );
} );
add_action( 'edited_category', function() {
// Si cambia el nombre o el padre de una categoría, los HTML cacheados quedan viejos.
// En sitios grandes, usa un group flush o una versión en la clave de cache.
} );Patrones que funcionan en producción:
- Clave por
post_id+ versión de taxonomía (incrementas la versión al editar cualquier categoría). - Transient solo si no hay object cache persistente; con Redis, un
wp_cache_seten el grupowppoland_bcsuele bastar. - Invalidar en
save_posty enedited_term/delete_termpara la taxonomía afectada. - No cachear el JSON-LD y el HTML por separado con claves distintas que puedan divergir: un solo builder, dos serializaciones.
En peticiones within-request (el mismo post renderizado dos veces en la página), un static $memo[ $post_id ] evita trabajo duplicado sin tocar la base de datos.
Dentro del Loop frente a un post ID arbitrario
Todas las funciones de este artículo aceptan $post_id opcional. En single.php puedes omitirlo. En un shortcode, un bloque dinámico o un endpoint REST, pásalo siempre:
add_shortcode( 'wppoland_cat_trail', function( $atts ) {
$atts = shortcode_atts( array( 'id' => 0 ), $atts, 'wppoland_cat_trail' );
$id = (int) $atts['id'] ?: get_the_ID();
return wppoland_cached_breadcrumbs( $id );
} );En temas de bloques, registra un bloque dinámico cuya render_callback llame a la misma función. No dupliques la lógica de ancestros en JavaScript del editor salvo que edites el rastro en tiempo real; el servidor sigue siendo la fuente de verdad para el HTML público y el JSON-LD.
Estilos y accesibilidad
Envuelve el rastro en <nav aria-label="Migas de pan">. Marca el separador con aria-hidden="true" para que los lectores de pantalla no lean “raquo” entre cada enlace. El último eslabón (el título de la entrada) puede ser texto plano sin enlace si ya estás en esa URL.
.category-breadcrumb {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 1rem;
}
.category-breadcrumb a {
color: #1d4ed8;
text-decoration: none;
}
.category-breadcrumb a:hover,
.category-breadcrumb a:focus-visible {
text-decoration: underline;
}
.category-breadcrumb .separator {
color: #d1d5db;
font-size: 0.75rem;
}Evita confiar solo en el color para el estado activo. El subrayado al foco cumple WCAG mejor que un cambio de tono mínimo.
Polylang y WPML
En sitios multilingües (comunes en agencias que publican en español y en inglés o portugués), el término padre en un idioma no es el mismo term_id que en otro. Resuelve primero el término en el idioma actual (API de Polylang / WPML), luego llama a get_ancestors() sobre ese ID. Cachear por post_id sin incluir el idioma en la clave mezcla migas entre locales.
Cuándo no construir migas desde categorías
Si la arquitectura de URLs del sitio no refleja la taxonomía (permalinks planos, hubs editoriales distintos de category), un BreadcrumbList basado en categorías miente al usuario y al buscador. En esos casos usa una jerarquía de páginas, un menú de ubicación o un campo ACF de rastro. Las categorías siguen siendo útiles para archivos y filtros; no tienen que alimentar el nav superior.
Tampoco fuerces migas de categoría en CPT que usan solo etiquetas planas. Sin jerarquía, el rastro de un solo nivel aporta poco frente a un simple enlace al archivo del CPT.
Resumen
get_the_category()da términos, no un árbol ordenado: elige hoja (primaria o más profunda) y sube.get_category_parents()acelera prototipos solo paracategory.get_ancestors()es la API correcta cuando controlas markup, Schema y taxonomías custom.- Emite BreadcrumbList coherente con el HTML visible, o deja que el plugin SEO lo haga una sola vez.
- Cachea el HTML por entrada e invalida al guardar el post o al editar términos.
Con eso cubres desde un tema clásico de un solo nivel hasta un single con JSON-LD, taxonomía custom y object cache. Empieza por el método 1, pasa a get_ancestors() cuando el diseño pida más de un padre, y añade Schema solo cuando el rastro visible ya sea estable.







