How to create simple breadcrumbs without plugins in PHP in WordPress

Contents

Introduction

This tutorial shows how to create simple, fast, accessible and SEO-friendly breadcrumbs for a WordPress theme without using plugins. The provided solution is a lightweight PHP function you can paste into your themes functions.php (or a small custom plugin) and call in template files. The function covers common WordPress conditions: pages (with parent chains), posts (including custom post types), categories/taxonomies, date archives, author/search/404, attachment pages, and paginated results. It emits semantic microdata for search engines and includes a CSS snippet for styling.

Why breadcrumbs matter

  • Usability: Breadcrumbs help users understand their location and navigate back up the content hierarchy quickly.
  • SEO: Search engines can show breadcrumb trails in search results structured data increases the chance of rich results.
  • Performance: A small handcrafted function avoids the overhead of plugins.

What this solution does

  1. Outputs an ordered list (ol) that represents the breadcrumb trail.
  2. Adds Schema.org microdata (BreadcrumbList ListItem) so search engines can parse the breadcrumb.
  3. Handles the common WordPress conditionals (posts, pages with parents, taxonomies, archives, searches, 404, attachments).
  4. Escapes output correctly using WordPress escaping functions.
  5. Is simple to customize (separator, home label, whether to show on front page, etc.).

The PHP function (drop into functions.php)

