Since the addition of custom post types to WordPress in version 2.9, one of the questions I’ve seen most on message boards and forums has been from users trying to figure out how to have their custom post type have loop pages like the built in posts post type does. Initially, when the permalink handling for custom post types was added in version 3.0, it only handled the endpoint for the single post. It is now looking like WordPress 3.1 will have support for a root archive for custom permalinks, but still won’t support any of the permastructure tags. Because of these limitations, we have had to come up with ways to easily support a more robust rewrite handling for custom post types.

For this example, I’ll create a custom post type called ‘movie-review’ that will have a permalink structure of mysite.com/movie-reviews/%year%/%monthnum%/%day%/%review-title%/ with supporting rewrite rules at each of the directories within that structure, ie, mysite.com/movie-reviews/%year%/,mysite.com/movie-reviews/%year%/%monthnum%/, etc.

The code starts with the normal [cci lang=”php”]register_post_type()[/cci] call, however the ‘rewrite’ argument will be set to false, so the default handling doesn’t interfere with the custom rewrite rules being added.

[cc lang=”php”]

register_post_type(‘movie-review’, array(

‘labels’ => array(

‘name’ => _x(‘Movie Reviews’, ‘post type general name’),

‘singular_name’ => _x(‘Movie Review’, ‘post type singular name’),

‘add_new’ => _x(‘Add New’, ‘movie-review’),

‘add_new_item’ => __(‘Add New Movie Review’),

‘edit_item’ => __(‘Edit Movie Review’),

‘new_item’ => __(‘New Movie Review’),

‘view_item’ => __(‘View Movie Review’),

‘search_items’ => __(‘Search Movie Reviews’),

‘not_found’ => __(‘No Movie Reviews found’),

‘not_found_in_trash’ => __(‘No Movie Reviews found in Trash’),

‘parent_item_colon’ => ”

),

‘public’ => true,

‘publicly_queryable’ => true,

‘query_var’ => ‘movie-review’,

‘rewrite’ => false,

‘hierarchical’ => false,

‘supports’ => array(‘title’, ‘editor’, ‘excerpt’)

));

[/cc]

The next addition is a rewrite tag to use for the movie-review post type. This tag will work similar to the %postname% tag used when creating the posts permalink structure, except it will be used for the custom post type’s title.

[cc lang=”php”]

global $wp_rewrite;

$wp_rewrite->add_rewrite_tag(‘%movie-review%’, ‘([^/]+)’, ‘movie-review=’);

[/cc]

Now that the custom rewrite tag has been created, the code to generate the rewrite rules for each endpoint that the custom post type will need can be added. The [cci lang=”php”]WP_Rewrite::generate_rewrite_rules()[/cci] method that is used for normal post rewrite rule creation can be used to parse and create most of the rewrite rules the post type needs. They will just need some slight modifications. The permalink prefix, which is the base for the custom post type, and the permalink structure are defined separately to allow the prefix to be used to create the rewrite rules for the root of the post type.

[cc lang=”php”]

global $wp_rewrite;

//the root of the post type, ie mysite.com/movie-reviews/ will be the landing page for the post type

$permalink_prefix = ‘movie-reviews’;

//the permalink structure for the post type that will be appended to the prefix, mysite.com/movie-reviews/2010/11/25/test-movie-review/

$permalink_structure = ‘%year%/%monthnum%/%day%/%movie-review%/’;

//we use the WP_Rewrite class to generate all the endpoints WordPress can handle by default.

$rewrite_rules = $wp_rewrite->generate_rewrite_rules($permalink_prefix.’/’.$permalink_structure, EP_ALL, true, true, true, true, true);

//build a rewrite rule from just the prefix to be the base url for the post type

$rewrite_rules = array_merge($wp_rewrite->generate_rewrite_rules($permalink_prefix), $rewrite_rules);

$rewrite_rules[$permalink_prefix.’/?$’] = ‘index.php?paged=1’;

