For WordPress sites using Custom Post Types, Taxonomy admin listings show cumulative term-assignment totals for all post_types instead of counts specific to the selected post_type. Since Taxonomy listings are always attached to a single post_type, totals for shared taxonomies don't make sense.
Fix term counts so wp-admin Taxonomy listings show the correct number of items for the selected post_type.
Post_type-specific counts should not replace WordPress's built-in, cumulative counts. Add functionality, don't remove any.
Post_type-specific counts should also be available to templates.
Since a term's post-counts don't change frequently and could be moderately costly to collect, WordPress stores term counts in the database. Those numbers are updated whenever a term is edited or assigned to a post. Likely because these counts pre-date the addition of term metadata in WordPress 4.4, term counts are stored in the wp_term_taxonomy
table, not wp_termmeta
.
Taxonomies can have an update_count_callback
, which is sort of like a hook that's called when term-counts are modified. Unfortunately, if that callback exists the logic inside wp_update_term_count_now()
forks and skips the two native, private term counting functions: _update_post_term_count()
and _update_generic_term_count()
. There doesn't seem to be any way of maintaining existing term count functionality and calling those without doing something regrettable like duplicating the dozen-line else clause from wp_update_term_count_now()
.
But digging deeper, it turns out to there's a common action named edited_term_taxonomy
called from both _update_post_term_count()
and _update_generic_term_count()
. This offers a much cleaner, future-proof hook for adding additional counts whenever the native counts are updated.
Whenever a term is modified or assigned to a post, the edited_term_taxonomy
hook generates a post_type-specific count and stores that value in the term's metadata.
Those counts are exposed to every matching WP_Term_Query
by the pre_get_terms
hook so WordPress can correctly order the results.
Metadata keys and orderby attributes both follow the pattern count_{$post_type}
.
A new, post_type-specific Count column replaces the native, cumulative count column on Taxonomy admin screens. Finally, a Refresh Count bulk action so counts can be added to existing terms.
Once the class is instantiated, wp-admin will display the new Count column. On the frontend, define orderby
in query_vars equal to something like count_{$post_type}
.
This code will live in a single PHP class. This makes it easy to include in a custom theme or wrap as a plugin without having to refactor anything. Because of this, some of the function/method nces look different from conventional WordPress code.
The class constructor sets up the three primary actions:
class TaxonomyCountColumn
{
public function __construct()
{
add_action('edited_term_taxonomy', [$this, 'updateCounts'], 100, 2);
add_action('pre_get_terms', [$this, 'orderByMeta']);
add_action('wp_loaded', [$this, 'setupTaxonomyAdmin']);
}
// other methods go here
}
This method collects post counts for a term and stores the value in the term's metadata.
public function updateCounts($termId, $taxName)
{
global $post_type;
$post_types = (array) ($post_type ?? $_REQUEST['post_type'] ?? get_taxonomy($taxName)->object_type);
$guid = sha1(json_encode([$termId, $taxName, $post_types]));
if (get_transient($guid) === false) {
foreach ($post_types as $type) {
$args = [
'posts_per_page' => -1,
'post_type' => $type,
'tax_query' => [['taxonomy' => $taxName, 'terms' => $termId]],
];
$countQuery = new \WP_Query($args);
update_term_meta($termId, "count_{$type}", $countQuery->found_posts);
}
set_transient($guid, true, 60);
}
}
To reduce server load and guard against multiple calls, the rest of this method is wrapped up in a 60-second transient. The transient $guid
is a just quick hash of the method arguments and $post_types
.
Some AJAX requests may be missing a $post_type
, so a stack of possible $post_type
sources are coalesced into the $post_types
array.
For each $post_type
(usually just one), $countQuery
collects all posts matching the current term and $post_type
then stores the post_count value for each term with update_term_meta()
.
For WordPress to be able to sort get_terms()
results by the new post_type-counts, the count metadata needs to be exposed. This method attaches to the pre_get_terms
hook and adds a new WP_Meta_Query
which provides WordPress with the post_type-specific count values it needs for ordering.
public function orderByMeta(\WP_Term_Query $query)
{
global $post_type;
$orderby = $query->query_vars['orderby'] ?? false;
if ($orderby && preg_match('/^count_[-a-z]+/', $orderby)) {
$meta_query = new \WP_Meta_Query([
"count_$post_type" => [
'relation' => 'OR',
['key' => "count_$post_type", 'type' => 'NUMERIC'],
['key' => "count_$post_type", 'compare' => 'NOT EXISTS'],
],
]);
$query->meta_query = $meta_query;
}
}
Because it's possible a term doesn't yet have post_type-count metadata yet, the meta_query matches terms where the key does and doesn't exist. It's a little bit messier, but otherwise terms without counts would be omitted.
Note: The $query
argument is passed by reference and doesn't need to be returned.
The remaining methods are mostly just wp-admin boilerplate. But one exception is setupTaxonomyAdmin
which adds the dynamic admin hooks for each taxonomy.
public function setupTaxonomyAdmin()
{
$taxonomies = get_taxonomies(['public' => true], 'names');
foreach ($taxonomies as $taxonomy) {
add_filter("manage_edit-{$taxonomy}_columns", [$this, 'addCountColumn'], 100);
add_filter("manage_edit-{$taxonomy}_sortable_columns", [$this, 'makeCountColumnSortable'], 100);
add_action("manage_{$taxonomy}_custom_column", [$this, 'renderCountColumn'], 100, 3);
add_filter("bulk_actions-edit-{$taxonomy}", [$this, 'addResetBulkAction']);
add_filter("handle_bulk_actions-edit-{$taxonomy}", [$this, 'bulkActionHandler'], 100, 3);
}
add_action('admin_notices', [$this, 'addCountUpdateNotice']);
add_action('admin_enqueue_scripts', [$this, 'adminCountColumnStyles'], 100);
}
The five dynamic hooks for creating columns and bulk_actions are specific to each taxonomy, but the remaining two actions are generic and only need to be called once.
I have no good reason for calling everything at priority 100. The default is 10, and I just wanted to slip these in at the end.
The complete code is available in this Gist.
I should probably wrap it up in a plugin, if no one else gets to it first.There is a slight discrepancy between how wp-admin tables and get_terms()
resolve multiple terms with the same orderby value. I yet don't know what extra step the admin table is taking and haven't been able to get compound orderby statements working in a WP_Term_Query
.
- The native "Count" column uses the key
posts
. - Chesterton's Fence is one of the best things I learned on Wikipedia.
- Why store counts in the database? Well, on a taxonomy admin page listing 13 items, the
get_{$taxonomy}
filter is called 156 times. No idea why, that's 12 times per term -- with only 4 visible columns.
Thank you SO much for providing such an insightful patch!
I would just like to propose one small improvement:
line 91: $newCols['post_type_count'] = 'Count '. ucfirst(get_current_screen()->post_type) . 's';
so that it is clear what is being counted. Thanks again!