Custom post names for WordPress custom post types

This post is more than 11 years old.

When you edit a new post, page, or an object of a custom post type, WordPress generates a slug or post name that will be used in its permalink. It derives the post name from the title you gave it. Sometimes, however, you want the post name to have a little more information in it.

The post name is important because it is helps to identify the post, and it also helps with search engine optimisation. Of course, the person creating the post can always type in their own post name, but if you have a pattern for creating them, why not let WordPress do the work for you?

I have a custom post type for wines that I use on winery websites. Wines belong to a range, have a title, and a vintage, and these three things together are usually enough to identify a wine. For example, a winery might have three wine ranges, each with a Shiraz, and several vintages of each, but there’s only one Example Hermitage Shiraz 2010.

To manage the wines in a way that permits some flexibility in searching, listing, and displaying them,  the wine range and vintage are stored as postmeta, so the post title is only the wine name — Shiraz. This means that when there’s more than one Shiraz, WordPress generates post names like “shiraz-2”. Better would be to add the wine range and vintage, like this:

example-hermitage-shiraz-2010

When you’ve saved a post in WordPress, you can edit the slug to anything you like. If you clear the slug and press OK, WordPress generates a new slug from the post title using an AJAX action (sample-permalink). We can intercept the AJAX action and set our own post title. NB: WordPress adds the AJAX action with priority 1, meaning it goes first! Rude. Luckily, 0 is less than 1 :)

When you save the post, WordPress calls a filter (name_save_pre) that lets you change the post title before the post is saved. You might think that changing it in the AJAX action would be enough, but it sends a blank post title if you had let WordPress generate the post title, so actually saving the post means WordPress generates the post title again. So we need to intercept the post title filter too. Just be sure to ignore the auto-draft saves!

// NB: AJAX action priority < 1 so we can beat WordPress to it!
add_action('wp_ajax_sample-permalink', 'ajaxSamplePermalink', 0);
add_filter('name_save_pre', 'filterNameSavePre');

/**
* intercept AJAX call for regenerating sample permalink, and add some extra bits where required
*/
function ajaxSamplePermalink() {
    // check that we're dealing with a product, and editing the slug
    $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
    $post_name = isset($_POST['new_slug'])? $_POST['new_slug'] : null;
    $new_title = isset($_POST['new_title'])? $_POST['new_title'] : null;

    if ($post_id && $post_name === '') {
        $post = get_post($post_id);

        if ($post->post_type == WP_WINE_PAGES_TYPE_PRODUCT) {
            // generate new slug
            $_POST['new_slug'] = generateProductSlug($post, $new_title);
        }
    }
}

/**
* intercept the post name and generate a new one if required
* @param string $post_name
* @return string
*/
function filterNameSavePre($post_name) {
    // check that we're dealing with a product, and editing the slug
    $post_id = isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0;
    $new_title = isset($_POST['post_title']) ? $_POST['post_title'] : 0;

    if ($post_id && $post_name === '') {
        $post = get_post($post_id);
        if ($post->post_type == WP_WINE_PAGES_TYPE_PRODUCT && $post->post_status != 'auto-draft') {
            // generate new slug
            $post_name = generateProductSlug($post, $new_title);
        }
    }

    return $post_name;
}

/**
* generate new post_name for product
* @param WP_Post $post
* @param string $new_title
* @return string
*/
function generateProductSlug($post, $new_title) {
    $range = get_post_meta($post->ID, '_wpwines_range');
    $vintage = get_post_meta($post->ID, '_wpwines_vintage');

    // not every wine has a range, title and vintage;
    // collate into an array, and join the non-empty elements
    $parts = array($range, $new_title, $vintage);
    $post_name = implode('-', array_filter($parts, 'strlen'));

    // make the post name "browser friendly"
    // NB: I actually use my own function to replace accented chars too:
    // http://snippets.webaware.com.au/snippets/simple-url-cleanser-in-php/
    $post_name = strtolower(sanitize_title_with_dashes($post_name));

    // make sure that it's unique
    $post_name = wp_unique_post_slug($post_name, $post->ID, $post->post_status, $post->post_type, $post->post_parent);

    return $post_name;
}

Edit: WordPress SEO messes up the name_save_pre step by hooking in at priority 0. This can be fixed by removing that plugin’s hook when the post type is your target post type:

/**
* stop WordPress SEO from getting in first!
*/
function stop_wordpress_seo_name_save_pre() {
    global $typenow;

    // when editing pages, $typenow isn't set until later!
    if (empty($typenow)) {
        // try to pick it up from the query string
        if (!empty($_GET['post'])) {
            $post = get_post($_GET['post']);
            $typenow = $post->post_type;
        }
        // try to pick it up from the quick edit AJAX post
        elseif (!empty($_POST['post_ID'])) {
            $post = get_post($_POST['post_ID']);
            $typenow = $post->post_type;
        }
    }

    if ($typenow == WP_WINE_PAGES_TYPE_PRODUCT) {
        // prevent WordPress SEO from stuffing up our custom slugs
        if (isset($GLOBALS['wpseo_admin'])) {
            remove_filter('name_save_pre', array($GLOBALS['wpseo_admin'], 'remove_stopwords_from_slug'), 0);
        }
    }
}
add_action('admin_init', 'stop_wordpress_seo_name_save_pre');

job-is-done-2013.