Video icon 64
Learning to code? Skill up faster with our practical video courses. Start your free trial today.
Advertisement

The Rewrite API: Post Types & Taxonomies

by

This is part two of a series looking at WordPress' Rewrite API. In part one we took a whistle stop tour of the basics of WordPress' Rewrite API. In this tutorial we will look at the rewrite settings available to us when registering a post type or taxonomy. While custom post types and taxonomies (unlike the default posts, categories and tags) don't benefit from any Settings -> Permalink interface, setting up rewrites for custom types is still fairly straightforward. We'll also be using the methods introduced in part one, so if you haven't already I recommend you read WordPress' Rewrite API Part One: The Basics.


Flushing the Rewrite Rules

When you register a custom type, WordPress also registers rewrite rules (actually, not quite, and I'll explain why in the 'Permastructures' section). As mentioned in part one, these rules will only be included once the rewrite rules are 'flushed'. Themes and plug-ins can force this 'flushing' by calling flush_rewrite_rules(). This needs to, and should only, be done once on activation and then again on deactivation (to clean up after yourself).

Obviously, prior to flushing the rewrite rules, you need to have added them. However the init hook on which post types should be registered, has already been fired and when it was, your plug-in or theme wasn't yet active and so your post types and taxonomies are not yet registered. In order to register the rewrite rules that come with your post types and taxonomies, this means you need to 'manually' register them on activation, prior to flushing the rewrite rules. So, this should be your set up:

function wptuts_register_types() {
	//function which registers your custom post type and taxonomies
}
add_action('init','wptuts_register_types');

function wptuts_plugin_activation() {

	// Register types to register the rewrite rules
	wptuts_register_types();

	// Then flush them
	flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'wptuts_plugin_activation');

function wptuts_plugin_deactivation() {

	flush_rewrite_rules();
}
register_activation_hook( __FILE__, 'wptuts_plugin_activation');

Themes can use the hooks after_switch_theme for activation and switch_theme for de-activation.


Custom Post Types

When you register a post type with register_post_type one of the arguments available to you is the rewrite argument. This should be an array with the following keys:

  • slug – a slug used to identify the post type in URLs. The post's slug is appended to this for the post's permalink e.g. www.example.com/books/the-wizard-of-oz
  • with_front – true or false. If your post's permalink structure begins with a constant base, such as '/blog' – this can also be added to your custom post type's permalink structure by setting it to true e.g. true will give www.example.com/blog/books/ and false www.example.com/books/
  • feeds – true or false. Whether to generate feed rewrite rules e.g. www.example.com/books/feed/rss and www.example.com/book/rss. The default value is the value of has_archive.
  • pages – true or false. Whether to generate rule for 'pretty' pagination for the post type archive e.g. www.example.com/books/page/2 instead of www.example.com/books?page=2. Defaults to true.
  • ep_mask – This used to be a separate argument: permalink_epmask. As of 3.4 it will move to the rewrite array. The default is EP_PERMALINK.

The 'feeds' and 'pages' keys concern only the post-type archive page (for which you need to have set the has_archive argument to true). From this rewrite array WordPress handles the generation of the rewrite rules for your post types automatically. As an example:

$labels = array(
	'name' => __('Books', 'your-plugins-translation-domain'),
	//array of labels
);

$args = array(
	'labels' => $labels,
	'has_archive'=>true,
	'rewrite' => array(
		'slug'=>'books',
		'with_front'=> false,
		'feed'=> true,
		'pages'=> true
	)
);
register_post_type('book',$args);

Would give the following rewrite rules:

  • The permalink of the book 'the-wizard-of-oz': www.example.com/books/the-wizard-of-oz
  • Archive of all books www.example.com/books (and www.example.com/books/page/2)
  • The feed of the above archive: www.example.com/books/feed/rss (and www.example.com/books/rss)

Taxonomies

The register_taxonomy() function offers fewer options:

  • slug – a slug to identify the taxonomy e.g. www.example.com/genre/history
  • with_front – As above.
  • hierarchical – true or false. If set to true and the taxonomy is hierarchical, the term permalink reflects the hierarchy. Defaults to false.
  • ep_mask – Added in 3.4. See EP Mask section below.

The first two options are similar to the above. The hierarchical option gives the term permalinks the same structure as pages. For example, let 'History' be a genre and 'Military History' a sub-genre. With hierarchical set to false, 'Military History' will have a the permalink:

www.example.com/genre/military-history

Whereas, set to true, it will have:

www.example.com/genre/military/military-history

Registering a taxonomy automatically registers your taxonomy-terms' feeds:

www.example.com/genre/military-history/feed

You can get the feed-link permalink to any taxonomy term with $feed_link = get_term_feed_link($term_id, $taxonomy, $feed)


Post Type Archives

By default, WordPress doesn't produce 'pretty' permalinks for your custom post type's year, month or day archives (nor the author archive – but we'll leave that one for now). While:

www.example.com/?post_type=book&year=2012&monthnum=05

Correctly gives an archive of all books published in May 2012:

www.example.com/books/2012/05

Will give a 404 error. However, we can simply add extra rewrite rules using the available rewrite API methods we covered in part one. One method is to add the following list of rewrite rules when you register your post type:

// Add day archive (and pagination)
add_rewrite_rule("books/([0-9]{4})/([0-9]{2})/([0-9]{2})/page/?([0-9]{1,})/?",'index.php?post_type=book&year=$matches[1]&monthnum=$matches[2]&day=$matches[3]&paged=$matches[4]','top');
add_rewrite_rule("books/([0-9]{4})/([0-9]{2})/([0-9]{2})/?",'index.php?post_type=book&year=$matches[1]&monthnum=$matches[2]&day=$matches[3]','top');

// Add month archive (and pagination)
add_rewrite_rule("books/([0-9]{4})/([0-9]{2})/page/?([0-9]{1,})/?",'index.php?post_type=book&year=$matches[1]&monthnum=$matches[2]&paged=$matches[3]','top');
add_rewrite_rule("books/([0-9]{4})/([0-9]{2})/?",'index.php?post_type=book&year=$matches[1]&monthnum=$matches[2]','top');

// Add year archive (and pagination)
add_rewrite_rule("books/([0-9]{4})/page/?([0-9]{1,})/?",'index.php?post_type=book&year=$matches[1]&paged=$matches[2]','top');
add_rewrite_rule("books/([0-9]{4})/?",'index.php?post_type=book&year=$matches[1]','top');

This can easily get a bit messy – especially when you consider that you would need to add extra rules if you wanted your archives to support pretty URLs for their feeds. However, the above illustrates an important fact about adding custom rules: The order in which the rules are added is important.

Recall that these rules are added to the rewrite array in the order in which you call add_rewrite_rule. When parsing a request, WordPress uses the first matching rule. Try switching the order in which the year and month archive rules are added. You'll find that www.example.com/books/2012/04/ takes you to the 2012 archive. This is because that URL matches the patterns for both the year and month archives, but the former was added first. Remember to always add the more specific rule first.

On the other hand, if you add a rewrite rule, whose regex already exists as a rule, that rule shall be overridden by the new one.


Permastructures

There is an easy way to achieve the above: add_permastruct. This function is used by WordPress to create 'permastructures', from which it generates rewrite rules (like the above), which handle pagination and feeds. When you register a custom post type, WordPress doesn't automatically create all the rewrite rules. Instead it registers a permastructure – and only when the rules are being generated (i.e. when they are being flushed) does WordPress use those permastructures to generate the actual rewrite rules.

An example of a permastructure is the one you use in Settings -> Permalinks. These accept any 'hard-coded' slugs or any tags that exist by default or have been added with add_rewrite_tag, which we covered in part one. For example, the permastructure %year%/%category%/%author% would generate the following rewrite rules:

  • www.example.com/2012/url-rewriting/stephen
  • www.example.com/2012/url-rewriting/stephen/page/2
  • www.example.com/2012/url-rewriting/stephen/feed/rss
  • www.example.com/2012/url-rewriting/
  • www.example.com/2012/url-rewriting/page/2
  • www.example.com/2012/url-rewriting/feed/rss
  • www.example.com/2012/
  • www.example.com/2012/page/2
  • www.example.com/2012/feed/rss

The add_permastruct Function

The add_permastruct function accepts four arguments:

  • name – A unique slug to identify your permastructure
  • struct – The permastructure itself e.g. '%year%/%category%/%author%'
  • with_front – true or false. This is the same argument as when registering the post type
  • ep_mask – See EP Mask section below

A couple of warnings on using add_permastruct need to be made here. First: you'll want to make sure that a custom permastructure doesn't clash with WordPress' rewrite rules for posts and pages. This can be done by prepending your custom permastructure with something hard-coded. For example:

'something-hard-coded/%year%/%monthnum%/%day%'

Secondly – the rules are added in that order – so if your tags are 'too generic', the latter rules may never be applied. For example, the above structure (which you can try on your Settings -> Permalinks page), generally works well, except that:

www.example.com/2012/page/2

Is interpreted as the page of posts by author '2', in category 'page' in 2012. If you want to use add_permastruct and have your pagination and feeds rules cascade nicely then you'll need to use tags which are not 'generic' (by which I mean that the regex expressions are not generic). %author% and %category% are good examples of a generic tag as these will typically match any character.

Custom Permastructure Example: Post Type Date Archives

The year, month, and day tags on the other hand are very specific – matching only positive integers of lengths four and two, so we can use add_permastruct for our post type's date archive. Because of the specific nature of the date tags, we need these rules to be added before the post type permalink rule – so add the following immediately before registering your post type:

// Please note that this will only work on WordPress 3.4+ http://core.trac.wordpress.org/ticket/19871
add_rewrite_tag('%book_cpt%','(book)s','post_type=');
add_permastruct('book_archive', '%book_cpt%/%year%/%monthnum%/%day%');

In the above, the custom tag %book_cpt% acts as a hard-coded slug to differentiate this permastructure from other rules (as per the first warning). The generated rules will only apply if %book_cpt% matches 'books' and, in which case the 'book' part is captured and interpreted as the value for post_type. Please note that add_rewrite_tag only accepts the third argument since WordPress 3.4. However, you could use the following work-around:

global $wp_rewrite;
$wp_rewrite->add_rewrite_tag('%book_cpt%','(book)s','post_type=');

Having set up the book archives, you might also expect that

www.example.com/books?year=2012

Would take us to the 2012 book archive as well. Testing it, however, reveals that instead it takes us to the post year archive page:

www.example.com/2012/

WordPress has redirected us to a different page due to something known as canonicalization.


Canonical Redirect

Typically there are many URLs that could point to the same content on your website. For example:

www.example.com/year/2012

www.example.com/year/2012/page/1

www.example.com/2012/////////page/1

www.example.com/index.php/2012/

www.example.com/index.php////2012///page/1

Will all take you to the first page of your 2012 archive. From a SEO perspective this isn't great – we can't assume that search engines will recognise these URLs as the same resource, and these URLs may end up competing against each other. Google may also actively penalise you for duplicate content, and while it is good at determining when this duplication isn't 'malicious', it still recommends to redirect these superfluous URLs to one preferred 'canonical' (or standard) URL. This is called canonicalization.

Doing so not only helps consolidate ratings such as link popularity, but also helps your users. If they use an ugly or 'incorrect' URL – they are taken to the 'correct' URL – and what's in their address bar, is what they're more likely to return to.

Since 2.1.0 WordPress has handled canonical redirection, even taking an educated guess at the sought after content if the original query returned a 404. Unfortunately, in this instance, WordPress is redirecting to the wrong URL. This is because the URL we actually want is not natively understood by WordPress, and it has ignored the 'post type' part of the URL. Fortunately, however we can use the redirect_canonical filter to fix this.

add_filter('redirect_canonical', 'wptuts_redirect_canonical', 10, 2);
function wptuts_redirect_canonical($redirect_url, $requested_url) {

	global $wp_rewrite;

	// Abort if not using pretty permalinks, is a feed, or not an archive for the post type 'book'
	if( ! $wp_rewrite->using_permalinks() || is_feed() || ! is_post_type_archive('book') )
		return $redirect_url;

	// Get the original query parts
	$redirect = @parse_url($requested_url);
	$original = $redirect_url;
	if( !isset($redirect['query'] ) )
		$redirect['query'] ='';

	// If is year/month/day - append year
	if ( is_year() || is_month() || is_day() ) {
		$year = get_query_var('year');
		$redirect['query'] = remove_query_arg( 'year', $redirect['query'] );
		$redirect_url = user_trailingslashit(get_post_type_archive_link('book')).$year;
	}

	// If is month/day - append month
	if ( is_month() || is_day() ) {
		$month = zeroise( intval(get_query_var('monthnum')), 2 );
		$redirect['query'] = remove_query_arg( 'monthnum', $redirect['query'] );
		$redirect_url .= '/'.$month;
	}

	// If is day - append day
	if ( is_day() ) {
		$day = zeroise( intval(get_query_var('day')), 2 );
		$redirect['query'] = remove_query_arg( 'day', $redirect['query'] );
		$redirect_url .= '/'.$day;
	}

	// If paged, apppend pagination
	if ( get_query_var('paged') > 0 ) {
		$paged = (int) get_query_var('paged');
		$redirect['query'] = remove_query_arg( 'paged', $redirect['query'] );

		if ( $paged > 1 )
			$redirect_url .= user_trailingslashit("/page/$paged", 'paged');
	}

	if( $redirect_url == $original )
		return $original;

	// tack on any additional query vars
	$redirect['query'] = preg_replace( '#^\??&*?#', '', $redirect['query'] );
	if ( $redirect_url && !empty($redirect['query']) ) {
		parse_str( $redirect['query'], $_parsed_query );
		$_parsed_query = array_map( 'rawurlencode', $_parsed_query );
		$redirect_url = add_query_arg( $_parsed_query, $redirect_url );
	}

	return $redirect_url;
}

The above function is lengthy, but not very complicated. It could be improved upon, and is only intended as an example of what you can do with the redirect_canonical filter. It first checks that pretty permalinks are turned on, that we are after our 'book' archive and it's not a feed. It then checks in turn:

  1. Is it a year, month or day archive? If so, remove the 'year' variable from the query string and set the redirect URL to www.example.com/books/[year]
  2. Is it a month or day archive? If so, remove the 'monthnum' variable from the query string and append the value to the redirect URL: www.example.com/books/[year]/[monthnum]
  3. Is it a day archive? If so, remove the 'day' variable from the query string and append the value to the redirect URL: www.example.com/books/[year]/[monthnum]/[day]
  4. Finally, if there is a paged variable, append this to the redirect URL

Tags in the Post Type Permalinks

Another feature that isn't supported for post types or taxonomies 'out of the box' is to use tags in the permalink structure. While tags used in the 'slug' of a post type's (or taxonomy's) rewrite array are correctly interpreted, WordPress doesn't replace these tags with their appropriate values when generating the permalinks – we need to replace it ourselves. However, using tags like this also breaks the post type's archive page – so we'll use a different method. As an example, let's suppose that we want our custom post type 'book' to have the structure:

www.example.com/books/[some-genre]/[a-book]

I'm using the example of a custom taxonomy, but the same methods can be used for any permastructure (for instance including the date, author, or even a custom field). First of all, we add the rewrite rule:

function wptuts_custom_tags() {
	add_rewrite_rule("^books/([^/]+)/([^/]+)/?",'index.php?post_type=book&genre=$matches[1]&book=$matches[2]','top');
}
add_action('init','wptuts_custom_tags');

Now, www.example.com/book/fiction/the-wizard-of-oz, for instance, points to the book 'the-wizard-of-oz'. However the permalink generated by WordPress still produces www.example.com/book/the-wizard-of-oz. The next step is to alter the produced permalink.

We did something similar in part one when we wanted to use a custom tag in the post permalink structure. Then we used the post_link filter; this time we use the equivalent for custom post types, the post_type_link filter. Using this hook we can inject our structure into the books' permalinks.

function wptuts_book_link( $post_link, $id = 0 ) {

	$post = get_post($id);

	if ( is_wp_error($post) || 'book' != $post->post_type || empty($post->post_name) )
		return $post_link;

	// Get the genre:
	$terms = get_the_terms($post->ID, 'genre');

	if( is_wp_error($terms) || !$terms ) {
		$genre = 'uncategorised';
	}
	else {
		$genre_obj = array_pop($terms);
		$genre = $genre_obj->slug;
	}

	return home_url(user_trailingslashit( "books/$genre/$post->post_name" ));
}
add_filter( 'post_type_link', 'wptuts_book_link' , 10, 2 );

Manipulating WordPress Rewrites

Let's extend the above permalink structure to achieve the following:

  • A specific book: www.example.com/books/[some-genre]/[a-book]
  • All books in a genre: www.example.com/books/[some-genre]
  • All books: www.example.com/books/

Recall that the order in which rewrite rules are added matter. Specifically, rules added first take priority.

So, first we register our custom taxonomy 'genre' with:

$args = array(
	...
	'rewrite' => array(
		'slug'=>'books'
	),
	...
)
register_taxonomy('genre',$args);

This adds the following permastructure:

  • Books in a genre: www.example.com/books/[some-genre]

After registering the taxonomy, we then register our custom post type as follows:

$args = array(
	...
	'rewrite' => array(
		'slug'=>'books'
	),
	...
)
register_post_type('book',$args);

This would register the following rules:

  • All books: www.example.com/books/ (which we want)
  • A specific book: www.example.com/books/[a-book] (which we don't)

However the second conflicts with (and is 'beaten' by) the competing rule added when we registered our taxonomy. The resulting structure is:

  • Book called 'slug' : www.example.com/books/fiction/slug
  • Books in genre 'slug': www.example.com/books/slug
  • All Books: www.example.com/books/

EP_Masks

Earlier when we looked at registering post types, taxonomies (or otherwise, permstructures), WordPress let us specify our own 'ep_mask'. So what are they?

In part one we looked at how we can add endpoints with add_rewrite_endpoint. The second argument in that function is a constant (or combination of constants using bitwise operators), which determine where the endpoint is added. For instance:

add_rewrite_endpoint( 'form', EP_PAGES );

Adds the rewrite form(/(.*))?/?$ to every page permalink and:

add_rewrite_endpoint( 'json', EP_PAGES | EP_PERMALINKS);

Adds the rewrite json(/(.*))?/?$ to every post and page permalink. So these constants specify a 'location' (i.e. 'at the end of a post permalink') and they are called endpoint masks (or ep masks).

When you register a post type, WordPress registers a permastructure – and associated with it an endpoint mask. Then when the rewrite rules are generated, it also adds any endpoint rewrite rules that have been added to that endpoint mask.

For instance, when WordPress registers the default 'Page' post type – it associates the endpoint mask EP_PAGES with the page permastructure. Then, any endpoint rewrite rules added to the EP_PAGES are actually added to that page permastructure. When you register a post type, you can specify your own endpoint mask, or use an existing one. By default it is EP_PERMALINKS – which means any endpoint rewrite rules that are added to EP_PERMALINKS are added to your custom post type's rewrite rules.

Of course you may not want endpoint rules added for your post type (in which case you can use the endpoint mask EP_NONE), or you may wish to add some endpoint rewrite rules only to your custom post type. To do this, you first need to create an endpoint mask, which is nothing more than a constant which satisfies:

  1. The value of the constant is a positive number and a power of 2: 2x (e.g. 2097152 = 221)
  2. This value is unique

The power of 2 requirement is necessary because WordPress uses binary logic to determine where endpoint rules should be added. Unfortunately, this is nearly impossible to check   so the best advice is to add endpoint masks only when necessary and give it a very high value (e.g. 221). At the time of writing 20 up to 213 are used by Core.

Define your endpoint mask just before registering your post type or taxonomy:

	define('EP_BOOK', 8388608); // 8388608 = 2^23

	$args = array(
		'labels' => $labels,
		'has_archive'=>true,
		'rewrite' => array(
			'slug'=>'books'
			'with_front'=> false
			'feed'=> true
			'pages'=> true
			'ep_mask'=> EP_BOOK
		)
	);

	register_post_type('book',$args);

	// Then you can endpoint rewrite rules to this endpoint mask
	add_rewrite_endpoint('loan', EP_BOOK);

(Note: The above uses WordPress 3.4 arguments. If you are using an older version of WordPress, you will have to use the now deprecated permalink_epmask.). As of WordPress 3.4 you can specify an endpoint mask when registering a taxonomy too.


Summary

In this tutorial I've covered the basics of the rewrite API for post types and taxonomies, but also some more advanced topics. WordPress' handling of rewrites is (necessarily) complex and the best way to understand it is to delve into the source code and test it out using what've you learnt and a rewrite analyzer plug-in.

There are a couple of tickets working their way through the WordPress development Trac at the moment, relating to the Rewrite API. In the future we see a much easier and conflict-free way of handing endpoint masks.

Advertisement