D
P
0

WordPress & PHP

`get_page_by_path('add-property')` Returns `null` for a Nested Child Page? Production Ballooned to 137 Duplicate Pages

July 24, 2026·5 min read
`get_page_by_path('add-property')` Returns `null` for a Nested Child Page? Production Ballooned to 137 Duplicate Pages

A booking platform I maintain for a client site has a custom plugin that auto-creates its required pages on activation. One day I opened Pages in the admin and the count made me pause: 137 pages. It should have been around 17. The rest, nearly 120 rows, were duplicates: add-property-2, add-property-3, on and on, and the same for bookings-2, bookings-3. The page list looked like a photocopier jammed in the on position.

The curious part was that not every page cloned itself. Only two split: add-property and bookings. Both happen to be child pages of owner-dashboard, where property owners manage their listings. Every top-level page stayed a single copy. Only those two nested children kept breeding.

The dead ends I tried first

My first instinct pointed the wrong way. I assumed someone had been clicking a button, or a rogue cron was looping. So I cleaned up by hand: select every add-property-N and bookings-N, trash them, empty the trash. Pages dropped back to 17. Relief, briefly.

Then the next deploy shipped, I bumped the plugin version, and the duplicates came right back. Not gradually, a fresh pair appeared the instant the version bumped. That was when I stopped blaming people. A bug that reappears at a predictable moment is deterministic, and the only thing that runs on a version bump was my own plugin bootstrap.

The root cause

The plugin has a bootstrap that ensures every required page exists, gated by a PCP_VERSION option. Each time the version constant goes up, it runs once and calls pcp_ensure_page() for each required page. That function is meant to be idempotent: if the page exists, do not create another. Here is how it checked:

$existing = get_page_by_path( $slug ); // $slug = 'add-property'
if ( ! $existing ) {
    wp_insert_post( /* ... new page ... */ );
}

For a top-level page this is correct. get_page_by_path('contact') finds the contact page, the guard trips, no insert. The trouble starts the moment a page has a parent.

add-property and bookings are children of owner-dashboard. In WordPress, a child page's path is not its bare slug, it is the full path through its parent: owner-dashboard/add-property, not add-property. And get_page_by_path() matches against that full path. So:

get_page_by_path( 'add-property' );              // null, even though the page EXISTS
get_page_by_path( 'owner-dashboard/add-property' ); // found

The bare-slug call always returned null. The guard concluded the page did not exist, so wp_insert_post() created another one. Because the slug add-property was already taken under the same parent, WordPress did not reject it, it silently appended a numeric suffix: add-property-2, then add-property-3 on the next bump. Every version bump added one more layer per nested child. That is why only those two pages ballooned, and only on a version bump.

The deceptive part was that nothing errored. get_page_by_path() returning null is valid for a path that does not match, and wp_insert_post() succeeded every time. From PHP's point of view nothing failed. The bug lived in the gap between the path I checked and the path the page actually uses.

The fix

There were two parts, and the order mattered.

First, check existence by the full path, not the bare slug. I gave pcp_ensure_page() a parent parameter and built the right path before the lookup:

function pcp_ensure_page( $slug, $title, $parent_slug = '' ) {
    $path = $parent_slug ? $parent_slug . '/' . $slug : $slug;
    $existing = get_page_by_path( $path );
    if ( $existing ) {
        return $existing->ID; // idempotency now correct for nested children
    }
    return wp_insert_post( /* ... */ );
}

With $parent_slug = 'owner-dashboard', the lookup becomes owner-dashboard/add-property and matches the existing page. The guard does its job, the insert stops. Version bumps no longer produce duplicates.

Second, clean up the hundreds of dupes already piled up. I added pcp_cleanup_duplicate_child_pages($max = 50) into that same version-gated bootstrap. It deletes the accumulated duplicates, scoped tightly: it only targets children named add-property, add-property-2, add-property-3, and so on under the required parent, never touching any other page. A few guardrails I treated as non-negotiable:

  • Always keep the page the pretty URL resolves to. One canonical page per child must survive, so links already out in the wild do not break.
  • Cap it at 50 deletions per run. With a backlog of around 120, deleting everything in one request could time out.
  • Make it self-continuing: the version option is left unset until the duplicate count hits 0, so the next run picks up where the last left off, with no manual intervention.

Once it ran, production pages fell from 137 to 17, and stayed there through the next version bumps.

The takeaway

  • A child page's path in WordPress is parent-slug/child-slug, not the bare slug. get_page_by_path() matches the full path.
  • Check a nested page's existence with the bare slug and you always get null, your idempotency guard leaks, and the insert keeps running.
  • WordPress does not reject a duplicate slug under the same parent, it appends -2, -3 suffixes. An invisible loop can pile up hundreds of pages without a single error.
  • A bug that reappears at a predictable moment, like a version bump, is deterministic. Suspect your own code, not other people.
  • For a bulk cleanup job: scope it tightly, keep the canonical page, cap it per run, and make it self-continuing so a big backlog cannot time out.