DEV Community

Guy-Argaman
Guy-Argaman

Posted on

Why your custom post type URLs 404 (and how to never re-save permalinks again)

You register a custom post type, publish a post, open it, and get a 404. Nothing is wrong with your code. You go to Settings → Permalinks, click Save without changing anything, and now it works.

Every WordPress developer learns this ritual eventually. Clients learn it too, usually right after a deploy. I finally got annoyed enough to find out exactly why it happens and to automate it away. This is what I learned.

Routes are described in code but served from a cache

When you call this:

register_post_type( 'recipe', array(
    'public'  => true,
    'rewrite' => array( 'slug' => 'recipes' ),
) );
Enter fullscreen mode Exit fullscreen mode

WordPress doesn't create any URL rules yet. It records a permastruct (/recipes/%recipe%) in the global $wp_rewrite object. add_rewrite_rule() and add_rewrite_endpoint() work the same way: they describe routes and nothing more.

The rules WordPress actually matches incoming URLs against are a big array of regex → query pairs, like recipes/([^/]+)/?$ => index.php?recipe=$matches[1]. Building that array is expensive, so WordPress builds it once and stores it in the rewrite_rules option. Every request reads the stored copy.

Nothing rebuilds the stored copy when your code changes. That only happens when something calls flush_rewrite_rules(), and saving Settings → Permalinks is one of the few things that does. Until then, /recipes/my-pie/ is matched against rules that have never heard of recipes, and you get a 404.

Why "just flush on init" is the wrong fix

The first fix everyone tries is this:

add_action( 'init', function () {
    register_post_type( 'recipe', /* … */ );
    flush_rewrite_rules(); // Don't.
} );
Enter fullscreen mode Exit fullscreen mode

It works, and it rebuilds the entire rule set and writes it to the database on every single request. The function's documentation warns that flushing is expensive and belongs in activation hooks, not on every page load.

The recommended pattern is to flush once in your plugin's activation hook. That's correct, but it only covers your own plugin being activated. It misses theme switches, code deploys where a slug changed, another plugin adding routes, and every site where someone edited a post type in a code snippet plugin. That's why the ritual survives.

The fix: rebuild only when the routes actually changed

What we want is "flush whenever the described routes differ from the ones the cached rules were built from". That's cheap to check, because everything the rules are built from is sitting right there in $wp_rewrite:

add_action( 'wp_loaded', 'permapilot_maybe_flush', PHP_INT_MAX );

function permapilot_maybe_flush() {
    $fingerprint = permapilot_fingerprint();
    if ( get_option( 'permapilot_fingerprint' ) === $fingerprint ) {
        return;
    }

    // A soft flush rebuilds the rules in the database without rewriting .htaccess.
    flush_rewrite_rules( false );
    update_option( 'permapilot_fingerprint', $fingerprint, true );
}

function permapilot_fingerprint() {
    global $wp_rewrite;

    return md5( wp_json_encode( array(
        $wp_rewrite->extra_permastructs, // Post types and taxonomies.
        $wp_rewrite->extra_rules_top,    // add_rewrite_rule( …, 'top' ).
        $wp_rewrite->extra_rules,        // add_rewrite_rule( …, 'bottom' ).
        $wp_rewrite->endpoints,          // add_rewrite_endpoint().
    ) ) );
}
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • wp_loaded, not init. Plugins and themes register routes on init, at all sorts of priorities. By wp_loaded, everyone is done.
  • A soft flush. Passing false rebuilds the rules in the database and leaves .htaccess alone. WordPress's .htaccess block is a generic catch-all anyway.

When nothing changed, the whole thing costs one autoloaded option read and one hash per request.

The edge case that almost made it slow

Then I found a problem. Some plugins register a post type only when is_admin() is true. On those sites, admin requests and front-end requests describe different routes, so the fingerprint flips on every request, and every request rebuilds the rules. That's exactly the "flush on init" disaster I was trying to avoid.

I wrote a test that reproduced it first: alternate 10 front-end requests with 10 admin-ajax.php requests and count the rebuilds. The result was 20 rebuilds in 20 requests.

The fix is a cap of 5 rebuilds per 10 minutes:

$now      = time();
$rebuilds = array_filter(
    (array) get_option( 'permapilot_rebuilds', array() ),
    function ( $time ) use ( $now ) {
        return $time > $now - 10 * MINUTE_IN_SECONDS;
    }
);
if ( count( $rebuilds ) >= 5 ) {
    return;
}
Enter fullscreen mode Exit fullscreen mode

Real route changes still apply on the next request. A flip-flopping site can never rebuild more than 30 times an hour.

What happens to the old URLs?

Fixing new URLs raised the opposite question: when you rename a slug from recipes to recipe, what happens to every link pointing at /recipes/pie/?

I expected 404s, and was surprised. WordPress's 404 handler, redirect_guess_404_permalink(), guesses a post with a matching slug and 301-redirects to it. So old post URLs mostly survive, but the guess is fuzzy: a typo like /recipe/pi/ also gets sent to "pie". And it only ever looks for posts. Rename a taxonomy from genre to genres and every /genre/jazz/ link is a plain 404, forever.

That gap is what I built a paid add-on for, Permapilot Pro. It remembers each post type's and taxonomy's old URL patterns and sends exact 301 redirects, for terms too. It also emails you when a route disappears after an update and visitors start landing on 404s there. The free plugin is complete on its own; this is the part I charge for.

Testing it against a real WordPress

Rewrite bugs live in the gaps between plugins, caches and requests, so mocks wouldn't have caught any of the bugs above. Every test runs against a real WordPress in Docker, set up by @wordpress/env. A small must-use plugin lets each test "change the site's code" by flipping an option, and the test then requests the URL like a browser would:

test("a newly registered post type's posts are reachable", async () => {
    wp("option", "update", "pa_fixture_cpt_slug", "book");
    wp("post", "create", "--post_type=book", "--post_status=publish",
        "--post_title=Dune", "--post_name=dune");

    const response = await get("/book/dune/");

    assert.equal(response.status, 200);
});
Enter fullscreen mode Exit fullscreen mode

Without the plugin, that test gets the classic 404. With it, a 200, and nobody touched Settings → Permalinks.

Try it

Permapilot is free on WordPress.org, has no settings, and works the moment it's activated:

https://wordpress.org/plugins/permapilot/

If you can break it, I'd love to hear how. And if you rename slugs on live sites, Permapilot Pro keeps the old URLs working.

Top comments (0)