WordPress’ taxonomy system is powerful enough to help you organize your content in many interesting ways. On some edge cases, though, some intricacies can cause you headaches. One such example is that on non-hierarchical terms like tags or some custom taxonomy, the CMS doesn’t seem to respect the order by which you enter them on the post.

As you can see on the above GIF, no matter how you add your tags, when you click on “Update“, the order reverts back to the defaults, which is based on the tags’ IDs. In most cases, this passes unnoticed, but there are projects where it might be a problem.

If you look at the database, though, you will see that there is a table called wp_term_relationships where the posts are being matched with the terms that they contain, and a term_order field is supposedly setting the order of appearance.

The problem here is that, for some reason, WordPress doesn’t seem to set that order when you save the post.

Searching for a solution, I came across some StackOverflow answers which suggested hooking into save_post or wp_insert_post to update the values, and I’ve even found a plugin that used to take care of the issue. As stated on the plugin’s documentation, though, it is not compatible with Gutenberg on WordPress 5.x. The same goes for the aforementioned hooks, which, after Gutenberg, became unreliable for such use.

Digging a bit deeper revealed that there is a poorly documented rest_after_insert_this-post_type hook which is most suitable for our case, as it fires right after a single post is completely created or updated via the REST API. The lack of an official example required some trial-and-error, but eventually, it turned out that the hook is exactly what we need.

So, to fix our issue, we need to combine all of the above to perform the following tasks:

  • Hook into the rest_after_insert_this-post_type and update the order of the wp_term_relationships table when the user updates a post.
  • Instruct the taxonomies to use each post’s custom order instead of the terms’ IDs.
  • As a bonus, make this as much reusable as we can, to easily restrict it to specific post types and taxonomies, if we need to.

Eventually, we end up with the following Class, which is now part of the Slash Admin plugin (hence the namespace):

<?php

namespace SlashAdmin;

class TaxonomyOrder {

	private wpdb $wpdb;

	/**
	 * Respect the order by which non-hierarchical terms are being added to a post.
	 *
	 * @param array $post_types
	 * @param array $taxonomies
	 *
	 */

	public $taxonomies = [];
	public $post_types = [];

	public function __construct( $post_types = [], $taxonomies = [] ) {
		global $wpdb;
		$this->wpdb       = $wpdb;
		$this->post_types = $post_types;
		$this->taxonomies = $taxonomies;
		add_action( 'init', array( $this, 'applyOrder' ) );
		if ( $this->getPostTypes() ) {
			foreach ( $this->getPostTypes() as $post_type ) {
				add_action( 'rest_after_insert_' . $post_type, array( $this, 'setOrder' ), 10, 2 );
			}
		}
	}

	public function applyOrder() {
		global $wp_taxonomies;
		foreach ( $this->getTaxonomies() as $taxonomy ) {
			$wp_taxonomies[ $taxonomy ]->sort = true;
			$wp_taxonomies[ $taxonomy ]->args = array( 'orderby' => 'term_order' );
		}
	}

	public function setOrder( $post, $request ) {
		$params = $request->get_params();
		foreach ( $this->getTaxonomies() as $taxonomy ) {
			$tax_name = $this->getBackendTaxName( $taxonomy );
			if ( isset( $params[ $tax_name ] ) && $params[ $tax_name ] ) {
				foreach ( $params[ $tax_name ] as $index => $term_id ) {
					$where = [
						'object_id'        => $post->ID,
						'term_taxonomy_id' => $term_id,
					];
					$data  = [
						'term_order' => intval( $index ),
					];
					$this->wpdb->update( $this->wpdb->prefix . 'term_relationships', $data, $where );
				}
			}
		}
	}

	private function getPostTypes() {
		$post_types = [];
		if ( $this->post_types ) {
			$post_types = $this->post_types;
		} else {
			$all_post_types = get_post_types();
			$excludes       = [
				'attachment',
				'revision',
				'nav_menu_item',
				'custom_css',
				'customize_changeset',
				'oembed_cache',
				'user_request',
				'wp_block',
			];
			if ( $all_post_types ) {
				foreach ( $all_post_types as $post_type ) {
					if ( ! in_array( $post_type, $excludes ) ) {
						$post_types[] = $post_type;
					}
				}
			}

		}

		return $post_types;
	}

	private function getTaxonomies() {
		$non_hierarchical = [];
		if ( $this->taxonomies ) {
			$non_hierarchical = $this->taxonomies;
		} else {
			$taxonomies = get_taxonomies();
			$excluded   = [ 'nav_menu', 'link_category', 'post_format' ];
			if ( $taxonomies ) {
				foreach ( $taxonomies as $taxonomy ) {
					if ( ! is_taxonomy_hierarchical( $taxonomy ) && ! in_array( $taxonomy, $excluded ) ) {
						$non_hierarchical[] = $taxonomy;
					}
				}
			}
		}

		return $non_hierarchical;
	}

	private function getBackendTaxName( $taxonomy ) {
		$name    = $taxonomy;
		$matches = [
			'post_tag' => 'tags',
		];
		if ( isset( $matches[ $taxonomy ] ) ) {
			$name = $matches[ $taxonomy ];
		}

		return $name;
	}
}

The key parts here are in the setOrder() and applyOrder() methods. The first method takes care of the database update and the second one instructs WordPress to respect our custom ordering. Then, both of them are being called in their respective actions inside __construct(). The rest of the code contains some helper methods to make it more reusable and extendable, but the core functionality is on those first two methods.

To call it, all you need to do is include the Class on your project and add the following line somewhere in your functions.php:

new SlashAdmin\TaxonomyOrder();

Alternatively, if you don’t feel comfortable editing your theme’s code, you can use Slash Admin and activate the “Term order in posts” option found under the plugin’s “Administration” tab.

Finally, using the class in your code can also give you some more control over the post types and taxonomies that will receive the change, as you can pass them as variables, like for example:

new SlashAdmin\TaxonomyOrder(
		[
			'post',
			'my-custom-post-type'
		],
		[
			'post_tag',
			'my-custom-taxonomy'
		]
	);

Leave a Reply

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

We use cookies. By browsing our site you agree to our use of cookies. Read moreAccept