WordPress WP_Rewrite: Custom Permalink Structures, Rewrite Rules, and Flush Rules Correctly

WordPress’s URL routing is controlled by the WP_Rewrite object — a PHP class that builds the rewrite rules stored in .htaccess (Apache) or passed to Nginx. When you register a custom post type with a custom slug, register a custom taxonomy, or call add_rewrite_rule(), you are adding entries to the rewrite rule table. The critical point that trips up every developer for the first time: after adding or changing rewrite rules in code, you must flush the rewrite rules for them to take effect. WordPress does not flush automatically except during plugin activation/deactivation. Forgetting to flush — or flushing on every page load (which is expensive) — are the two most common rewrite-related bugs. Understanding the WP_Rewrite object also unlocks the ability to build custom permalink structures for custom post types, add rewrite endpoints for custom URL segments, and control the trailing slash behaviour of your site’s URLs.

Problem: You registered a custom post type product with the slug products, but visiting /products/my-product/ returns a 404. After changing the permalink structure for the CPT, you need to flush rules correctly — on activation only, not on every page load — and add a custom rewrite endpoint for a /tab/ URL segment.

Solution: Flush rewrite rules in the plugin activation hook with flush_rewrite_rules(). Add a rewrite endpoint with add_rewrite_endpoint(). Use the rewrite_rules_array filter to inspect or modify all rules.

<?php
// ── Register CPT with custom rewrite slug ─────────────────────────────
function register_product_cpt() {
    register_post_type( 'product', [
        'public'  => true,
        'label'   => 'Products',
        'rewrite' => [
            'slug'       => 'products',   // → /products/my-product/
            'with_front' => false,        // don't prepend the blog base
        ],
    ] );
}
add_action( 'init', 'register_product_cpt' );

// ── Flush ONLY on activation — never on every page load ───────────────
register_activation_hook( __FILE__, function () {
    register_product_cpt();           // register first, then flush
    flush_rewrite_rules();            // writes to .htaccess / updates db
} );

register_deactivation_hook( __FILE__, function () {
    flush_rewrite_rules();            // clean up on deactivation too
} );

// ── Add a rewrite endpoint ────────────────────────────────────────────
// Adds /tab/{value}/ to any post, page, or attachment URL
// e.g. /products/my-product/tab/reviews/
add_action( 'init', function () {
    add_rewrite_endpoint(
        'tab',                          // endpoint name
        EP_PERMALINK | EP_PAGES         // which URL types to apply to
    );
} );

// Reading the endpoint value in a template:
$tab = get_query_var( 'tab', 'overview' ); // 'overview' = default

// ── Custom rewrite rule ───────────────────────────────────────────────
// Route /api/products/{slug}/ to index.php?post_type=product&name={slug}
add_action( 'init', function () {
    add_rewrite_rule(
        '^api/products/([^/]+)/?$',            // regex for the URL
        'index.php?post_type=product&name=$matches[1]', // WordPress query vars
        'top'                                  // 'top' = check before others, 'bottom' = after
    );
} );

// ── Inspect current rewrite rules (debug only) ───────────────────────
add_filter( 'rewrite_rules_array', function ( $rules ) {
    // Uncomment to dump all rules:
    // error_log( print_r( $rules, true ) );
    return $rules;
} );

// ── Manual flush from WP-CLI (use during development) ─────────────────
// wp rewrite flush

NOTE: Calling flush_rewrite_rules() on every page load is a serious performance issue — it rebuilds and writes the entire rewrite rule set to the database and to .htaccess on every request. The correct approach is to flush only in activation and deactivation hooks. During development, flush from WP-CLI (wp rewrite flush) or temporarily add it to init, test, then remove it. The add_rewrite_endpoint() function needs a corresponding flush to take effect — add the endpoint in init and flush in the activation hook. After calling add_rewrite_endpoint(), the endpoint name is automatically registered as a query variable — you can read it with get_query_var().