D
P
0

WordPress & PHP

Naming a 2nd WP_Query `$more`/`$page` Collides With a WordPress Global — a Fatal That Masquerades as Out-of-Memory

June 24, 2026·4 min read
Naming a 2nd WP_Query `$more`/`$page` Collides With a WordPress Global — a Fatal That Masquerades as Out-of-Memory

A single template in a custom theme I built kept truncating mid-render. The output would stop dead — unclosed markup, no footer, half the content gone. What pointed me straight at memory was that the response byte size was identical on every single load. Not random, not intermittent. It cut off at exactly the same point each time. That is the textbook signature of PHP running out of air halfway through a render, so I burned hours poking at memory_limit.

In that template I ran a second loop for related posts and stored its WP_Query in a variable named $more. In a couple of other spots I also reached for $page and $id. The trigger looked roughly like this:

$more = new WP_Query( array(
    'post_type'      => 'listing',
    'posts_per_page' => 4,
    'post__not_in'   => array( get_the_ID() ),
) );
 
while ( $more->have_posts() ) {
    $more->the_post();
    // render related listing cards...
}
wp_reset_postdata();
 
// ... much further down, still in the same template:
if ( $more->have_posts() ) {
    // show a "view all" link
}

That second $more->have_posts() was the line killing the page. The fatal it threw was:

Fatal error: Uncaught Error: Call to a member function have_posts() on int

But that message never reached the screen. All I ever saw was a cleanly truncated page.

Why this happens

$more is not an ordinary variable name. It is a WordPress global. WordPress uses it to track whether a post is being shown in "read more" mode (tied to the <!--more--> tag). The moment my loop called the_post(), WordPress ran setup_postdata() under the hood, and that function rewrites a whole set of globals based on the post currently in scope — including $more, which it sets to the integer 0 or 1.

So here is the actual sequence. I assigned a WP_Query object to $more, the loop ran fine, and on the very first iteration setup_postdata() clobbered my $more into an integer. The WP_Query object was swallowed by the WordPress global. By the time execution reached the second $more->have_posts(), $more was no longer an object — it was an int. Calling a method on an integer is what threw the "on int" fatal.

So why did it look exactly like out-of-memory? Because a PHP fatal halts execution at precisely the same point every time. The render proceeds deterministically until it hits that one poisoned line, then dies. The output flushed up to that position is identical on every request, so the byte size is stable. That is, ironically, the strongest hint that this is a fatal and not an OOM — true memory exhaustion tends to stop at a wobbling threshold that shifts with load, not at the same exact byte count over and over.

The fix

The diagnostic that finally cracked it was a gated register_shutdown_function() that prints error_get_last() on shutdown:

// Temporary, gated so it never leaks to production / public output.
register_shutdown_function( function () {
    if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
        return;
    }
    $err = error_get_last();
    if ( $err && in_array( $err['type'], array( E_ERROR, E_PARSE, E_COMPILE_ERROR ), true ) ) {
        error_log( sprintf(
            'SHUTDOWN FATAL: %s in %s:%d',
            $err['message'], $err['file'], $err['line']
        ) );
    }
});

The instant this shutdown handler fired, it handed me the real file:line plus the actual "Call to a member function have_posts() on int" message. The memory theory collapsed on the spot — this was a type fatal, not exhaustion.

The fix was trivial once the cause was visible: never reuse a reserved WordPress loop global as a variable name. I renamed $more to $related_query (and used $secondary_query elsewhere):

$related_query = new WP_Query( array(
    'post_type'      => 'listing',
    'posts_per_page' => 4,
    'post__not_in'   => array( get_the_ID() ),
) );
 
while ( $related_query->have_posts() ) {
    $related_query->the_post();
    // render related listing cards...
}
wp_reset_postdata();
 
if ( $related_query->have_posts() ) {
    // show a "view all" link
}

Because $related_query is not a global that setup_postdata() touches, the object survives intact across the whole template. No more integer impersonating a WP_Query, no more fatal, the page renders in full.

The names to avoid inside templates — because WordPress will overwrite or shadow them through setup_postdata() — include: $post, $more, $page, $pages, $numpages, $multipage, $id, and $authordata. For any secondary WP_Query, give it a clear, non-colliding name: $related_query, $secondary_query, $featured_query.

The takeaway

Two things I walked away with. First, a stable-size truncation is the tell of a fatal, not an OOM. If a page always cuts off at exactly the same byte count on every load, do not sprint to memory_limit. Drop in a gated register_shutdown_function() and read error_get_last() — it will hand you the file, line, and real message, which a silently truncated screen never will.

Second, WordPress's reserved globals are a silent time-bomb inside templates. Names like $more, $page, and $id look innocent and generic, which is exactly why you reach for them without a second thought. But setup_postdata() will overwrite them mid-loop, and your variable changes type out from under you with no warning whatsoever. Give your secondary queries specific, unique names and this entire class of bug disappears before it can ever be born.