Removing the Slug from Custom Post Types

I came across an interesting question on WordPress Answers today and it tickled my brain.

In WordPress, when you register a custom post type, having a slug is mandatory out-of-the-box. There are a few of reasons for this, but the most important one is performance. If you have conflicting rewrite rules, you can make them all work by hooking into the process and running some queries to see which rule is the intended target of the current path. These queries aren’t free, and in the world of performance, every bit counts. With that in mind, removing slugs is ill-advised. Sometimes there are restrictions beyond your control, and you have to go against your better judgement to do so anyway, so for those cases, here’s how you can remove the slug from custom post type permalinks.

Step 1: Remove the default rewrite rules

When you register a post type, if the ‘rewrite’ argument is not set to false, WordPress will add rewrite rules for you. Normally this is a good thing, but in this case, it’s not going to do so according to our master plan, so we need to disable this. When registering your post type, set the ‘rewrite’ argument to false.

Step 2: Manually add the rewrite rules

When register_post_type is called, WordPress does two main things to add rewrite rules for that post type: it adds a rewrite tag, and adds a permastruct. Since we’re asking WordPress not to do this, we’ll have to do so manually. The rewrite tag will depend on if your post type is hierarchical or not, so your code will look something like this (this should happen on init):

if ( $hierarchical )
	add_rewrite_tag( "%{$post_type}%", '(.+?)', "{$query_var}=" );
else
	add_rewrite_tag( "%{$post_type}%", '([^/]+)', "{$query_var}=" );
add_permastruct( $post_type, "%{$post_type}%", array( 'ep_mask' => EP_PERMALINK ) );

Step 3: Rearrange the generated rewrite rules

Now your rewrite rules are added, but they’re added relatively high in the rewrite array. This is going to cause unnecessary conflicts with other rewrite rules, so we need to move them down the chain a bit. Unfortunately, WordPress doesn’t provide a clean way to do this, so we have to do something a little “hacky” to get the rules in the order we want. Specifically, we need to pull our rules out and inject them into the rules array at a later point in the chain. Since this is an associative array in a specific order, this is easier said than done. We’ll first hook into the {$post_type}_rewrite_rules filter and pull out the rules for this post type, storing them for later use:

global $my_post_type_rules;
$my_post_type_rules = $rules;
# We no longer need the attachment rules, so strip them out
foreach ( $rules as $regex => $value ) {
	if ( strpos( $regex, 'attachment' ) )
	unset( $my_post_type_rules[ $regex ] );
}
return array();

Next, we’ll inject them using the rewrite_rules_array filter:

# This is the first 'page' rule
$offset = array_search( '(.?.+?)/trackback/?$', array_keys( $rules ) );
$page_rules = array_slice( $rules, $offset, null, true );
$other_rules = array_slice( $rules, 0, $offset, true );
return array_merge( $other_rules, $GLOBALS['my_post_type_rules'], $page_rules );

Step 4: Resolve conflicting rewrite rules on-the-fly

Currently, our rewrite rules are working, but pages are not. That’s because WordPress thinks that every page is an entry in our custom post type due to the conflicting rules. To resolve this, we need to hook into the request action and potentially manipulate the generated query vars.

if ( isset( $qv[ $post_type_query_var ] ) ) {
	if ( get_page_by_path( $qv[ $post_type_query_var ] ) ) {
		$qv = array( 'pagename' => $qv[ $post_type_query_var ] );
	}
}
return $qv;

Final notes

That’s the gist of the work we needed to do. It doesn’t cover everything, but should illustrate the important points of what needs to be done. Below I’ve included a class that combines all this and also resolves a few edge cases not addressed or discussed above.

The only other point worth noting is that you can’t do this to more than one post type, at least not without some significant hacking. You see, WordPress stores rewrite rules as an associative array of regex => redirect. Because the regular expression is the array key, you cannot have two identical expressions (having two would be pointless anyway). Are you out of luck? No, but I’ll save that for a future post (dun dun dun… cliffhanger!).

Combined class

<?php
/**
 * Strip the slug out of a custom post type
 */
if ( !class_exists( 'Slugless_Rewrites' ) ) :

class Slugless_Rewrites {

	public $rules;

	public $post_type;

	public $query_var;

	public $hierarchical;

	public $has_archive;

	public $archive_feeds;

