Auto-Sync Post Slug with Title for a Custom Post Type in WordPress

WordPress generates a post’s slug from its title at the moment the post is first saved. After that, changes to the title do not automatically update the slug — this is intentional behaviour for public post types, because changing a published URL would break incoming links and bookmarks. However, for custom post types registered with 'publicly_queryable' => false — types that are not intended to appear in search results and whose URLs are never exposed publicly — this protection becomes an obstacle. If your code uses get_permalink() or the post_name field to build internal references, a stale slug after a title rename silently breaks those references. The correct solution is to hook into save_post_{post_type}, compare the current slug to the sanitized title, and update the slug only when they differ — with recursion protection to prevent the update from triggering the hook again.

Problem: Your nominees custom post type has 'publicly_queryable' => false, so WordPress does not expose a slug editor in the admin. When an editor renames a nominee's title, the slug stays as the original value — breaking any internal links or API references that depend on post_name.

Solution: Hook into save_post_nominees at priority 20, compare post_name to sanitize_title( post_title ), and call wp_update_post() only when they differ. Remove and re-add the hook around the update to prevent infinite recursion. For posts created before this code was added, run a one-time bulk backfill.

<?php
// ── Keep 'nominees' slug in sync with the post title on every save ─────
add_action( 'save_post_nominees', 'sync_nominees_slug', 20, 2 );

function sync_nominees_slug( int $post_id, WP_Post $post ) {
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    if ( wp_is_post_revision( $post_id ) ) {
        return;
    }

    $expected_slug = sanitize_title( $post->post_title );

    if ( $post->post_name === $expected_slug ) {
        return; // Already in sync — nothing to do
    }

    // Remove hook to prevent infinite recursion when wp_update_post fires save_post
    remove_action( 'save_post_nominees', 'sync_nominees_slug', 20 );

    wp_update_post( [
        'ID'        => $post_id,
        'post_name' => $expected_slug,
    ] );

    add_action( 'save_post_nominees', 'sync_nominees_slug', 20, 2 );
}

// ── One-time bulk sync for posts saved before this code was added ──────
// Run once from WP-CLI or a temporary admin URL, then remove this function.
function bulk_sync_nominees_slugs() {
    $posts = get_posts( [
        'post_type'     => 'nominees',
        'posts_per_page' => -1,
        'post_status'   => 'any',
        'no_found_rows' => true,
        'fields'        => 'ids',
    ] );

    $updated = 0;
    foreach ( $posts as $post_id ) {
        $post          = get_post( $post_id );
        $expected_slug = sanitize_title( $post->post_title );

        if ( $post->post_name !== $expected_slug ) {
            wp_update_post( [
                'ID'        => $post_id,
                'post_name' => $expected_slug,
            ] );
            $updated++;
        }
    }
    return $updated; // number of posts whose slugs were corrected
}

NOTE: The save_post_{post_type} hook suffix (e.g. save_post_nominees) is the correct way to target a single CPT — it avoids running on all post types like a plain save_post callback would. The recursion guard is essential: calling wp_update_post() inside a save_post callback without removing the hook first causes the callback to fire again, leading to an infinite loop that exhausts PHP's call stack. For public post types where the slug is editable in the UI, do not use this pattern — WordPress intentionally decouples title and slug after first publish to protect existing URLs.

Sources:

  1. https://wordpress.stackexchange.com/questions/295727/automatically-update-slug-with-latest-title-within-custom-post-type