Copy and paste the following function into your themes functions.php or into a small custom plugin. The function echoes the breadcrumb HTML. It is designed to be straightforward to adapt and extend.



    // Home link (always first)
    home_url = esc_url( home_url(/) )
    echo 
  • echo . esc_html(home_label) . echo . position . echo
  • position // POSTS PAGE (blog home) when using a static front page posts page if ( is_home() ) { // If a separate posts page is set, show its title posts_page_id = get_option(page_for_posts) if ( posts_page_id ) { title = get_the_title( posts_page_id ) link = get_permalink( posts_page_id ) echo
  • echo . esc_html( title ) . echo . position . echo
  • } else { // Default Blog label echo
  • echo . esc_html__(Blog,text-domain) . echo . position . echo
  • } echo return } // SINGLE POST (including custom post types) if ( is_singular() ! is_attachment() ) { post_type = get_post_type() if ( post_type post_type !== post ) { // Custom post type: show archive link if exists post_type_obj = get_post_type_object( post_type ) if ( post_type_obj ) { archive_link = get_post_type_archive_link( post_type ) if ( archive_link ) { echo
  • echo . esc_html( post_type_obj->labels->name ) . echo . position . echo
  • position } } } // For posts (post_type === post) show category (first category) if available if ( post_type === post ) { categories = get_the_category( post->ID ) if ( ! empty( categories ) ) { category = categories[0] // Show parent categories chain category_parents = get_category_parents( category->term_id, true,
  • , false ) if ( category_parents ) { // get_category_parents returns a string with anchors, but we need to wrap positions // Well parse and output categories manually to control positions ancestors = array_reverse( get_ancestors( category->term_id, category ) ) foreach ( ancestors as ancestor_id ) { ancestor = get_category( ancestor_id ) if ( ancestor ) { link = get_category_link( ancestor->term_id ) echo
  • echo . esc_html( ancestor->name ) . echo . position . echo
  • position } } // finally the category itself link = get_category_link( category->term_id ) echo
  • echo . esc_html( category->name ) . echo . position . echo
  • position } } } else { // For other post types, optionally show taxonomy term if post has a hierarchical taxonomy — customize as needed taxonomies = get_object_taxonomies( post_type, objects ) if ( ! empty( taxonomies ) ) { foreach ( taxonomies as tax ) { if ( tax->hierarchical ) { terms = wp_get_post_terms( post->ID, tax->name ) if ( ! empty( terms ) ! is_wp_error( terms ) ) { term = terms[0] ancestors = array_reverse( get_ancestors( term->term_id, tax->name ) ) foreach ( ancestors as ancestor_id ) { ancestor = get_term( ancestor_id, tax->name ) if ( ancestor ! is_wp_error( ancestor ) ) { link = get_term_link( ancestor ) echo
  • echo . esc_html( ancestor->name ) . echo . position . echo
  • position } } link = get_term_link( term ) echo
  • echo . esc_html( term->name ) . echo . position . echo
  • position break // show just one hierarchical taxonomy trail } } } } } // Finally show the current post title (no link) echo
  • echo . get_the_title( post->ID ) . echo . position . echo
  • echo return } // PAGE (hierarchical pages with parents) if ( is_page() ) { if ( post->post_parent ) { parent_ids = array_reverse( get_post_ancestors( post->ID ) ) foreach ( parent_ids as parent_id ) { title = get_the_title( parent_id ) link = get_permalink( parent_id ) echo
  • echo . esc_html( title ) . echo . position . echo
  • position } } // current page title echo
  • echo . get_the_title() . echo . position . echo
  • echo return } // CATEGORY, TAG, TAXONOMY ARCHIVES if ( is_category() is_tag() is_tax() ) { term = get_queried_object() if ( term ) { // if hierarchical taxonomy, show parents chain if ( is_taxonomy_hierarchical( term->taxonomy ) ) { ancestors = array_reverse( get_ancestors( term->term_id, term->taxonomy ) ) foreach ( ancestors as ancestor_id ) { ancestor = get_term( ancestor_id, term->taxonomy ) if ( ancestor ! is_wp_error( ancestor ) ) { link = get_term_link( ancestor ) echo
  • echo . esc_html( ancestor->name ) . echo . position . echo
  • position } } } // current term link = get_term_link( term ) echo
  • echo . esc_html( term->name ) . echo . position . echo
  • } echo return } // AUTHOR if ( is_author() ) { curauth = get_queried_object() if ( curauth ) { echo
  • echo . esc_html( curauth->display_name ) . echo . position . echo
  • } echo return } // DATE ARCHIVES (year, month, day) if ( is_year() is_month() is_day() ) { if ( is_day() ) { year_link = get_year_link( get_query_var(year) ) month_link = get_month_link( get_query_var(year), get_query_var(monthnum) ) echo
  • echo . get_query_var(year) . echo . position . echo
  • position echo
  • echo . get_the_date(F) . echo . position . echo
  • position echo
  • echo . get_the_date(j) . echo . position . echo
  • echo return } elseif ( is_month() ) { year_link = get_year_link( get_query_var(year) ) echo
  • echo . get_query_var(year) . echo . position . echo
  • position echo
  • echo . get_the_date(F) . echo . position . echo
  • echo return } else { // year echo
  • echo . get_query_var(year) . echo . position . echo
  • echo return } } // SEARCH if ( is_search() ) { echo
  • echo . sprintf( esc_html__(Search results for: %s,text-domain), get_search_query() ) . echo . position . echo
  • echo return } // ATTACHMENT if ( is_attachment() ) { parent = get_post( post->post_parent ) if ( parent ) { // Parent chain parent_ids = array_reverse( get_post_ancestors( parent->ID ) ) foreach ( parent_ids as parent_id ) { title = get_the_title( parent_id ) link = get_permalink( parent_id ) echo
  • echo . esc_html( title ) . echo . position . echo
  • position } echo
  • echo ID ) ) . > . esc_html( get_the_title( parent->ID ) ) . echo . position . echo
  • position } // Current attachment title echo
  • echo . get_the_title() . echo . position . echo
  • echo return } // 404 if ( is_404() ) { echo
  • echo . esc_html__(404 Not Found,text-domain) . echo . position . echo
  • echo return } // DEFAULT / fallback: show current queried object title if available title = wp_get_document_title() if ( title ) { echo
  • echo . esc_html( title ) . echo . position . echo
  • } echo } ?>

    How the function is intended to be used

    After adding the function to functions.php, call it from any template file where you want breadcrumbs to appear (for example header.php, single.php, page.php or inside a template part before the main content).

    
    

    Markup, Schema and Accessibility

    The function outputs an ordered list (ol) with the attribute itemscope itemtype=https://schema.org/BreadcrumbList, and each list item is a ListItem with itemprop=itemListElement. Each anchor has itemprop=item and the visible name is provided via an i element with itemprop=name. The numeric position is emitted in an i element with itemprop=position.

    For accessibility, the ol includes aria-label=Breadcrumb. If you prefer, you can wrap the breadcrumbs inside a nav element in your theme template and give it role=navigation and aria-label=Breadcrumbs. The function avoids adding extra wrapper tags so you can control the exact wrapper in templates.

    Styling the breadcrumbs (simple CSS)

    Below is a minimal stylesheet. Paste into your theme stylesheet (style.css) or enqueue a small stylesheet. The CSS keeps the breadcrumbs inline and adds separators. Adjust to match your theme.

    .breadcrumbs {
      list-style: none
      padding: 0
      margin: 0 0 1em 0
      display: flex
      flex-wrap: wrap
      gap: 0.5rem
    }
    
    .breadcrumbs li {
      display: inline-flex
      align-items: center
      font-size: 0.95rem
    }
    
    .breadcrumbs li   li::before {
      content: »
      margin: 0 0.5rem
      color: #888
      font-size: 0.9rem
    }
    
    / Links /
    .breadcrumbs a {
      color: #0073aa
      text-decoration: none
    }
    
    .breadcrumbs a:hover {
      text-decoration: underline
    }
    
    / Current item (last li) /
    .breadcrumbs li:last-child {
      font-weight: 600
      color: #222
    }
    

    Customization points

    • Home label: change the home_label variable inside the function.
    • Show on front page: set show_on_front to true if you want breadcrumbs to appear on the front page.
    • Separator: the visual separator is handled in CSS using ::before adjust the CSS or change the separator if you want inline separators from PHP.
    • Localization: wrap static strings with translation functions (e.g., esc_html__(Home,text-domain)).
    • Filtering: you can add apply_filters() around labels or the final output to allow theme-specific modifications by child themes or plugins.
    • Return vs echo: the function echoes directly. If you prefer to return the HTML, build a string and return it instead of echo, and then echo where you call the function.

    Security, performance and best practices

    • Escape output: all URLs and text are escaped with esc_url() and esc_html()/get_the_title() to avoid XSS issues.
    • Keep logic light: the function relies on core WP functions, so overhead is minimal. If you have extremely high-traffic and heavy breadcrumb generation, consider caching the generated string (transients) for non-logged-in users.
    • Avoid duplicate markup: if using a plugin that already shows breadcrumbs (Yoast SEO, Rank Math, etc.), disable it to avoid two breadcrumb trails.

    Common edge cases and troubleshooting

    • Custom post types: If your CPT has no archive or you want a specific label, add a manual link or change the branch that outputs the archive link.
    • Taxonomies: For non-hierarchical taxonomies (tags), we simply show the term. For complex requirements (multiple terms), decide which term to display (first, primary, or a defined primary sort).
    • Permalink issues: If breadcrumbs linking to categories or pages return 404, flush permalinks via Settings → Permalinks.
    • Translation: Wrap hard-coded labels with __() or _e() and set a proper text domain for translation.

    Where to place the breadcrumb call

    1. Open your theme template where you want breadcrumbs to appear (commonly header.php, single.php, or page.php).
    2. Insert: if ( function_exists(simple_breadcrumbs) ) simple_breadcrumbs() — call inside PHP tags in your template. The code sample earlier shows this.
    3. Style with theme CSS so it matches your design system and test on mobile/responsive screens.

    Final notes

    This is a deliberately compact, robust starting point for breadcrumbs without plugins. It focuses on accessibility and structured data while remaining easy to read and adapt. For more advanced features — such as honoring a user-configurable breadcrumb order, integrating with WPML or Polylang for multilingual sites, or supporting a primary term concept from SEO plugins — extend the function or add filters to allow selective customization.

    Quick checklist before going live

    • Place the function in functions.php or in a tiny custom plugin.
    • Call the function from the theme template where you want breadcrumbs to appear.
    • Adjust labels and test different content types (pages with parents, posts, archives, search, 404).
    • Test structured data using Googles Rich Results Test or the Schema Markup Validator to ensure breadcrumb markup is recognized.
    • Style the breadcrumbs to fit your theme and ensure good contrast and spacing.


    Acepto donaciones de BAT's mediante el navegador Brave 🙂



    Leave a Reply

    Your email address will not be published. Required fields are marked *