foreach($rewrite_rules as $regex => $redirect) {

if(strpos($redirect, ‘attachment=’) === false) {

//add the post_type to the rewrite rule

$redirect .= ‘&post_type=movie-review’;

}

//turn all of the $1, $2,… variables in the matching regex into $matches[] form

if(0 < preg_match_all('@$([0-9])@', $redirect, $matches)) {

for($i = 0; $i add_rule($regex, $redirect, ‘top’);

}

[/cc]

Once all of the code to generate the rewrite rules is in place, the rewrite rules need to be flushed. This can either be done through the admin by re-saving the permalinks, or through code if included in a plugin. The rewrite rules are now finished. The only thing left is to override the permalink format for the links created for the post type with the following:

[cc lang=”php”]

add_filter(‘post_type_link’, ‘filter_movie_review_link’, 10, 2);

function filter_movie_review_link($permalink, $post) {

if((‘movie-review’ == $post->post_type) && ” != $permalink && !in_array($post->post_status, array(‘draft’, ‘pending’, ‘auto-draft’)) ) {

$rewritecode = array(

‘%year%’,

‘%monthnum%’,

‘%day%’,

‘%movie-review%’

);

$unixtime = strtotime($post->post_date);

$date = explode(” “,date(‘Y m d H i s’, $unixtime));

$rewritereplace = array(

$date[0],

$date[1],

$date[2],

$post->post_name,

);

$permalink = str_replace($rewritecode, $rewritereplace, ‘/movie-reviews/%year%/%monthnum%/%day%/%movie-review%/’);

$permalink = user_trailingslashit(home_url($permalink));

}

return $permalink;

}

[/cc]

This should complete all the steps needed to add all of the rewrite rules for each of the endpoints within the permalink structure created for this post type. I went ahead and put this all together in a small plugin that wraps the [cci lang=”php”]register_post_type()[/cci] method with the above code to add the needed rewrite rules:

[cc lang=”php”]

class Custom_Post_Type_With_Rewrite_Rules {

private $post_type;

private $query_var;

private $permalink_prefix;

private $permalink_structure;

/**

* Constructor method

*

* $permalink_args options:

* -front: The front of the permalinks for this post type. All URLs for this post type will start with this

* -structure: The structure of the permalink. Accepts the following tags: %year%, %month%, %day% and %{query_var}%, the structure must contain the query var tag

*

* @param string $post_type

* @param array $post_type_args Arguments normally passed into register_post_type

* @param array $permalink_args Arguments controlling the permalink structure.

*/

public function __construct($post_type, $post_type_args = array(), $permalink_args = array()) {

//make sure the rewrite settings for the post type are set to false to prevent interference

$post_type_args[‘rewrite’] = false;

//register the post type and get the returned args

$post_type_args = register_post_type($post_type, $post_type_args);

if(” == get_option(‘permalink_structure’) || !$post_type_args->publicly_queryable) {

return; //only continue if using permalink structures and post type is publicly queryable

}

$this->post_type = $post_type_args->name;

$this->query_var = $post_type_args->query_var;

$default_permalink_args = array(

‘structure’ => ‘%year%/%monthnum%/%day%/%’.$this->query_var.’%/’,

‘front’ => $this->post_type

);

$permalink_args = wp_parse_args($permalink_args, $default_permalink_args);

$this->permalink_prefix = trim($permalink_args[‘front’], ‘/’);

$this->permalink_structure = trailingslashit(ltrim($permalink_args[‘structure’], ‘/’));

//register the add_rewrite_rules method to run only when rules are being flushed.

add_action(‘delete_option_rewrite_rules’, array($this, ‘add_rewrite_rules’));

//go ahead and add the rewrite rules if the option is currently empty

$current_rules = get_option(‘rewrite_rules’);

if(empty($current_rules)) {

$this->add_rewrite_rules();

}

//add a filter to fix the url for this post type

add_filter(‘post_type_link’, array($this, ‘filter_post_type_link’), 10, 4);

}

public function add_rewrite_rules() {

global $wp_rewrite;

//register the rewrite tag to use for the post type

$wp_rewrite->add_rewrite_tag(‘%’.$this->query_var.’%’, ‘([^/]+)’, $this->query_var . ‘=’);

//we use the WP_Rewrite class to generate all the endpoints WordPress can handle by default.

$rewrite_rules = $wp_rewrite->generate_rewrite_rules($this->permalink_prefix.’/’.$this->permalink_structure, EP_ALL, true, true, true, true, true);

//build a rewrite rule from just the prefix to be the base url for the post type

$rewrite_rules = array_merge($wp_rewrite->generate_rewrite_rules($this->permalink_prefix), $rewrite_rules);

$rewrite_rules[$this->permalink_prefix.’/?$’] = ‘index.php?paged=1’;

foreach($rewrite_rules as $regex => $redirect) {

if(strpos($redirect, ‘attachment=’) === false) {

//add the post_type to the rewrite rule

$redirect .= ‘&post_type=’ . $this->post_type;

}

//turn all of the $1, $2,… variables in the matching regex into $matches[] form

if(0 < preg_match_all('@$([0-9])@', $redirect, $matches)) {

for($i = 0; $i add_rule($regex, $redirect, ‘top’);

}

}

/**

* Filter to turn the links for this post type into ones that match our permalink structure

*

* @param string $permalink

* @param object $post

* @return string New permalink

*/

public function filter_post_type_link($permalink, $post) {

if(($this->post_type == $post->post_type) && ” != $permalink && !in_array($post->post_status, array(‘draft’, ‘pending’, ‘auto-draft’)) ) {

$rewritecode = array(

‘%year%’,

‘%monthnum%’,

‘%day%’,

‘%hour%’,

‘%minute%’,

‘%second%’,

‘%post_id%’,

‘%author%’,

‘%’.$this->query_var.’%’

);

$author = ”;

if ( strpos($this->permalink_structure, ‘%author%’) !== false ) {

$authordata = get_userdata($post->post_author);

$author = $authordata->user_nicename;

}

$unixtime = strtotime($post->post_date);

$date = explode(” “,date(‘Y m d H i s’, $unixtime));

$rewritereplace = array(

$date[0],

$date[1],

$date[2],

$date[3],

$date[4],

$date[5],

$post->ID,

$author,

$post->post_name,

);

$permalink = str_replace($rewritecode, $rewritereplace, ‘/’.$this->permalink_prefix.’/’.$this->permalink_structure);

$permalink = user_trailingslashit(home_url($permalink));

}

return $permalink;

}

}

/**

* Public registration method for custom post types with rewrite rules.

*

* $permalink_args options:

* -front: The front of the permalinks for this post type. All URLs for this post type will start with this

* -structure: The structure of the permalink. Accepts the following tags: %year%, %month%, %day% and %{query_var}%, the structure must contain the query var tag

*

* @param string $post_type

* @param array $post_type_args Arguments normally passed into register_post_type

* @param array $permalink_args Arguments controlling the permalink structure.

*/

function register_post_type_with_rewrite_rules($post_type, $post_type_args = array(), $permalink_args = array()) {

new Custom_Post_Type_With_Rewrite_Rules($post_type, $post_type_args, $permalink_args);

}

//test code for the above

function register_custom_post_types() {

register_post_type_with_rewrite_rules(‘movie-review’, array(

‘labels’ => array(

‘name’ => _x(‘Movie Reviews’, ‘post type general name’),

‘singular_name’ => _x(‘Movie Review’, ‘post type singular name’),

‘add_new’ => _x(‘Add New’, ‘movie-review’),

‘add_new_item’ => __(‘Add New Movie Review’),

‘edit_item’ => __(‘Edit Movie Review’),

‘new_item’ => __(‘New Movie Review’),

‘view_item’ => __(‘View Movie Review’),

‘search_items’ => __(‘Search Movie Reviews’),

‘not_found’ => __(‘No Movie Reviews found’),

‘not_found_in_trash’ => __(‘No Movie Reviews found in Trash’),

‘parent_item_colon’ => ”

),

‘public’ => true,

‘publicly_queryable’ => true,

‘query_var’ => ‘movie-review’,

‘rewrite’ => false,

‘capability_type’ => ‘movie-review’,

‘hierarchical’ => false,

‘supports’ => array(‘title’, ‘editor’, ‘excerpt’, ‘thumbnail’),

), array(‘front’=> ‘my-custom-prefix’, ‘structure’=>’%year%/%author%/%movie-review%’));

}

add_action(‘init’, ‘register_custom_post_types’);

[/cc]