I have a hierarchical Custom Post Type called project, registered as follows:

register_post_type( 'project',
        'public'            => true,
        'hierarchical'      => true,
        'rewrite' => array(
            'with_front' => false

The URLs currently look like this:

I would like the URLs to look like this:

How can I most efficiently accomplish this such that other URLs are not interfered with (e.g. pages, blog posts, other custom post types)?

All the solutions that are currently out there either cause 404s on other pages on the site or do not work correctly with nested URLs.


This will allow you to use the post name without the slug. Essentially anytime the link is requested it can be altered to exclude the base post type. And any time a query runs with just a name, the available post types used in the search are altered to include your post type.

function wpse_remove_cpt_slug( $post_link, $post, $leavename ) {

    // leave these CPT alone
    $whitelist = array ('project');

    if ( ! in_array( $post->post_type, $whitelist ) || 'publish' != $post->post_status )
        return $post_link;

    if( isset($GLOBALS['wp_post_types'][$post->post_type],
        $slug = $GLOBALS['wp_post_types'][$post->post_type]->rewrite['slug'];
    } else {
        $slug = $post->post_type;

    // remove post slug from url
    $post_link = str_replace( '/' . $slug  . '/', '/', $post_link );

    return $post_link;
add_filter( 'post_type_link', 'wpse_remove_cpt_slug', 10, 3 );
add_filter( 'post_link', 'wpse_remove_cpt_slug', 10, 3 );

function wpse_parse_request( $query ) {

    // Only noop the main query
    if ( ! $query->is_main_query() )

    // Only noop our very specific rewrite rule match
    if ( 2 != count( $query->query )
         || ! isset( $query->query['page'] ) )

    // 'name' will be set if post permalinks are just post_name, otherwise the page rule will match
    if ( ! empty( $query->query['name'] ) )
        $query->set( 'post_type', array( 'post', 'project', 'page' ) );
add_action( 'pre_get_posts', 'wpse_parse_request' );


post_type_link is a filter applied to the permalink URL for a post or custom post type prior to printing by the function get_post_permalink.

post_link is a filter applied to the permalink URL for a post prior to returning the processed url by the function get_permalink.

This hook is called after the query variable object is created, but before the actual query is run. The pre_get_posts action gives developers access to the $query object by reference (any changes you make to $query are made directly to the original object - no return value is necessary).

|improve this answer|||||
  • Could you add further explanation please? Just to clarify what the functions are doing and why. – Howdy_McGee Nov 4 '16 at 20:16
  • I've seen this solution posted before in other places. This only works to change the permalink when it is displayed (e.g. via get_the_permalink()), but the actual URLs do not change. The post is still only accessible by its original URL, /project/hello/world. – darrylyeo Nov 4 '16 at 20:23
  • 1
    pre_get_posts allows the name to be queried by including the post type in the main query so it works both ways. @Howdy_McGee - sure but not soon... It's Friday! I won't say I think this is 100% but it's the best answer so far. – jgraup Nov 4 '16 at 21:11
  • I take back what I said above; I forgot that I need to set 'rewrite' => array( 'slug' => '/' ) for this solution to work (as countless other articles and websites have said to do). With this in place, the URLs for my custom post type work as I want, but all the pages and blog posts on the site now return 404 errors as I anticipated. I need something much more robust than this. – darrylyeo Nov 4 '16 at 23:55

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.