Custom Post Types and Better Permalinks

By John P. Bloch

I just found a really nifty new trick for using more flexible permalinks for custom post types in WordPress (v3.0 and later). A bit of a back story here:

At a recent DC PHP beverage subgroup I mentioned to Andrew Nacin (@nacin) that I didn’t know anything about WP_Rewrite (the WordPress API that handles permalinks); he said ‘Good luck with that!’ and I felt obligated to learn it. Later, Aaron Jorbin (@aaronjorbin) made the same challenge.

So I opened my phpxref of WordPress and just sat down with the code, and knew it very well ten hours later. I even understand what the EP Masks do. It’s pretty cool stuff, but not for the faint of heart. (see more beneath the fold)

So recently, I made a “News Releases” custom post type on a site. The default permalink for custom post types is http://www.example.com/post_type/post_name/. For a news feed, I wanted to make the permalinks look a little bit more like blog posts, but with ‘newsroom’ before (i.e.: http://www.example.com/newsroom/year/month/post_name/). So here’s how I did it:

First, in the custom post type registration function, turn rewrite OFF:

register_post_type( 'news', array(
  'rewrite' => false,
  'public' => true
) );

Next, add a rewrite tag, permastruct, and rewrite rule to handle all the rewriting we could need:

add_rewrite_tag( '%news%', '([^/]+)' );
add_permastruct( 'news', '/newsroom/%year%/%monthnum%/%news%/', true, 1 );
add_rewrite_rule( 'newsroom/?$', 'index.php?post_type=news', 'top' );

This is the meat of the rewriting. I won’t go into the details of how or why this works, since it’s very complex. But it does. I may go into how rewrite works at a later date, but right now I want to share just the snippet.

This is almost the end. All of the previous code gets hooked into init. The next part completes the permalinking:

function jpb_cp_news_permalink( $link, $post, $leavename, $sample ){
  if( 'news' != $post->post_type )
    return $link;
  $rewritecode = array(
    '%year%',
    '%monthnum%',
    '%day%',
    '%hour%',
    '%minute%',
    '%second%',
    $leavename? '' : '%postname%',
    '%post_id%',
    $leavename? '' : '%pagename%',
    $leavename? '' : '%news%',
  );
  $unixtime = strtotime($post->post_date);
  $date = explode(' ', date('Y m d H i s', $unixtime));
  $replace_array = array(
    $date[0],
    $date[1],
    $date[2],
    $date[3],
    $date[4],
    $date[5],
    $post->post_name,
    $post->ID,
    $post->post_name,
    $post->post_name,
  );
  $path = str_replace($rewritecode, $replace_array, $link);
  return $path;
}

This function runs any time you pull a ‘news’ post type permalink and adds the correct information. This will even get you the correct permalink with editable slug on the post edit page. This function hooks into post_type_link. So all together now:

function jpb_cp_custom_permalinks(){
  register_post_type( 'news', array(
    'rewrite' => false,
    'public' => true
  ) );

  add_rewrite_tag( '%news%', '([^/]+)' );
  add_permastruct( 'news', '/newsroom/%year%/%monthnum%/%news%/', true, 1 );
  add_rewrite_rule( 'newsroom/?$', 'index.php?post_type=news', 'top' );
}

add_action( 'init', 'jpb_cp_custom_permalinks' );

function jpb_cp_news_permalink( $link, $post, $leavename, $sample ){
  if( 'news' != $post->post_type )
  return $link;
  $rewritecode = array(
    '%year%',
    '%monthnum%',
    '%day%',
    '%hour%',
    '%minute%',
    '%second%',
    $leavename? '' : '%postname%',
    '%post_id%',
    $leavename? '' : '%pagename%',
    $leavename? '' : '%news%',
  );
  $unixtime = strtotime($post->post_date);
  $date = explode(' ', date('Y m d H i s', $unixtime));
  $replace_array = array(
    $date[0],
    $date[1],
    $date[2],
    $date[3],
    $date[4],
    $date[5],
    $post->post_name,
    $post->ID,
    $post->post_name,
    $post->post_name,
  );
  $path = str_replace($rewritecode, $replace_array, $link);
  return $path;
}

add_action( 'post_type_link', 'jpb_cp_news_permalink', 10, 4 );

And now you’ve got a custom post type that looks and acts exactly like a blog post!

One final note: after implementing this code, you will have to refresh your permalink settings. To do so, go to Settings -> Permalinks and hit save (you don’t even need to change anything).

EDIT
————————-
So I found a couple of areas where the code I posted could be improved. Namely, the fact that the permalink didn’t create archives of content. Here’s the modified code:

function jpb_cp_custom_permalinks(){
  register_post_type( 'news', array(
    'rewrite' => array('slug'=>'newsroom'),
    'public' => true
  ) );
  add_rewrite_tag( '%news%', '([^/]+)' );
  $extra_post_types = get_post_types( array( '_builtin' => false, 'publicly_queryable' => true ) );
  if( empty( $extra_post_types ) )
    return;
  add_rewrite_tag( '%post_type%', '('.implode('|',$extra_post_types).')' );
  add_permastruct( 'news', '/%post_type%/%year%/%monthnum%/%news%/', true, 1 );
}

add_action( 'init', 'jpb_cp_custom_permalinks' );

