St. Louis Web Developer & User Interface Designer

I recently had a need to rewrite the URLs of all parent and child pages in a custom post type so that they appeared to live at the website root, but in reality, continued to live in a custom post type within their hierarchy.

Preface

The situation:

I have a page called Services that lives at domain.com/services.

I have a custom post type called Services.

I have Services post called Programming that lives at domain.com/services/programming.

I have Services post called Web Development, that is a child of Programming, that lives at domain.com/services/programming/web-development.

The goal:

The Services page should remain where it is i.e. domain.com/services.

The Programming post should appear to live at the website root i.e. domain.com/programming.

The Web Development service should also live at the website root i.e. domain.com/web-development.

Line 13: If post belongs to a post type that we want to remove the post type slug from.

Line 15: Build a new permalink from home URL with just the post name.

All of this will essentially give us URLs like this:

domain.com/programming/

domain.com/web-development/

But neither of those will work – you’ll get a 404 error – because WordPress can no longer identify those posts as Services posts and therefore attempts to find pages called Programming and Web Development, which don’t exist, hence the 404 error.

As you can see, using the services slug, WordPress knows to take whatever comes after it ((.+?)) and pass it in as the pagename value ($matches[1]), which translates to something like:

domain.com/index.php?services=programming

Since the first rewrite rule no longer applies (slug removed), it now passes programming in as the pagename without specifying the post type, and since this page doesn’t exist, WordPress can’t return the page.

2. Add post type back to the post query

Now that WordPress doesn’t know that it’s dealing with a custom post type, we need to provide it with this information. For that we’re going to use the pre_get_posts hook.

Line 7: We’ll need the WordPress database object to get additional information about the post.

Line 9-11: We’re going to make sure that the current query is the main query. Since a page can have multiple queries, we’re limiting our filter to only the query that gets information about the page itself.

Line 13: We’re going to grab the pagename from the query, which might be programming or web-development.

Line 15-20: Now we’re going to use the pagename to look in the database for that particular post and extract its post type.

Line 22-23: If this post has a post type that we know we need to manipulate, we’ll need to perform additional actions.

Line 24: We need to a create a query var for the post type and set it to the pagename, so WordPress knows where to actually find the data for the post.

Line 25: We also need to set the post_type back to services.

Line 26: In order for WordPress to load the right template i.e. single-services.php, we need to set is_single to true.

Line 27: Last, but not least, we set is_page to false for good measure, since it’s a custom post type.

Result

You’ve met all your goals. In addition, if you try to create a page with the same name as one of your services, you’ll notice that the slug (i.e. post_name, pagename, etc.) will now increment, avoiding a possible collision.

Here are a couple of things to keep in mind:

I didn’t test the performance of this yet, but if you use some kind of caching system on your website, you should be fine.

All my functions live within a PHP namespace, so I only prefixed them with custom_ for simplicity of this post.

I’ve looked at many different solutions online, and they all had their fair share of problems, so this may too, I just don’t know it yet 🙂

If you have questions or suggestions, leave them in the comments below.

First, I would output the current WordPress rewrite rules (example in post), just to make sure the ones you added are indeed active. Second, I would print out the $query variable (print_r($query, true)) in custom_pre_get_posts to see that the values are set to what you expect (and also make sure the code in the filter is executed).

I’m trying to use this in combination with 301 redirects to create a short urls post type.
I didnt have the postype setup to begin with, so it took a minute to figure out I needed to add: add_action( 'init', 'register_services' ); May want to note this in the post.

Also, setting has_archive to true automatically adds it to your permalink structure, you show false.

My custom permalink structure is set to: /blog/%postname%/
So I was getting /blog/posttype/postname/
Shouldnt have mattered because you’re grabbing the last part of the path, but I think something was screwy there because when I print $post_name later in custom_pre_get_posts, it wasn’t finding anything.

Let’s systematically troubleshoot this. For step #1, let’s confirm the post_type_link filter is working. If you go anywhere in your template and use get_permalink($id), with $id belonging to a services post, please provide the absolute path it printed out (you can omit the domain).

You have a couple options. (1) You could wrap the filters in a is_main_site() check, which would only apply it to your main site, leaving your child sites as-is. (2) In step 1, where you remove the “services” slug, you could perform a is_multisite() check and then parse $post_path accordingly, essentially accounting for the site name by keeping it in there or putting it back.

I do have MU enabled on the site that I did this on, however, I’m only applying this code on the main site and not child sites, since don’t need it (yet). Looking at the code above, there is nothing to suggest that it wouldn’t work, except for, as Kevin pointed out, you may have to adjust the $permalink that’s created in step #1 when MU is installed as sub-directories instead of sub-domains, since there’ll be an extra component in the URL that needs to be accounted for.