	public function __construct( $post_type, $args = array() ) {
		$args = wp_parse_args( $args, array(
			'hierarchical'  => false,
			'query_var'     => false,
			'has_archive'   => false,
			'archive_feeds' => true
		) );

		$this->post_type = $post_type;

		$this->hierarchical = $args['hierarchical'];

		if ( $args['query_var'] )
			$this->query_var = $args['query_var'];
		else
			$this->query_var = $this->post_type;

		$this->has_archive = $args['has_archive'];

		$this->archive_feeds = $args['archive_feeds'];

		add_action( 'init',                             array( $this, 'add_rewrites' ),            20 );
		add_filter( 'request',                          array( $this, 'check_rewrite_conflicts' )     );
		add_filter( "{$this->post_type}_rewrite_rules", array( $this, 'strip_rules' )                 );
		add_filter( 'rewrite_rules_array',              array( $this, 'inject_rules' )                );
	}

	public function add_rewrites() {
		if ( $this->hierarchical )
			add_rewrite_tag( "%{$this->post_type}%", '(.+?)', "{$this->query_var}=" );
		else
			add_rewrite_tag( "%{$this->post_type}%", '([^/]+)', "{$this->query_var}=" );

		if ( ! empty( $this->has_archive ) )
			$this->add_archive_rules();

		add_permastruct( $this->post_type, "%{$this->post_type}%", array( 'ep_mask' => EP_PERMALINK ) );
	}

	public function add_archive_rules() {
		global $wp_rewrite;

		$archive_slug = $this->has_archive === true ? $this->post_type : $this->has_archive;

		add_rewrite_rule( "{$archive_slug}/?$", "index.php?post_type={$this->post_type}", 'top' );
		if ( $this->archive_feeds && $wp_rewrite->feeds ) {
			$feeds = '(' . trim( implode( '|', $wp_rewrite->feeds ) ) . ')';
			add_rewrite_rule( "{$archive_slug}/feed/$feeds/?$", "index.php?post_type={$this->post_type}" . '&feed=$matches[1]', 'top' );
			add_rewrite_rule( "{$archive_slug}/$feeds/?$", "index.php?post_type={$this->post_type}" . '&feed=$matches[1]', 'top' );
		}
		add_rewrite_rule( "{$archive_slug}/{$wp_rewrite->pagination_base}/([0-9]{1,})/?$", "index.php?post_type={$this->post_type}" . '&paged=$matches[1]', 'top' );
	}

	public function check_rewrite_conflicts( $qv ) {
		if ( isset( $qv[ $this->query_var ] ) ) {
			if ( get_page_by_path( $qv[ $this->query_var ] ) ) {
				$qv['pagename'] = $qv[ $this->query_var ];
				unset( $qv[ $this->query_var ], $qv['post_type'], $qv['name'] );
			}
		}
		return $qv;
	}

	public function strip_rules( $rules ) {
		$this->rules = $rules;
		# We no longer need the attachment rules, so strip them out
		foreach ( $this->rules as $regex => $value ) {
			if ( strpos( $value, 'attachment' ) )
				unset( $this->rules[ $regex ] );
		}
		return array();
	}

	public function inject_rules( $rules ) {
		# This is the first 'page' rule
		$offset = array_search( '(.?.+?)/trackback/?$', array_keys( $rules ) );
		$page_rules = array_slice( $rules, $offset, null, true );
		$other_rules = array_slice( $rules, 0, $offset, true );
		return array_merge( $other_rules, $this->rules, $page_rules );
	}
}

endif;