function jpb_cp_news_permalink( $link, $post, $leavename, $sample ){
  if( 'news' != $post->post_type )
    return $link;
  $rewritecode = array(
    '%year%',
    '%monthnum%',
    '%day%',
    '%hour%',
    '%minute%',
    '%second%',
    $leavename? '' : '%postname%',
    '%post_id%',
    '%post_type%',
    $leavename? '' : '%pagename%',
    $leavename? '' : '%news%',
  );
  $unixtime = strtotime($post->post_date);
  $date = explode(' ', date('Y m d H i s', $unixtime));
  $replace_array = array(
    $date[0],
    $date[1],
    $date[2],
    $date[3],
    $date[4],
    $date[5],
    $post->post_name,
    $post->ID,
    $post->post_type,
    $post->post_name,
    $post->post_name,
  );
  $path = str_replace($rewritecode, $replace_array, $link);
  return $path;
}

add_action( 'post_type_link', 'jpb_cp_news_permalink', 10, 4 );

Note that I am using the rewrite attribute of the post type now. Also, the call to add_rewrite_rule has been removed now that we have a rewrite tag to replace it (%post_type%).

7 Comments Comments are closed

  1. To do so, go to Settings -> Permalinks and hit save (you don’t even need to change anything).

    You don’t need to even hit save, actually. Just visit the page.

    Nice job working through all of this. A lot of people don’t understand the rewriting engine, or think it is too complex, or alternatively not powerful enough… But when you really dig into it, it’s got quite a kick in terms of both power and spice.

    • A lot of people don’t understand the rewriting engine, or think it is too complex

      Oh, trust me, it’s very complex and hard to understand for a while. But I’m glad I did it. There’s something very rewarding about working through something like that and coming out on top. I may have to turn this into a plugin that adds extra permalinks options on the permalinks page….

  2. Huh. Interesting bit of knowledge to add: this doesn’t actually set the permalinks exactly like blog posts. For example, /newsroom/2010/08 will give you the August 2010 archives from blog posts. I already have an idea of how to fix this, but will have to post the edit later.

  3. Sorry, but where I put all this function? I’m newbie on WordPress world, the truth is that I’m a designer that like to try coding sometimes LOL…
    I’m using Custom Type that I created using a plugin, so I didn’t need code for custom type, but to create better permalink I’ll need.

    Where I put these php functions?
    Thanks!

  4. Hi LeoStorch,
    Thanks for stopping by. This code was a first pass at something I’ve made into an actual plugin called Custom Post Permalinks. I strongly suggest that you use that plugin instead of this code. It’ll simplify the process for you.

  5. This almost solves my problem, but I must be going wrong somewhere.

    I have a custom post type called “bp_news”, which I used to have slugged as “news”. The problem being, I also have a page called “news” that displays a paginated list of all “bp_news” posts. The pagination did not work, because WordPress thought that news/page/2 was a reference to a news post title. I think your solution here will work.

    I have the following code:


    /* News posts */
    add_action( 'init', 'bp_news_register' );
    function bp_news_register() {
    register_post_type( 'bp_news',
    array(
    'labels' => array(
    'name' => __( 'News' ),
    'singular_name' => __( 'News Item' ),
    'add_new' => __('Add News Item'),
    'add_new_item' => __('Add News Item'),
    'edit' => __('Edit News Item'),
    'edit_item' => __('Edit News Item'),
    'new_item' => __('New News Item'),
    'view' => __('View News Item'),
    'view_item' => __('View News Item'),
    'search_items' => __('Search News Items'),
    'not_found' => __('No news items found'),
    'not_found_in_trash' => __('No news items found in Trash')
    ),
    'public' => true,
    'show_ui' => true,
    'menu_position' => 5,
    '_builtin' => false,
    'capability_type' => 'post',
    'hierarchical' => false,
    //'rewrite' => array( 'slug' => 'news-item', 'with_front' => false),
    'rewrite' => false,
    'supports' => array ('title', 'editor', 'comments', 'trackbacks', 'revisions', 'author', 'excerpt', 'thumbnail'))
    );
    add_rewrite_tag( '%bp_news%', '([^/]+)' );
    add_permastruct( 'bp_news', '/news/%year%/%monthnum%/%news%/', true, 1 );
    add_rewrite_rule( 'news/?$', 'index.php?post_type=bp_news', 'top' );
    }

    add_action( 'post_type_link', 'jpb_cp_news_permalink', 10, 4 );

    function jpb_cp_news_permalink( $link, $post, $leavename, $sample )
    {
    if( 'bp_news' != $post->post_type )
    return $link;
    $rewritecode = array(
    '%year%',
    '%monthnum%',
    '%day%',
    '%hour%',
    '%minute%',
    '%second%',
    $leavename? '' : '%postname%',
    '%post_id%',
    $leavename? '' : '%pagename%',
    $leavename? '' : '%news%',
    );
    $unixtime = strtotime($post->post_date);
    $date = explode(' ', date('Y m d H i s', $unixtime));
    $replace_array = array(
    $date[0],
    $date[1],
    $date[2],
    $date[3],
    $date[4],
    $date[5],
    $post->post_name,
    $post->ID,
    $post->post_name,
    $post->post_name,
    );
    $path = str_replace($rewritecode, $replace_array, $link); return $path;
    }

    Now, the permalinks generated by the loop on the “news” page seems to look okay, but WordPress cannot find a post to match it. The permalink displayed on the admin edit page for my custom “bp_news” type looks like: Permalink: http://www.bassettprovidentia.com/news/2011/01/%news%/

    Where do you think I am going wrong?

  6. I have finally nailed the permalink issues thanks to your post. Thank you.