D
P
0

WordPress & PHP

`post_link` Filter Is Correct but Some Posts Stay at Root? Permalink Manager Lite Overrides at Priority 99

July 30, 2026·5 min read
`post_link` Filter Is Correct but Some Posts Stay at Root? Permalink Manager Lite Overrides at Priority 99

A client site I maintain had one firm permalink rule: every article post must show its category before the slug, so the shape is /{category}/{slug}/. The theme already carried a post_link filter for exactly that, straightforward code I assumed was deterministic:

add_filter('post_link', 'theme_category_permalink', 10, 2);
 
function theme_category_permalink($url, $post) {
    $terms = get_the_terms($post->ID, 'category');
    if (empty($terms) || is_wp_error($terms)) {
        return $url;
    }
    return home_url('/' . $terms[0]->slug . '/' . $post->post_name . '/');
}

The problem: the output differed from post to post. Some articles came out as /{category}/{slug}/, exactly what I wanted. Others stayed parked at the root as /{slug}/, with no category prefix at all. Identical taxonomy, identical meta, and the code above clearly has a single path for every post. Same code, same input, different output. A non-deterministic bug in deterministic code always means another hand is on the wheel.

Three theories, all wrong

Theory one: stale files. Maybe an old copy of the theme was stuck on the server, so I re-synced, compared checksums, and confirmed production matched the repo byte for byte. No difference. The wrong posts stayed wrong.

Theory two: opcache. Perhaps PHP was serving cached bytecode from an earlier version of the permalink logic. I flushed opcache and restarted PHP-FPM. Zero change.

Theory three, the one that felt most plausible at the time: hook ordering. Maybe my filter ran at the wrong priority and something clobbered it. I nudged the priority from 10 to 5, then to 20. Still random. The same posts stayed category-less. Three hours gone on three guesses, and not one of them touched the real cause.

Finding the culprit

I stopped guessing and started interrogating the filter directly. First trick: a sentinel probe. I fed in a marker string that could never appear naturally as a post_link input, then watched what came back out:

$out = apply_filters('post_link', 'PROBE-INPUT', get_post($id), false);
error_log('post_link sentinel -> ' . $out);

If the filters in that chain respected their input, I should see a trace of PROBE-INPUT in the output. Instead I got a complete permalink with not one character of my sentinel in it. The meaning was blunt: some downstream filter was ignoring the incoming $url and replacing it wholesale, not modifying it.

Now I needed to know which one. I dropped in a temporary diagnostic mu-plugin that dumps every callback on the post_link hook along with the file and line it came from, using Reflection:

add_action('init', function () {
    global $wp_filter;
    foreach ($wp_filter['post_link']->callbacks as $prio => $cbs) {
        foreach ($cbs as $cb) {
            $fn  = $cb['function'];
            $ref = is_array($fn)
                ? new ReflectionMethod($fn[0], $fn[1])
                : new ReflectionFunction($fn);
            error_log(sprintf(
                'post_link prio %d -> %s:%d',
                $prio, $ref->getFileName(), $ref->getStartLine()
            ));
        }
    }
});

The log laid it all bare. My theme filter sat at priority 10, right where I expected. But at priority 99 there was a second callback I never registered, and its file pointed at wp-content/plugins/permalink-manager/. A plugin that was not even in the inventory I had been handed.

The root cause

The culprit was Permalink Manager Lite, installed quietly by the SEO team before I came on. The plugin hooks post_link at priority 99, well above the theme filter, and instead of adjusting the URL the theme already built, it pulls the permalink from its own per-post storage table, an option named permalink-manager-uris, and overwrites it whole.

That explained the randomness. The posts that showed up correctly as /{category}/{slug}/ were not the work of my theme filter at all. They were posts where the SEO team had once typed a URI by hand in the plugin's metabox, in that same shape. Posts never touched in the metabox got the plugin's default URI, the slug at root with no category. So what I read as random variation was actually a precise map of which posts had been hand-edited. The code was deterministic. It just was not my code deciding the output.

One detail briefly misled my verification: the edge cache ignored query strings, so ?nocache=1 did nothing to bust the HTML cache. The only genuinely live oracle was the REST API, which computes a fresh permalink on every request:

curl -s 'https://client-site.example/wp-json/wp/v2/posts/123' | jq -r '.link'

The .link value there is the real filtered permalink, with no page cache in the way.

The fix

I did not want two systems fighting over URL ownership. Step one: audit the contents of permalink-manager-uris. There were 54 entries, which I classified into the deliberate ones and the auto-generated defaults. With that map clear, I deactivated the plugin so the theme owned URLs again.

But deactivating the plugin surfaced a second problem: many old root URLs were already indexed. Once the plugin was off, the canonical moved to /{category}/{slug}/, and requests to the root /{slug}/ needed a 301 to the canonical. Here is the trap: core's redirect_canonical does NOT handle this for permalinks built by a filter. Core only knows the default permastruct, not a URL your theme filter invented. So I wired up my own template_redirect handler:

add_action('template_redirect', function () {
    if (!is_singular('post')) {
        return;
    }
    $canonical = get_permalink(get_queried_object_id());
    $current   = home_url($_SERVER['REQUEST_URI']);
    if (untrailingslashit(strtok($current, '?')) !== untrailingslashit($canonical)) {
        wp_safe_redirect($canonical, 301);
        exit;
    }
});

Now every post has one canonical computed by the theme, and old root URLs redirect permanently to the category form. One system, one owner, one source of truth.

Takeaways

  • A non-deterministic bug in deterministic code means another hand is involved. Suspect a plugin or filter you did not register yourself.
  • A sentinel probe through apply_filters with a marker input quickly proves whether a filter overwrites wholesale rather than modifies.
  • Dump $wp_filter['post_link'] with Reflection to get the file:line of every callback. That tells you who is on the hook instead of guessing.
  • Watch out for plugins someone else installed that never made it into your inventory. Permalink Manager Lite hooks at priority 99 and overrides from its own table.
  • When the edge cache ignores query strings, the REST API is the honest live oracle for a permalink.
  • Core redirect_canonical does not 301 root to canonical for filter-built permalinks. Ship your own template_redirect.