Skip to content

About

πŸ₯– Breadcrumbs block for WordPress.

Topics

Resources

Contributing

Security policy

Stars

49 stars

Watchers

3 watching

Forks

Repository files navigation

X3P0: Breadcrumbs

Mostly decorative banner that displays a screenshot of the Breadcrumbs block in action and Nova, the brand's mascot, in a wheat field holding a baguette.

The X3P0: Breadcrumbs plugin is a breadcrumbs plugin reimagined for a modern, block-based WordPress.

In 2009, I launched the first version of this script as a WordPress plugin. And I've continually refined it ever since. Today, this plugin exists as a WordPress block, all built upon a solid OOP foundation that's more powerful than ever. The block is the easy-to-use version that regular, everyday WordPress users can simply plug into their site and go about their business. But the stuff under the hood gives developers an insane amount of control to customize breadcrumbs to suit their needs.

Table of Contents

Usage

From the Editor

You can add the Breadcrumbs block via the block inserter in the editor anywhere. This can include post, pages, templates, parts, and patterns. Just insert it and configure the settings however you like. Preferably, you'd add it to something like your Header template part so that it gets reused across the site.

Block Themes

If you're a block theme author, you can include support for the Breadcrumbs block in your theme by adding the following block markup to a template, part, or pattern:

<!-- wp:x3p0/breadcrumbs /-->

That will output the block with the defaults. Of course, you can configure it by customizing the attributes available for the block (see block.json for all attributes). Here is an example that uses the icons attribute to show an arrow separator and turns on crumb icons via iconVisibility:

<!-- wp:x3p0/breadcrumbs {"icons":{"separator":"x3p0-breadcrumbs/arrow"},"iconVisibility":"all"} /-->

Classic Themes

If you're using or building a classic theme, you can wrap the Breadcrumbs block markup inside the WordPress do_blocks() function to parse the markup with PHP. Place the following code in your header.php template or in another template where you want to output the breadcrumbs:

<?= do_blocks('<!-- wp:x3p0/breadcrumbs /-->') ?>

Alternatively, feel free to fully customize output using PHP following the instructions under the Developers section.

Developers

Outputting Breadcrumbs with PHP

The plugin isn't just a simple block. Its foundation is actually a robust, object-oriented script for generating breadcrumbs for pretty much anything you throw at it.

Basic Breadcrumbs Implementation

The most straightforward way of displaying breadcrumbs is via the breadcrumbs() helper function, which returns the shared BreadcrumbsRenderer instance, and calling its render() method:

use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render();

It doesn't get any simpler than that for outputting breadcrumbs. Of course, that includes all the default configuration, which you might want to customize.

Defining Breadcrumbs Parameters

The breadcrumbs()->render() method accepts three optional parameters:

  • breadcrumbsConfig: Accepts either an instance of the BreadcrumbsConfig class or an array of arguments for configuring breadcrumbs.
  • markupConfig: Accepts either an instance of the MarkupConfig class or an array of arguments for configuring the final HTML markup of the breadcrumb trail, including the icons it renders.
  • markupType: Accepts a case of the MarkupType enum: MarkupType::Html (the default), MarkupType::Microdata, MarkupType::Rdfa, or MarkupType::JsonLinkedData. It also accepts the class name of a custom Markup implementation.

These parameters are described in the followup sections below. For now, just know that you have options for customizing the breadcrumbs to your liking.

Here's an example of configuring the breadcrumbs with array configs:

use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	breadcrumbsConfig: [],              // Optional: BreadcrumbsConfig or array
	markupConfig:      [],              // Optional: MarkupConfig or array
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

If you use array-type configuration for breadcrumbsConfig or markupConfig, they are automatically converted to BreadcrumbsConfig and MarkupConfig instances for you. Using arrays is just for convenience.

But if you prefer to use the *Config classes, you're welcome to do that:

use X3P0\Breadcrumbs\BreadcrumbsConfig;
use X3P0\Breadcrumbs\Markup\MarkupConfig;
use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	breadcrumbsConfig: new BreadcrumbsConfig(), // Optional: BreadcrumbsConfig or array
	markupConfig:      new MarkupConfig(),      // Optional: MarkupConfig or array
	markupType:        MarkupType::Html         // Optional: MarkupType case or Markup class name
);

Breadcrumbs Configuration

The BreadcrumbsConfig class accepts multiple parameters:

  • labels: An array of internationalized crumb labels that can be customized:
    • home: Home
    • untitled: Untitled
    • error_404: Page not found
    • archives: Archives
    • search: Search results for: %s
    • paged: Page %s
    • paged_comments: Comment Page %s
    • archive_hour: Hour %s
    • archive_minute: Minute %s
    • archive_second: Second %s
    • archive_week: Week %s
    • archive_day: %s
    • archive_month: %s
    • archive_year: %s
  • mapRewriteTags: An array of post types and whether to generate breadcrumbs based on each post type's rewrite tags (e.g., %year%, %monthnum%, etc.). By default, this is set to true for any post type rewrite slug that has % character in it.
  • postTaxonomy: An array of post types and which taxonomy to use in the breadcrumb trail for single posts. The array key must be valid post type names (e.g., post, book), and the array values must be valid taxonomy names (e.g., category, genre). By default, this is an empty array.
  • network: Whether to show the network as part of the breadcrumb trail on multisite installations. Defaults to false.

Here is an example of disabling post rewrite tags and enabling the category taxonomy for single posts:

