Lets an editor mark one term per taxonomy as a post's primary one, and gives
you explicit ways to use that choice: an API, an option on core's Post Terms
block, a Block Bindings source, and %category% permalinks.
Nothing about a post's term order changes. get_the_terms(), get_the_category()
and everything built on them return exactly what they returned before — the
plugin stores the choice and hands it to whatever asks for it.
Enabled for category out of the box; opt in any other taxonomy with a filter.
The chosen term is only honoured while it is still one of the post's terms. Untick it and the post falls back to its first term; tick it again and the editor's choice is restored. There is no meta to clean up.
- PHP 8.1+
- WordPress 6.6+
composer require humanmade/wp-primary-categoryThe package is a wordpress-plugin, so composer/installers puts it in
wp-content/plugins/. Activate it as usual.
hm_primary_term_taxonomies filters the list, and is evaluated on every call —
hook it wherever suits. Anything that is not a registered taxonomy is dropped, so
a typo degrades to "no primary term for that taxonomy" rather than an error.
One exception to "wherever suits": hook it before init priority 20, which is
when the post meta is registered. A taxonomy added after that has no meta, and
so no REST field, no editor picker and no block toggle.
// Also allow a primary tag.
add_filter( 'hm_primary_term_taxonomies', function ( array $taxonomies ): array {
$taxonomies[] = 'post_tag';
return $taxonomies;
} );
// Leave categories alone; only sectors get a primary term.
add_filter( 'hm_primary_term_taxonomies', function ( array $taxonomies ): array {
return array_values( array_diff( $taxonomies, [ 'category' ] ) );
} );
// Turn the feature off entirely.
add_filter( 'hm_primary_term_taxonomies', '__return_empty_array' );All functions live in the HM\Primary_Term namespace.
const META_PREFIX = '_hm_primary_';
// The enabled taxonomies, filtered and validated.
function taxonomies(): array;
// The post meta key for a taxonomy, e.g. `_hm_primary_category`.
function meta_key( string $taxonomy ): string;
// The chosen term if the post still has it, else the post's first term, else null.
function get_primary_term( int $post_id, string $taxonomy = 'category' ): ?\WP_Term;
// The same, as an ID; 0 when the post has no terms in the taxonomy.
function get_primary_term_id( int $post_id, string $taxonomy = 'category' ): int;
// Record a choice. False if the term does not exist, is in another taxonomy, or
// is not assigned to the post. A term ID of 0 clears the choice.
function set_primary_term( int $post_id, int $term_id, string $taxonomy = 'category' ): bool;The choice is stored in post meta keyed _hm_primary_{$taxonomy}, registered
for every post type the taxonomy is attached to and exposed in the REST API.
Core's Post Terms block takes an extra hmPrimaryOnly attribute. Set it and the
block renders the post's primary term for whichever taxonomy its own term
attribute names, instead of the whole list:
<!-- wp:post-terms {"term":"category","hmPrimaryOnly":true} /-->In the editor the option is a Primary term only toggle in the block's inspector sidebar. It appears only when the block points at a taxonomy this plugin has enabled, so the toggle cannot promise something the server will not deliver.
Markup, classes, prefix and suffix are core's — the block renders normally and the list is trimmed to its first term, rather than being rebuilt by hand.
A taxonomy that is not enabled still works if you set the attribute directly: it falls back to that taxonomy's first term rather than erroring.
The term ordering this needs is applied for the duration of that one block's render and removed immediately afterwards. No other block, template or query in the same request sees a changed term order.
The Post Terms block renders the primary term as a term list. When you want
the bare value — in a heading, a button's URL, a sentence — bind it instead. The
hm/primary-term source resolves to the same term get_primary_term() returns,
for any block attribute that accepts a binding.
| Argument | Values | Default |
|---|---|---|
taxonomy |
Any taxonomy name. | category |
key |
name, url or slug. |
none — required |
url is the term's archive link, via get_term_link(), so it works for any
taxonomy rather than just categories.
A "More on ..." button linking to the primary sector's archive, with the label bound to the sector's name:
<!-- wp:buttons -->
<div class="wp-block-buttons"><!-- wp:button {"metadata":{"bindings":{
"url":{"source":"hm/primary-term","args":{"taxonomy":"sector","key":"url"}},
"text":{"source":"hm/primary-term","args":{"taxonomy":"sector","key":"name"}}
}}} -->
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="#">More</a></div>
<!-- /wp:button --></div>
<!-- /wp:buttons -->And the primary category's name in a paragraph:
<!-- wp:paragraph {"metadata":{"bindings":{"content":{"source":"hm/primary-term","args":{"key":"name"}}}}} -->
<p>Uncategorised</p>
<!-- /wp:paragraph -->The binding returns nothing — and so the block keeps its own saved content — when
the post has no terms in that taxonomy, when key is not one of the three above,
or when the term has no resolvable link. That fallback is the point: write
something sensible into the block and an uncategorised post renders it, rather
than an empty heading or a dead link.
There is deliberately no format or template argument. A translatable string
like "More on %s" parked in block markup is out of reach of the site's own
translations — wrap the binding in whatever markup and copy the template needs
instead.
The source requires WordPress 6.5+ for Block Bindings. On anything older it is not registered and every bound block falls back to its own content.
Sites with %category% in their permalink structure get the primary category in
the URL rather than whichever one core happened to pick. This is a
post_link_category filter, and it applies only while category is an enabled
taxonomy and the primary is one of the categories core offered.
Changing which category appears in a post's URL changes that URL. On an existing
site, check your redirects before enabling this on a %category% structure.
Turn it off on a site that already has %category% URLs in the wild —
changing which term appears changes the URL:
add_filter( 'hm_primary_term_filter_permalinks', '__return_false' );The control renders inside the core taxonomy panel, directly below the term selector — the same place Yoast SEO puts its own primary category control. It lists the terms the post already has and writes the chosen ID to the meta.
It appears only once the post has two or more terms in that taxonomy. One term is the primary by definition, so a control offering a single fixed option is a question with one answer. Assign a second term and it appears.
It appears for every enabled taxonomy, hierarchical or flat, on every post type that taxonomy is attached to. A post with no terms in the taxonomy gets no control: the selector immediately above already makes that obvious.
Unticking the chosen term does not clear the meta. The choice is ignored while the term is missing and honoured again the moment it is ticked back on.
0.2.0 removes the global get_the_terms filter. Up to 0.1.2 the plugin put
the chosen term at index 0 for every caller. That reach was the selling point,
and it is the reason it has gone: it changed feeds and archive listings that
never asked for it, and a maintainer reading get_the_category()[0] had no way
to see why it worked.
What replaces it is explicit and opt-in: get_primary_term(), the
hmPrimaryOnly block attribute, and the permalink filter.
If you were relying on the ordering, you have to say so now:
| Was | Now |
|---|---|
get_the_category( $post )[0] |
get_primary_term( $post ) |
get_the_terms( $post, $tax )[0] |
get_primary_term( $post, $tax ) |
A core/post-terms block showing the primary first |
Add hmPrimaryOnly — it now shows the primary alone |
%category% permalinks picking the primary |
Still does, now via post_link_category rather than as a side effect |
sort_primary_term_first() has been removed. Nothing else in the API changed:
the meta keys, get_primary_term(), set_primary_term() and the migration
command all behave as before, so there is no data to migrate.
Yoast stores a primary term as a term ID in _yoast_wpseo_primary_{$taxonomy}.
This plugin stores the same integer in _hm_primary_{$taxonomy}. The shapes are
identical, so the migration copies keys rather than transforming data:
wp primary-term migrateReversing the direction syncs choices back to Yoast, which is what you want before rolling this plugin back:
wp primary-term migrate --from=hm --to=yoast --overwriteThat is a one-off command and deliberately not a runtime hook, so nothing in this plugin depends on Yoast being installed.
| Flag | What it does |
|---|---|
--from=<yoast|hm> |
Where to read from. Default yoast. |
--to=<yoast|hm> |
Where to write. Default hm. Must differ from --from. |
--taxonomy=<list> |
Comma-separated. Defaults to the enabled taxonomies — pass this explicitly for one that is not enabled yet. |
--post-type=<list> |
Comma-separated. Defaults to every post type each taxonomy is attached to. |
--batch-size=<n> |
Posts loaded per batch. Default 500. |
--dry-run |
Report and write nothing, --cleanup included. |
--overwrite |
Replace a destination that already holds a different term. |
--cleanup |
Delete the source meta once a post has been copied successfully. |
Every post is reported as one of five outcomes, counted per taxonomy:
- copied — the destination was empty and the source had a usable value.
- already — the destination already held the same term. Nothing written.
- conflict — the destination held a different term. Skipped and logged
with its post ID, because that value is somebody's explicit choice and you did
not ask for it to be thrown away.
--overwritereplaces it and counts it as copied. - orphaned — the source names a term the post no longer has, or one that no
longer exists. Skipped and logged:
get_primary_term()would refuse to return it, so copying it would only spread a value that is already broken. - empty — no source value. Skipped quietly.
Conflicts and orphans end the run on WP_CLI::warning() rather than
WP_CLI::success(), so a scripted run can tell that something was left behind.
--cleanup deletes the source only after a successful copy. Never on a skip, an
unresolved conflict, or a dry run.
# 1. See what would happen. Nothing is written.
wp primary-term migrate --dry-run
# 2. Do it. Conflicts are reported with their post IDs and left alone.
wp primary-term migrate
# 3. Verify: a second dry run should now report everything as `already`.
wp primary-term migrate --dry-run
# 4. Once you are happy, drop Yoast's copy of the data.
wp primary-term migrate --cleanupDeal with any conflicts between steps 2 and 4 — either by hand, or by re-running
with --overwrite if Yoast's value is the one you want to keep. Step 4 will not
clean up a post it has not successfully copied, so anything unresolved keeps
both values until you decide.
composer install
npm install
composer lint # PHPCS, HM standard
npm test # PHPUnit inside WordPress Playground — no database neededThe integration suite runs WP_UnitTestCase against the ephemeral,
SQLite-backed WordPress that @wp-playground/cli boots, so there is no MySQL or
Docker to set up. See tests/integration/bootstrap-playground.php for the one
shim that makes that work.
Generalised from the primary-sector feature built for The Media Leader, where it is in production.
GPL-2.0-or-later.