Ryan, your tutorial was very useful. My case was similar to yours and I was able to remove the slug. But I made a few modifications for this to work as you can see here https://gist.github.com/stefanbc/6620151. First of all, this line

1

add_filter('pre_get_posts','custom_pre_get_posts');

add_filter('pre_get_posts','custom_pre_get_posts');

should be something like this

1

add_action('pre_get_posts','custom_pre_get_posts');

add_action('pre_get_posts','custom_pre_get_posts');

because according to the WordPress codex there is no filter pre_get_posts
And another thing I modified was this

Thanks for the feedback, Stefan. You are right about it being an action and not a filter. I’ve updated my post above. With regard to `pagename` vs `name`, if your custom post type is based on a post, which is the default, `name` would be in fact the way to go, but if it’s based on a page, I think `pagename` is recommended. I’m actually using it as a page, but omitted that in my setup– thanks for catching that!

Thanks for the reply Ryan. I must be doing something wrong, because I cant get it to work. First, in step 1, you forgot the word custom in the function. Line 8 should be:function custom_post_type_link($permalink, $post, $leavename) {

But regardless, I tried this but it didnt work. Not sure if this matters or not, but the site is located in this pathpublic_html/SUBFOLDER/the-wordpress-install-is-here

Hi Ryan…
Ok, I tried new code …the permalink does change int he editor screen …the slug is not there, but I get the 404. I tried resetting permalinks just to make sure there wasn’t an issue there with the 404, but still no dice.
I will paste what I have below for the CPT just in case it is something on my end…

Maybe you have a different kind of WordPress configuration or a plugin that is preventing this from working? I downloaded WordPress 3.8, installed it in a subdirectory as a single site, and then only installed that plugin I referenced above, and that worked for me.

As a side note, this is also working on a production site running multisite, where WordPress is in a subdirectory, but served from the root.

The ultimate test would be to setup a clean WordPress, just to rule out a configuration/setup/plugin issue. If that works, you know it has something to do with your sandbox.

With regard to debugging the sandbox, since the problem seems to be related to step #2 (since the URL seems to be accurate), I would start with printing out various variables. Something like:

Thanks for your help Ryan!
….I deactivated all plugins, and switched to 2014 theme…. still same issue. 404 error.

Here is my set up: I originally had a website in the main public_html folder. Then over the years, I added three add-on domains for this hostgator account. so it was set up like this:

public_html/MAIN WORDPRESS INSTALL/ (then I have three different folders here….one for each of the three add-on websites)

Each of the three folders has its own WordPress install. Then a year or so ago, I deleted the main wordpress install from the server because I dont use that site anymore…. but I kept the three ass-on websites….. so it looks like this
public_html/website1.com or
public_html/website2.com or
public_html/website2.com

It’s the same public_html/ but with three different add-on domains, and the original wordpress install is not there anymore….just the addon folders. Not sure why this would make a difference, but maybe it does?

The individual WordPress sites (using add-on domains) should be isolated from each other, so I don’t think that’s the problem.

Re: WordPress theme and plugin deactivation, while that is a good test, there could still be some configuration in the database that could prevent this from working. A better test would be a fresh install with a newly setup database.

Also, have you checked the output of the variables mentioned in my previous comment?

You could try something like this. It assumes you are using the post_tag taxonomy and only have one term. If you have more than one term, it will use the first one. In addition, while the permalink will generate based on your selected term, it doesn’t validate the term when parsing the URL, meaning the page would render with any term, really.

I am trying to change my cpt urls using your code. I could remove slug from parent post urls, but it fails on child post urls. My cpt is hierarchical. I modified the post_type_link hooked function as below and now it changes link for child pages as well

Now the permalinks are created correctly, but the problem is that i get a 404 on child posts. I have dumped the query on both parent and child posts and i have noticed that on child post, the query_var attachment is set and no other query_vars are set. Please help if possible. Thanks.

Second, and this is the main problem, the post name being used to find your child page in the database contains the parent slug, in other words, parent-page/child-page, but there is no post with a post name like that, hence the page not found error.

That means you have to strip out the parent slug or simply take the contents after the last slash.

If you add the following after $post_name = $query->get('pagename'); in the pre_get_posts hook, it should work:

Hello Ryan,
thank you for this great post, it was exactly what I am looking for.
Just one question regarding to Tomas post if this function or plugin will work with WPML:
I use your function for a site which is published in two languages (German source and English using WPML plugin). I have different custom post types (on of them is members, not hierarchical) which are first filled in an overview page (simple loop) including an link to the detail page of the member. The overview works as expected but when I try to get the detail page it only works in the German version, the English version throws an error 404. I would really appreciate any advise which will point me to the right direction.
Thank you,
Yana

I was wrong, it is not the first function, it is the pre_get_post function which causes the problem. After cache clearing the whole site throws an 404 error. It seems that in my case the first cpt mitglieder-svr is allways used in every query.

Give the original code a try for pre_get_posts (especially the query part that gets the post type), since you are not exactly doing what I’m doing in the blog post (which is writing all permalinks to the root without the post type slug.

Let’s say your post name is “foobar” and let’s say you build a URL as follows:

example.org/examplefoobar

When the query is performed to look up the post, there is no post in your database called “examplefoobar,” which is why step 2 is not working.

If you are adding “example” in the post name, you must remove it again prior to making the query. So, on line 8 in your first post, you could try something like this:

$post_name = str_replace('example', '', $query->get('pagename'));

Note that in this case, regardless of where “example” occurs in the string, it will be removed, so depending on your rules, you may want to refine it.

Better yet, though, if you changed it to example/foobar instead of examplefoobar, that might be a little bit more reliable, as you could just grab everything after the last slash, but at that point, you might as well just change the slug of the post type to example, because then you don’t need any of this extra code.

You can find this argument in the rewrite section on the register_post_type page in the WordPress codex.

I have implemented your solution on my local environment, and it works like a charm. However, when i do the same on the online solution, some links break, and i cannot save stuff in the administration panel. (posts, permalinks and more). Any thoughts?

Nevermind Ryan, i seemed to fix the problem. The former programmer on the site im working on, made a bunch of whitespaces in the online version of the functions.php file, which caused the white screen of death. 🙂 thanks for the awesome solution

When i implemented your solution, i changed the permalinks to some of my custom post types, so that they linked to the root of the website. By doing that, i now have more than one link to my custom post types. The links i had before i changed it, is still active, and thats not good according to google (SEO), because the pages are interpreted as duplicates (obviously).

Yo Ryan
i’ve spent time on the problem and here is what happen :
– i can’t get the post type in the pre_get_posts function because after apply the post_type_link filter,$post_name = $page_name = $query->get('pagename'); is empty.

Here is the result.
For info, my child post name is CEP.
As you can see the query let appear it as an attachment, but i didn’t know why (this is not the case if didnt filter the permalink).
My WP version is 4.3.1

I see the attachment, and that doesn’t appear to be normal. My guess is that either your theme or a plugin is responsible for that. It’s difficult to troubleshoot without having more context. I ran my code in a brand new WordPress installation, so I know there was nothing interfering there.

That said, I would test the code in a brand new WordPress installation and then add your theme and plugins back, one by one, to isolate what might be causing this.

Hi Ryan.
First at all thanks a lot for the timing you spending to help me.

So it’s really stange.
I’m also woth the twenty Fifteen theme.

After putting the die just before $query, this work well.
So i ‘ve donwload Custom Post Type UI, set another type and change the Git.

After this done, any thing work as except.
I’ve got an 404 on my custom type
And for the cpt “Services” (which work well before), i’m falling with the ‘attachment problem’ even if i remove the CPT plugin…

I’ve been trying to get this to work and in hope to find a solution, I also came across this before yours ( http://kellenmace.com/remove-custom-post-type-slug-from-permalinks/ ) which works, but not on a multisite install it would seem ( just on the main site ), so I continued to look for help and came here. I saw that you said your solution should work on a multisite install, so I thought ‘great!’, but I’ve been trying to get this to work for a little while now, and keep getting a 404 error.

I have a series of cpt’s that I want to do this with ( ‘excursion’, ‘hotel’, ‘offer’, ‘tour’ ), I have these setup on a multisite install of WP using subdomains. I have also got domain mapping on them, but I have tried your solution first without this active, but with no joy.

It doesn’t seem to work at all. On the main site the ‘custom_post_type_link’ function seems to work as the url has those parts removed. But the ‘custom_pre_get_posts’ function doesn’t seem to work as I get a 404.

On the other sites of the install it doesn’t do anything at all, the pages load without removing the parts and therefore load as ‘normal’.

I pasted it into a functions.php file on a multisite I’m running, and added multiple tours to two different sites. Each permalink had the post type removed and I saw each tour page render (using the default template).

There must be something else you have tweaked or installed that is interfering with the code above, but based on what you posted, I can confirm that it works.

I could definitely see WPML causing issues, since it manipulates database queries. I run a network of sites with WPML, but I don’t remove post type slugs on any of those sites. You might deactivate both the domain mapping and WPML plugin to see if that resolves the issue. If so, then at least you know where to look to find a workaround.

Hi Alex! I don’t use WooCommerce, so I don’t have any information that would by helpful here. I will say that my tutorial is for changing the URL for post types that you register. Changing the URLs in post types registered by a third-party plugin might have unintended consequences.