use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

$breadcrumbsConfig = [
	'mapRewriteTags' => ['post' => false],
	'postTaxonomy'   => ['post' => 'category']
];

echo breadcrumbs()->render(
	breadcrumbsConfig: $breadcrumbsConfig,
	markupConfig:      [],              // Optional: MarkupConfig or array
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

If you prefer to work with the BreadcrumbsConfig class directly, use this:

use X3P0\Breadcrumbs\BreadcrumbsConfig;
use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

$breadcrumbsConfig = new BreadcrumbsConfig(
	mapRewriteTags: ['post' => false],
	postTaxonomy:   ['post' => 'category']
);

echo breadcrumbs()->render(
	breadcrumbsConfig: $breadcrumbsConfig,
	markupConfig:      [],              // Optional: MarkupConfig or array
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

Markup Configuration

The MarkupConfig class accepts several parameters:

  • namespace: Used as a prefix for classes in a BEM-style structure (e.g., {namespace}__{element}--{modifier}). Defaults to breadcrumbs (note: the Breadcrumbs block uses wp-block-x3p0-breadcrumbs).
  • containerAttr: An array of HTML attributes and values to apply to the container:
    • class: {namespace}
    • role: navigation
    • aria-label: Breadcrumbs
    • data-wp-interactive: x3p0/breadcrumbs
    • data-wp-router-region: breadcrumbs
  • showOnFront: Whether to show the breadcrumbs on the site front page. Defaults to false.
  • showFirstCrumb: Whether to display the first (homepage) breadcrumb. Defaults to true.
  • showLastCrumb: Whether to display the last (current page) breadcrumb. Defaults to true.
  • linkLastCrumb: Whether to link the last (current page) breadcrumb. Defaults to false. The showLastCrumb parameter must be enabled for this to work.
  • iconVisibility: Which crumbs render their icon, as a Markup\Support\IconVisibility case: None (default), First, AllButLast, or All. Crumb icons are off by default, so this is the switch to flip when the icons you configure aren't showing up. It does not govern the separator, which has its own flag below.
  • icons: The icons the trail renders, as an Icon\IconConfig instance or a flat array mapping icon preset keys to icon values. Covered in full under Icon Configuration.
  • labelVisibility: Which crumbs render their text label, as a Markup\Support\LabelVisibility case: All (default), AllButFirst, Last, or None. A hidden label is still output for assistive tech behind a visually-hidden class rather than dropped, and it is only ever hidden when the same crumb's icon is visible β€” otherwise the crumb would have nothing to show at all.
  • showSeparator: Whether the separator is rendered between crumbs. Defaults to true.
  • showTrailingSeparator: Whether the separator is also rendered after the last crumb rather than only between crumbs. Defaults to false.

Here is an example of using array-style formatting to disable the first breadcrumb and link the last one:

use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

$markupConfig = [
	'showFirstCrumb' => false,
	'linkLastCrumb'  => true
];

echo breadcrumbs()->render(
	breadcrumbsConfig: [],              // Optional: BreadcrumbsConfig or array
	markupConfig:      $markupConfig,
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

Or if you prefer to work directly with the MarkupConfig class, use this method:

use X3P0\Breadcrumbs\Markup\MarkupConfig;
use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

$markupConfig = new MarkupConfig(
	showFirstCrumb: false,
	linkLastCrumb:  true
);

echo breadcrumbs()->render(
	breadcrumbsConfig: [],              // Optional: BreadcrumbsConfig or array
	markupConfig:      $markupConfig,
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

Icon Configuration

The icons you've picked for one trail are part of how that trail is presented, so they travel with the rest of the markup configuration: the markupConfig parameter's icons option. It takes a map of icon preset key to icon value, or an X3P0\Breadcrumbs\Icon\IconConfig instance holding the same map.

An icon preset key names something that can carry an icon β€” the separator, the home crumb, single posts of a given post type. Every key is preset to an icon by the plugin or an extension, and the icons option is where you override that. The keys the plugin owns are the cases of X3P0\Breadcrumbs\Icon\IconPresetKey, and that enum is how you should name them: reach for a case, not the string it happens to be backed by.

Case What it covers
IconPresetKey::Separator The separator rendered between crumbs
IconPresetKey::Home The home crumb
IconPresetKey::Date Date archive crumbs
IconPresetKey::Time Time archive crumbs
IconPresetKey::User User and author crumbs
IconPresetKey::Search The search results crumb
IconPresetKey::Error404 The 404 crumb
IconPresetKey::Paged Pagination crumbs
IconPresetKey::Archive Generic archive crumbs
IconPresetKey::Custom Custom crumbs that don't carry their own icon
IconPresetKey::Fallback The last resort for any key nothing answers for
IconPresetKey::PrivacyPolicy The page assigned as the site's privacy policy
IconPresetKey::PostsPage The page assigned to list blog posts
IconPresetKey::NetworkSite The site crumb on multisite
IconPresetKey::MediaImage, MediaAudio, MediaVideo Attachment crumbs, by media type

Three more families of keys are registered at runtime, one per viewable post type, post type archive, and taxonomy. There's no case to reach for, so the enum hands you a builder for each:

  • IconPresetKey::postType('page') β€” single posts of a post type.
  • IconPresetKey::postTypeArchive('book') β€” a post type's archive crumb.
  • IconPresetKey::taxonomy('category') β€” term crumbs of a taxonomy.

An icon value is one of:

  • An X3P0\Breadcrumbs\Icon\Icon case β€” Icon::Chevron, Icon::Arrow, and the rest of the icons this plugin bundles. See The Icon API for the full list.
  • A {collection}/{name} reference to any icon registered with WordPress's icon library β€” core/home, core/search, core/pencil, and the rest of core's set. These are another API's identifiers, so a string is the right way to name them; don't mirror them into constants of your own.
  • One of the built-in text/glyph values, which are literal characters rather than SVGs: text-slash (/), text-bar (|), text-middot (Β·), text-black-circle (●), or text-white-circle (β—‹).

You only need to name the keys you want to change. A key you leave out carries no opinion and falls through to whatever it's preset to. There's no way to configure a key to nothing, because "show no icons" isn't a fact about any one key β€” that's the sibling iconVisibility option, which governs the trail rather than one key within it.

Here is an example that uses a slash separator, an outlined house for the home crumb, a pencil for single posts, and the bundled category icon for post format terms:

use X3P0\Breadcrumbs\Icon\Icon;
use X3P0\Breadcrumbs\Icon\IconPresetKey;
use X3P0\Breadcrumbs\Markup\MarkupType;
use X3P0\Breadcrumbs\Markup\Support\IconVisibility;
use function X3P0\Breadcrumbs\breadcrumbs;

$icons = [
	IconPresetKey::Separator->value        => 'text-slash',
	IconPresetKey::Home->value             => Icon::HouseOutline,
	IconPresetKey::postType('post')        => 'core/pencil',
	IconPresetKey::taxonomy('post_format') => Icon::Category
];

echo breadcrumbs()->render(
	breadcrumbsConfig: [],              // Optional: BreadcrumbsConfig or array
	markupConfig:      [
		'iconVisibility' => IconVisibility::All,
		'icons'          => $icons
	],
	markupType:        MarkupType::Html // Optional: MarkupType case or Markup class name
);

Note the iconVisibility above: crumb icons are hidden by default, so configuring one isn't enough on its own. The separator is the exception β€” it's governed by showSeparator instead and renders out of the box.

Keys arrive as strings because the same map comes off the block's icons attribute, saved in post content, which is why ->value appears above. Values don't have that constraint, so an Icon case can be passed directly. If you'd rather not write ->value at all, build the config through the class instead:

use X3P0\Breadcrumbs\Icon\Icon;
use X3P0\Breadcrumbs\Icon\IconConfig;
use X3P0\Breadcrumbs\Icon\IconPresetKey;
use X3P0\Breadcrumbs\Markup\MarkupConfig;
use X3P0\Breadcrumbs\Markup\Support\IconVisibility;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	markupConfig: new MarkupConfig(
		iconVisibility: IconVisibility::All,
		icons:          (new IconConfig())
			->withIcon(IconPresetKey::Separator, 'text-slash')
			->withIcon(IconPresetKey::Home, Icon::HouseOutline)
			->withIcon(IconPresetKey::postType('post'), 'core/pencil')
	)
);

Markup Types

The plugin comes with four classes for rendering the final HTML of the breadcrumb trail, which are implementations of the X3P0\Breadcrumbs\Markup\Markup contract. Unless you're wanting to create your own markup implementations, you don't need to worry about those. Instead, you just need to know what types are available.

Name the one you want with a case of the X3P0\Breadcrumbs\Markup\MarkupType enum, which is the canonical list of the built-in types:

  • MarkupType::Html: Renders a plain HTML list of breadcrumbs. This is the default.
  • MarkupType::Microdata: Renders an HTML list of breadcrumbs using Schema.org microdata.
  • MarkupType::Rdfa: Renders an RDFa (Resource Description Framework in Attributes) compliant HTML list of breadcrumbs (recommended for most use cases).
  • MarkupType::JsonLinkedData: Renders a JSON-LD script tag rather than a visible trail, for the document <head>. See Outputting JSON Linked Data (JSON-LD).

The first three also answer to the short string keys html, microdata, and rdfa, since those are the types offered in the block editor's markup control and the block stores its choice as one of those keys. Reach for the enum in PHP β€” it's typed, it's discoverable in your editor, and it covers all four types. JSON-LD is not a block editor option and therefore has no string key at all: it can only be named by its enum case or class name.

This example uses RDFa schema attributes:

use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	breadcrumbsConfig: [],              // Optional: BreadcrumbsConfig or array
	markupConfig:      [],              // Optional: MarkupConfig or array
	markupType:        MarkupType::Rdfa // Optional: MarkupType case or Markup class name
);

Putting It All Together

The PHP under the hood for rendering breadcrumbs is very complex because each WordPress site is unique and has different needs. But that doesn't mean the public API needs to be complex. With the convenience of the breadcrumbs() wrapper function, you have everything you need for the majority of situations.

Here's a look at what a few config options could look like using array-style syntax:

use X3P0\Breadcrumbs\Icon\Icon;
use X3P0\Breadcrumbs\Icon\IconPresetKey;
use X3P0\Breadcrumbs\Markup\MarkupType;
use X3P0\Breadcrumbs\Markup\Support\IconVisibility;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	breadcrumbsConfig: [
		'mapRewriteTags' => ['post' => false],
		'postTaxonomy'   => ['post' => 'category']
	],
	markupConfig: [
		'showFirstCrumb' => false,
		'linkLastCrumb'  => true,
		'iconVisibility' => IconVisibility::All,
		'icons'          => [
			IconPresetKey::Separator->value => Icon::Arrow,
			IconPresetKey::Home->value      => 'core/home'
		]
	],
	markupType: MarkupType::Rdfa
);

Of course, you're welcome to continue using the *Config classes if you prefer less syntactic sugar and more direct access to objects:

use X3P0\Breadcrumbs\BreadcrumbsConfig;
use X3P0\Breadcrumbs\Icon\Icon;
use X3P0\Breadcrumbs\Icon\IconConfig;
use X3P0\Breadcrumbs\Icon\IconPresetKey;
use X3P0\Breadcrumbs\Markup\MarkupConfig;
use X3P0\Breadcrumbs\Markup\MarkupType;
use X3P0\Breadcrumbs\Markup\Support\IconVisibility;
use function X3P0\Breadcrumbs\breadcrumbs;

echo breadcrumbs()->render(
	breadcrumbsConfig: new BreadcrumbsConfig(
		mapRewriteTags: ['post' => false],
		postTaxonomy:   ['post' => 'category']
	),
	markupConfig: new MarkupConfig(
		showFirstCrumb: false,
		linkLastCrumb:  true,
		iconVisibility: IconVisibility::All,
		icons:          (new IconConfig())
			->withIcon(IconPresetKey::Separator, Icon::Arrow)
			->withIcon(IconPresetKey::Home, 'core/home')
	),
	markupType: MarkupType::Rdfa
);

Advanced Use Cases

Outputting JSON Linked Data (JSON-LD)

The plugin has a markup implementation for outputting JSON-LD style formats in the <head> of your webpage. This feature is not currently enabled by default since the plugin's primary purpose is to make displayable breadcrumbs via the block editor.

To enable it, you can call the normal breadcrumbs()->render() method and pass in the MarkupType::JsonLinkedData enum case as the markup type. And, of course, hook it to wp_head:

use X3P0\Breadcrumbs\Markup\MarkupType;
use function X3P0\Breadcrumbs\breadcrumbs;

add_action('wp_head', function() {
	echo breadcrumbs()->render(markupType: MarkupType::JsonLinkedData);
});

Modifying the Breadcrumb Trail with Events

The plugin dispatches typed events at the key moments while a trail is built and rendered. Listening to these events is the recommended way to change which breadcrumbs are shown, to adjust the finished trail, or to retarget how it is rendered. Each event is also bridged to a matching WordPress action, so you can use a plain add_action() callback.

There are three events:

  • Query\Event\QueryTypeResolving: Dispatched while resolving which query type matches the current request, before the query runs. This is what you hook into to change which breadcrumbs are shown for a given page.
  • Crumb\Event\CrumbsBuilt: Dispatched after the trail has been built, before it is returned. This is what you hook into to append, remove, or relabel crumbs.
  • Markup\Event\MarkupRendering: Dispatched after the trail is built but before it is rendered. This is what you hook into to change the markup type or config used to render the trail for a given request.
Changing the Query Type

The QueryTypeResolving event carries $event->queryType β€” the type detected from the current request: a QueryType case for a built-in type, a Query class-string for a custom one, or null when nothing matched β€” and $event->breadcrumbsConfig, the active BreadcrumbsConfig for this build. Reassign $event->queryType to override it, to a QueryDefinition case, a Query class-string, or null to build no breadcrumbs.

The simplest way to hook in is the bridged action, which passes the event object:

use X3P0\Breadcrumbs\Query\Event\QueryTypeResolving;

add_action(QueryTypeResolving::NAME, function (QueryTypeResolving $event) {
	if ($yourCondition) {
		$event->queryType = MyQuery::class;
	}
});

Alternatively, register a typed listener directly on the dispatcher. Do this on the x3p0/breadcrumbs/register action (documented below), where the plugin β€” and therefore the container β€” is available.

Adjusting the Finished Crumbs

The CrumbsBuilt event carries:

  • $event->crumbs: The finished CrumbCollection β€” the same mutable instance the caller receives, so changes you make here are what ultimately gets rendered.
  • $event->makeCrumb() / $event->addCrumb(): Build new crumbs through the plugin's factory (see below).

Every crumb in the collection is a Crumb object exposing getSlug() (its type slug, e.g. post, used in the crumb--{slug} CSS class), getLabel(), and getUrl().

Finding crumbs. Every lookup method takes a callback matching the crumb itself. Match on the crumb's class β€” with whereInstanceOf(), an instanceof check inside a callback, or replaceInstanceWhere() (below) β€” rather than a string identifier. Your editor gets full type hinting this way, and you can safely read the crumb's typed properties (e.g. a Term crumb's $term, a Post crumb's $post):

  • first(?callable $callback = null): ?Crumb: The first crumb, or the first matching the callback.
  • last(?callable $callback = null): ?Crumb: The last crumb, or the last matching the callback.
  • contains(callable $callback): bool: Whether any crumb satisfies the callback.
  • every(callable $callback): bool: Whether every crumb satisfies the callback.
  • filter(callable $callback): CrumbCollection: A new collection of the crumbs that do satisfy the callback.
  • reject(callable $callback): CrumbCollection: A new collection of the crumbs that don't satisfy the callback.
  • whereInstanceOf(string $class): CrumbCollection: A new collection of the crumbs that are instances of the class or interface.

Add count(), isEmpty(), isNotEmpty(), all(), map(), and reduce() for inspecting the collection as a whole.

Building a crumb. To create a crumb to add or use as a replacement, call $event->makeCrumb() with a CrumbType case (for a built-in type) or a Crumb class-string (for a custom one), plus its constructor parameters. It returns the crumb without adding it to the trail:

$crumb = $event->makeCrumb(MyCrumb::class, ['foo' => $bar]);

To build and append in a single step, use $event->addCrumb() instead.

Adding crumbs:

  • push(Crumb $crumb): void: Append to the end of the trail.
  • prepend(Crumb $crumb): void: Insert at the front.
  • insertBefore(Crumb $target, Crumb $crumb): void: Insert before a crumb you found. Does nothing if the target isn't in the trail.
  • insertAfter(Crumb $target, Crumb $crumb): void: Insert after a crumb you found. Does nothing if the target isn't in the trail.

Removing crumbs:

  • removeWhere(callable $callback): void: Remove every crumb that satisfies the callback (e.g. an instanceof check).
  • pop(): ?Crumb / shift(): ?Crumb: Remove and return the last/first crumb.

Replacing crumbs:

  • replace(Crumb $existing, Crumb $replacement): void: Swap a specific crumb in place, keeping its position.
  • replaceWhere(callable $callback, callable $replacement): void: Swap every matching crumb in place. The replacement callback receives the matched crumb, which is handy for wrapping or relabeling it.
  • replaceInstanceWhere(string $class, callable $callback, callable $replacement): void: Swap every crumb that is an instance of $class and satisfies the callback. Both callbacks receive only crumbs of the class, so they may typehint it directly.

Here's a practical example that removes the home crumb, relabels the post type archive, and inserts a crumb after it:

use X3P0\Breadcrumbs\Crumb\Crumb;
use X3P0\Breadcrumbs\Crumb\Event\CrumbsBuilt;
use X3P0\Breadcrumbs\Crumb\Type\Home;
use X3P0\Breadcrumbs\Crumb\Type\PostType;

add_action(CrumbsBuilt::NAME, function (CrumbsBuilt $event) {
	// Remove the home crumb.
	$event->crumbs->removeWhere(fn (Crumb $crumb) => $crumb instanceof Home);

	// Insert a custom crumb right after the post type archive crumb.
	if ($archive = $event->crumbs->first(fn (Crumb $crumb) => $crumb instanceof PostType)) {
		$event->crumbs->insertAfter(
			$archive,
			$event->makeCrumb(MyCrumb::class)
		);
	}
});

As with the query event, you may register a typed listener for CrumbsBuilt::class on the dispatcher instead of using the action bridge β€” see The Extension System below for the tidiest way to do that.

Retargeting the Rendered Markup

The MarkupRendering event fires after the trail is built but before it is rendered, so it is where you change how a finished trail is presented for the current request. It carries three things:

  • $event->crumbs: The finished CrumbCollection, read-only. Mutate crumbs on CrumbsBuilt instead; by this point the trail is fixed and only its presentation is in play.
  • getMarkupType() / setMarkupType(): The markup type to render with. Pass a MarkupType case for a built-in format or a string key for a custom one.
  • getConfig() / setConfig(): The MarkupConfig to render with.

For example, render the trail with microdata on your shop's pages while leaving every other request untouched:

use X3P0\Breadcrumbs\Markup\Event\MarkupRendering;
use X3P0\Breadcrumbs\Markup\MarkupType;

add_action(MarkupRendering::NAME, function (MarkupRendering $event) {
	if (function_exists('is_shop') && is_shop()) {
		$event->setMarkupType(MarkupType::Microdata);
	}
});

As with the other events, you may register a typed listener for MarkupRendering::class on the dispatcher instead of using the action bridge.

The Icon API

Everything under Icon Configuration is the caller's half of the icon system: overriding what a key is preset to. This section is the other half β€” what does the presetting, and how an extension joins in.

How an Icon Is Chosen

For every crumb it renders, and for the separator, the markup layer settles on exactly one icon value:

  1. An icon pinned to the crumb itself, via Crumb::getIcon() β€” the icon saved in a post's or term's meta. That's a decision about one specific object, so nothing overrides it.
  2. The trail's IconConfig for the crumb's key, then the preset registered for it.
  3. IconPresetKey::Fallback, so a key nothing answers for still renders something.

Step 1 is applied by Markup\Type\Html::crumbIcon(), which is the only place that has a crumb in hand. Steps 2 and 3 are IconResolver::resolve(), which never sees a crumb β€” it takes a list of keys and answers with an icon value, which IconRenderer then turns into markup.

A crumb names one key, picking the narrowest description of itself that fits. The Post crumb decides like this:

return match (true) {
	$this->isPrivacyPolicy()                => IconPresetKey::PrivacyPolicy,
	$this->isPostsPage()                    => IconPresetKey::PostsPage,
	'attachment' === $this->post->post_type => $this->mediaPresetKey(),
	default                                 => IconPresetKey::postType($this->post->post_type)
};

So the privacy policy asks about privacy-policy, not about post-type:page β€” an icon you set for pages in general doesn't reach it, because it isn't describing that page.

Some of those narrow keys carry no editor control, deliberately. The privacy policy shows a shield because the plugin has an opinion about that page, not because you asked for one, and it isn't worth a row in the inspector. They're still overridable: an icon saved in that page's own meta outranks everything above, and is the natural place to say something about one specific page.

Note what the Post key deliberately leaves out. A post's visibility state β€” private, password protected β€” gets no key, because a breadcrumb hides nothing: it only ever renders for someone already permitted to see the post.

The Bundled Icon Collection

The plugin registers its own icons with WordPress's icon library on init, in a collection named x3p0-breadcrumbs. Each is a case of the X3P0\Breadcrumbs\Icon\Icon enum, which is how you should name them. IconPreset takes a case directly; IconConfig and the block attribute take the {collection}/{name} reference that Icon::name() returns.

Icon::Archive, Icon::Arrow, Icon::Article, Icon::Box, Icon::BrandingWatermark, Icon::Category, Icon::Chevron, Icon::ChevronDouble, Icon::Color, Icon::Description, Icon::EmojiHouse, Icon::EmojiHouseGarden, Icon::EmojiHouses, Icon::HomeFill, Icon::HomeOutline, Icon::HouseFill, Icon::HouseOutline, Icon::List, Icon::Package, Icon::ReceiptLong, Icon::Shipping, Icon::Straighten, Icon::Triangle, Icon::Unseen

Nothing in the plugin's icon handling is special-cased to this collection. An icon registered by core, a theme, or another plugin is named and rendered exactly the same way β€” those come from someone else's registry, so a {collection}/{name} string is how you name them and there's no local constant to invent.

Presets

A preset is everything the plugin, or an extension, has to say about one key. It is an IconPreset, and it carries four things:

  • icon: What the key shows. Required β€” every preset shows something. An Icon case, or a {collection}/{name} reference.
  • label: The translated label for the key's block editor control. A preset with a label is an option; one without is not. Several built-ins are deliberately unlabeled: they supply an icon and stay out of the editor.
  • group: The heading the control is filed under, as an IconGroup case or the key of a group an extension declared. Defaults to IconGroup::General.
  • slug: The name of the WordPress object the preset was derived from, if any (page, category), so the editor can tell apart two objects with the same label.

It's immutable. withIcon(), withLabel(), and withGroup() return copies, which is what IconPresets::amend() expects its callback to hand back.

Presets live in a single registry, IconPresets. Everything goes in on init via IconPresetRegistrar β€” the plugin's own keys from the IconPresetKey cases, plus one per viewable post type, per post type archive, and per viewable taxonomy, each labeled from the WordPress object itself. Your listener runs after all of that, so the set you see is complete.

Registering Presets

The plugin dispatches IconPresetsRegistered very late on init β€” after every post type and taxonomy is registered, so a listener checking whether one exists finds a complete answer. The event carries the shared registry, so what you register is what the trail resolves against and what the editor lists.

use X3P0\Breadcrumbs\Icon\Event\IconPresetsRegistered;
use X3P0\Breadcrumbs\Icon\Icon;
use X3P0\Breadcrumbs\Icon\IconPreset;
use X3P0\Breadcrumbs\Icon\IconPresetKey;

add_action(IconPresetsRegistered::NAME, function (IconPresetsRegistered $event) {
	$presets = $event->presets;

	// A group of this plugin's own, so its controls get a heading.
	$presets->registerGroup('my-plugin', __('My Plugin', 'my-plugin'));

	// A key of its own: an icon, and a control for setting it.
	$presets->register('my-plugin:course', new IconPreset(
		Icon::Article,
		__('Courses', 'my-plugin'),
		'my-plugin'
	));

	// A key with an icon and no control.
	$presets->register('my-plugin:lesson', new IconPreset(Icon::Description));

	// Change something already registered β€” including a post type or
	// taxonomy preset this extension doesn't own.
	$presets->amend(
		IconPresetKey::Separator,
		static fn (IconPreset $preset) => $preset->withIcon(Icon::Arrow)
	);
});

The registry's surface:

  • register(key, IconPreset): Claims a key. Registering one twice is a mistake rather than an override, so it throws β€” including for the plugin's own keys, which are registered before this event fires. Use amend() to change one.
  • amend(key, callable): Changes a key's preset. The callback receives it and returns a copy. This is how you retarget a preset you don't own β€” the icon or grouping of a post type or taxonomy β€” and a key with no preset is passed over, so amending a taxonomy a given site may not have needs no check first. Amendments compose β€” two of them on the same key both apply, in order.
  • unregister(key): Drops a registration. The key falls back to whatever it derives to.
  • has(key) / get(key): Whether a preset is registered, and the preset itself with any amendments applied. get() returns null for a key nothing registered, and IconResolver falls through to the trail's fallback.
  • all(): Every preset, keyed, in registration order.
  • registerGroup(key, label) / groups(): Group headings. The built-ins are the cases of IconGroup β€” General, PostType, PostTypeArchive, Taxonomy, Media β€” listed first, then any an extension declared. A preset naming a group nobody declared is filed under the catch-all.

Why grouping is a separate call. A preset names the group it belongs to but never labels one, so two presets filed together can't disagree about what their group is called.

Per-Post and Per-Term Icons

The plugin registers one meta key, x3p0-breadcrumbs-icon (the MetaKey::Icon case), for both posts and terms, with an empty object subtype so it applies across every post type and taxonomy β€” including ones registered later. It's exposed in the REST API, and the plugin writes it from a row in the block editor's Summary panel and from a field on the term add/edit screens.

The value is sanitized against WordPress's icon registry on save: only a currently registered {collection}/{name} reference is kept, and anything else β€” empty, malformed, or pointing at a deregistered icon β€” stores an empty string. Unlike IconConfig values, the text/glyph keys and the deprecated pre-7.1 keys aren't accepted here, since this is a fresh, user-authored value with no backward compatibility to carry.

Post::getIcon() and Term::getIcon() read that meta, which is how an icon chosen for one specific post or term becomes step 1 of the order above and outranks anything preset for its kind.

Icons on Custom Crumbs

A custom Crumb joins in through two methods it inherits:

  • getIconPresetKey(): IconPresetDefinition|string β€” the key it resolves its icon through. Defaults to $this->getSlug(). Override it when your crumb belongs to a key you or the plugin registered, picking the narrowest description that fits.
  • getIcon(): string β€” an icon pinned to this crumb specifically, overriding everything else. Empty by default. Override it when the crumb has an icon of its own to report, the way Post and Term return the icon saved in their meta.
use X3P0\Breadcrumbs\Crumb\Crumb;
use X3P0\Breadcrumbs\Icon\IconPresetDefinition;

final class MyCrumb extends Crumb
{
	public function getSlug(): string
	{
		return 'my-plugin-course';
	}

	public function getLabel(): string
	{
		return __('Courses', 'my-plugin');
	}

	public function getIconPresetKey(): IconPresetDefinition|string
	{
		return 'my-plugin:course';
	}
}

Register a preset under the same key and the crumb picks up an icon and a block editor control, with no further wiring.

The general-purpose Custom crumb needs neither override: it takes its icon as a constructor argument alongside its label and URL, since it has no queried object to derive anything from.

use X3P0\Breadcrumbs\Crumb\CrumbType;
use X3P0\Breadcrumbs\Icon\Icon;

$event->addCrumb(CrumbType::Custom, [
	'label' => __('Courses', 'my-plugin'),
	'url'   => home_url('/courses/'),
	'icon'  => Icon::Article->name()
]);
Keys Derived From Your Own Enum

Writing 'my-plugin:course' at every call site is the same problem the plugin solves for itself with IconPresetKey. If your keys are derived from a domain enum you already have, implement IconPresetDefinition and the case becomes the key:

use X3P0\Breadcrumbs\Icon\IconPresetDefinition;

enum Lesson: string implements IconPresetDefinition
{
	case Quiz = 'quiz';
	case Video = 'video';

	public function presetKey(): string
	{
		return 'my-plugin:lesson:' . $this->value;
	}
}
$presets->register(Lesson::Quiz, new IconPreset(
	Icon::Article,
	__('Quiz', 'my-plugin')
));

And in the crumb, with no conversion in sight:

public function getIconPresetKey(): IconPresetDefinition|string
{
	return Lesson::tryFrom($this->kind) ?? $this->getSlug();
}

Every signature that accepts a key accepts the interface, so nothing has to convert first. It extends UnitEnum rather than BackedEnum, so a pure enum β€” one deliberately carrying no backing value because it mirrors constants another plugin owns β€” can implement it too. This is how the plugin's own WooCommerce integration names its endpoint, store page, and catalog sorting keys.

Note that this is not one of the Contracts\EnumDefinition family. Those map a case to a concrete class for a factory to build; this maps a case to a string, and there's no class behind an icon preset key.

Available Hooks

Beyond the event bridges above, the plugin fires one lifecycle action.

x3p0/breadcrumbs/register

Fires immediately after the X3P0\Breadcrumbs\Plugin class registers its default bindings and service providers, but before they boot. The plugin object is passed to attached callbacks, giving you access to the container to register your own services, listeners, or custom types. This is the correct hook for any of the registration examples in this section.

do_action('x3p0/breadcrumbs/register', $plugin);

Building Custom Query, Assembler, Crumb, and Markup Types

There may be times when you need custom Query, Assembler, Crumb, or Markup classes for custom use cases. There's no registry to populate β€” each subsystem's factory resolves a class directly through the container, so you just extend the subsystem's abstract base (Query, Assembler, or Crumb) and dispatch to it by its ::class name wherever the plugin accepts that subsystem's type:

namespace MyPlugin\Breadcrumbs;

use X3P0\Breadcrumbs\Query\Query;

final class MyQuery extends Query
{
	public function query(): void
	{
		$this->context->addCrumb(/* ... */);
	}
}
use MyPlugin\Breadcrumbs\MyQuery;
use X3P0\Breadcrumbs\Query\Event\QueryTypeResolving;

add_action(QueryTypeResolving::NAME, function (QueryTypeResolving $event) {
	if ($yourCondition) {
		$event->queryType = MyQuery::class;
	}
});

The same pattern applies to a custom Assembler β€” dispatched via $context->assemble(MyAssembler::class) β€” and a custom Crumb β€” dispatched via $context->makeCrumb(MyCrumb::class) or $context->addCrumb(MyCrumb::class). Please study the plugin's existing classes under src/ if you need to understand the conventions and, more precisely, the abstract contracts to extend.

Note that $context isn't the same object for every subsystem: a Query receives a QueryContext (query(), assemble(), makeCrumb(), addCrumb()), while an Assembler receives the narrower AssemblerContext (everything but query()) β€” an assembler can delegate to other assemblers and build crumbs, but it cannot dispatch a query, by design. A Crumb doesn't receive a context at all; its constructor takes BreadcrumbsConfig $config directly, since a crumb only ever needs to read config to produce its label and URL. For how a custom crumb picks up an icon, see Icons on Custom Crumbs.

Custom Markup Types

A custom Markup type works the same way: extend Markup and pass its ::class name as markupType (or to setMarkupType() on the MarkupRendering event). If you also want it selectable by a short string key β€” for markupType: 'my-key', or as an option in the block editor's markup control β€” implement MarkupBlockOption and tag the class under Markup::TAG:

use X3P0\Breadcrumbs\Markup\Markup;
use X3P0\Breadcrumbs\Markup\MarkupBlockOption;

final class MyMarkup extends Markup implements MarkupBlockOption
{
	public static function key(): string
	{
		return 'my-key';
	}

	public static function label(): string
	{
		return __('My Markup', 'my-plugin');
	}

	public function render(): string
	{
		// ...
	}
}
use X3P0\Breadcrumbs\Markup\Markup;

add_action('x3p0/breadcrumbs/register', function ($plugin) {
	$plugin->container()->tag(MyMarkup::class, Markup::TAG);
});

The Extension System

The event examples above are the raw seams. When you're integrating an entire platform or plugin β€” routing several pages through custom queries and wiring up listeners β€” the plugin offers a tidier way to bundle it all into a single class: an extension. This is how the plugin's own built-in WooCommerce integration works (which requires WooCommerce 11.1 or later), and third parties use the same mechanism with no core edits.

An extension extends X3P0\Breadcrumbs\Extension\Extension, which implements the event package's ListenerSubscriber contract, and defines up to two methods:

  • subscribeTo(Listenable $registry): void β€” registers the extension's listeners on the registry it is given, typically a handful of $registry->listenTo(...) or $registry->listen(...) calls. This is how you subscribe to QueryTypeResolving, CrumbsBuilt, and MarkupRendering without reaching for the global action bridges.
  • boot(): void β€” optional, and a no-op by default. Runs once, before the extension's listeners are subscribed, for wiring that isn't an event subscription β€” hooking WordPress actions and the like.

There's no isActive() or register() on the base class β€” an extension is treated as active the moment it's tagged (see below), so the platform guard (checking for a class or function the target platform defines) belongs in the code doing the tagging. And there's nothing to register: any custom Query, Assembler, or Crumb types the extension needs are just classes dispatched by their ::class name, exactly as described above.

Constructor dependencies, if any, are resolved from the container.

Here's an extension that reroutes a page to a custom query and relabels a crumb:

use X3P0\Breadcrumbs\Crumb\Crumb;
use X3P0\Breadcrumbs\Crumb\Event\CrumbsBuilt;
use X3P0\Breadcrumbs\Crumb\Type\PostType;
use X3P0\Breadcrumbs\Extension\Extension;
use X3P0\Breadcrumbs\Packages\Event\Listener\Listenable;
use X3P0\Breadcrumbs\Query\Event\QueryTypeResolving;

final class MyExtension extends Extension
{
	public function subscribeTo(Listenable $registry): void
	{
		$registry->listenTo($this->onQueryTypeResolving(...));
		$registry->listenTo($this->onCrumbsBuilt(...));
	}

	public function onQueryTypeResolving(QueryTypeResolving $event): void
	{
		if (my_platform_is_thing_page()) {
			$event->queryType = MyQuery::class;
			$event->stopPropagation();
		}
	}

	public function onCrumbsBuilt(CrumbsBuilt $event): void
	{
		$event->crumbs->replaceInstanceWhere(
			PostType::class,
			fn (PostType $crumb) => 'thing' === $crumb->postType->name,
			fn (PostType $crumb) => $event->makeCrumb(ThingCrumb::class, [
				'decoratedCrumb' => $crumb
			])
		);
	}
}

For anything larger than a couple of small handlers, register a listener by class name instead of by method. The registry builds it through the container the first time its event actually fires:

final class MyExtension extends Extension
{
	public function subscribeTo(Listenable $registry): void
	{
		$registry->listen(QueryTypeResolving::class, RerouteThingQueries::class);
		$registry->listen(CrumbsBuilt::class, RelabelThingCrumbs::class);
	}
}

Each listener is then a class of its own with a single __invoke() method taking the event, and may declare its own constructor dependencies for the container to resolve. listen() names the event explicitly, since a class name carries no signature to read it from β€” in exchange, subscribeTo() reads as a manifest of everything the extension does, and each behavior can be swapped, removed, or reasoned about on its own. The built-in WooCommerce integration is written this way.

To activate it, guard on the target platform, bind the extension in the container, and tag it with Extension::TAG on the x3p0/breadcrumbs/register action. Tagged extensions are subscribed automatically, right alongside the built-ins:

use X3P0\Breadcrumbs\Extension\Extension;

add_action('x3p0/breadcrumbs/register', function ($plugin) {
	if (! function_exists('my_platform')) {
		return;
	}

	$plugin->container()->singleton(MyExtension::class);
	$plugin->container()->tag(MyExtension::class, Extension::TAG);
});

That's the whole lifecycle in one class: the platform guard gating the single tag call, and the event listeners doing the real work β€” opted into the same boot sequence the plugin uses for its own WooCommerce integration.

License

X3P0 Breadcrumbs is licensed under the GPL version 3.0 or later.

The project includes resources from Material Icons, which are licensed under Apache 2.0.

About

πŸ₯– Breadcrumbs block for WordPress.

Topics

Resources

Contributing

Security policy

Stars

49 stars

Watchers

3 watching

Forks

Releases

Packages

Used by

Contributors

Languages