12 thoughts on “Removing the Slug from Custom Post Types

  1. Does this still work? I’m using a genesis child theme with the following code:

    `
    add_action(‘init’, ‘lander_landing_page’);
    function lander_landing_page() {

    $labels = array(
    ‘name’ => ‘Landing Pages’,
    ‘singular_name’ => ‘Landing Page’,
    ‘add_new’ => ‘Add New’,
    ‘add_new_item’ => ‘Add New Landing Page’,
    ‘edit_item’ => ‘Edit Landing Page’,
    ‘new_item’ => ‘New Landing Page’,
    ‘all_items’ => ‘All Landing Pages’,
    ‘view_item’ => ‘View Landing Page’,
    ‘search_items’ => ‘Search Landing Pages’,
    ‘not_found’ => ‘No Landing Pages found’,
    ‘not_found_in_trash’ => ‘No Landing Pages found in Trash’,
    ‘parent_item_colon’ => ”,
    ‘menu_name’ => ‘Landing Pages’
    );
    $args = array(
    ‘labels’ => $labels,
    ‘public’ => true,
    ‘publicly_queryable’ => true,
    ‘exclude_from_search’ => false,
    ‘show_in_nav_menus’ => true,
    ‘show_ui’ => true,
    ‘show_in_menu’ => true,
    ‘show_in_admin_bar’ => true,
    ‘can_export’ => true,
    ‘query_var’ => true,
    ‘capability_type’ => ‘page’,
    ‘hierarchical’ => false,
    ‘menu_position’ => 20,
    ‘rewrite’ => false,
    ‘supports’ => array(
    ‘title’,
    ‘genesis-seo’,
    ‘genesis-scripts’
    )

    );
    register_post_type(‘landingpage’, $args);
    flush_rewrite_rules();

    }
    `

    and then I do

    `
    $test = $l = new Slugless_Rewrites;
    `

    I’m expecting mydomain/?landingpage=some-slug to automatically be converted to mydomain/some-slug.

  2. A really interesting solution Matt, and I’d love to get it working.

    The trouble that I have with the code you posted on StackExchange is that it breaks normal posts when the permalink is ‘/%postname%/’. I suspect this is due to rule prescendence, but I’ve not been able to nail why.

    Experimented with adding the rules at the end of the rule array, no luck. It breaks my CPT. I wanted to add these rules after the post rules to see if that solved the issue.

    Of course, if the permalink rule is anything else, it works fine :)

    Any ideas?

    • Hey Dan, sorry for the delay, I missed your post. By now, you’ve probably either figured this out or moved on, but just in case (or for anyone else who comes along), here’s what you have to do:

      In `check_rewrite_conflicts()`, you need to add another conditional after the one on line 76. Your conditional would check to see if the post exists. You can use the same function get_page_by_path (https://codex.wordpress.org/Function_Reference/get_page_by_path) and pass the post type of ‘post’. At this point, you’re running two relatively inefficient queries to get to your custom post type, so that’s something to keep in mind if you have a very large site.

      Another way to go about this, as pointed out by @Sepp above, is written up by WordPress.com VIP here: http://vip.wordpress.com/documentation/remove-the-slug-from-your-custom-post-type-permalinks/. This is a much better way to go to solve this problem. The approach I detailed was really a demo to solve for a larger problem of conflicting permalink structures (which may be post types, taxonomies, or custom paths).

      • Thanks Matt

        I was actually waiting to hear from you, I just assumed you were busy!

        I’d tried another variation of what @Sepp mentions above, but it was causing issues. Having compared it to what I have, I can see why, and I now have what I need. The code from VIP.WordPress.com is the right code to use for a fast query!

        Thank you!
        Dan

        • Hey guys,

          too bad, the VIP code doesn’t work for me. I still get a 404 error. Any idea what could be wrong?

          Thanks,
          Regina

        • Btw… it works when permalinks are set to /%postname%/, but my permastructure is /blog/%category%/%postname%/.

          The CPT is hierarchical and with_front is false.

          Maybe I need a new rewrite rule??

          • With the VIP code, I found that it worked for %/permalinks%/ as you’ve discovered.

            However, for year/month/date/postname, etc, I had to add a special switch statement to modify the query to check the right parameter for the slug.

            There’s more to it than this, but it’s a bit like this:


            // What permalink structure do we have?
            $permalinkStructure = trim(get_option('permalink_structure'));
            $normalPermalink = false;

            //error_log(print_r($query, true));

            switch($permalinkStructure)
            {
            // Got category in the URL for permalinks, which means WordPress
            // mistakes the post for a category.
            case '/%category%/%postname%/':
            $post_name = $query->get('category_name');
            break;

            // Normal
            case '/%postname%/':
            $normalPermalink = true;
            $post_name = $query->get('name');
            break;

            // Year/Month/Day/Post
            // Year/Month/Post
            default:
            $normalPermalink = true;
            $post_name = $query->get('pagename');
            break;
            }

          • Thanks for your answer, Dan. I’m sorry I’m an absolute beginner and I just don’t get it to work. How should your code be integrated in the VIP code?

          • Hi Regina – that code is really not for beginners. There’s a lot more to that code than meets the eye.

            Have you tried any plugins?

          • Yes I think you’re right, I think I have to live with the slug. Everything I tried to get the permalink structure I want did not work out and I’m just not able to get the rewrite rules working. Sad that wordpress doesn’t offer better control for URLs itself.

            This is the only plugin I found, but it only works for /%postname%/ structure:
            http://wordpress.org/plugins/remove-slug-from-custom-post-type/

Leave a Reply