D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

Pagination Page 2 Renders Page 1? `get_query_var('paged', $default)` Never Uses Its Default

· · 8 min read
Pagination Page 2 Renders Page 1? `get_query_var('paged', $default)` Never Uses Its Default

The OLDER link on that article archive had never rendered, not once. The archive held 14 articles while its query fetched 24 posts per page, so every article fit on page one and the navigation to the next page was never drawn. No visitor could click that link, and for the same reason nobody could see that behind it the pagination was broken in three separate ways.

Nobody asked for the fix. What the client wanted on that page was a menu change: the topic dropdown replaced by real href links, then a second dropdown added for location. That menu work shipped on 21 September, and the pagination fix rode along in the same push as my own find.

One of the three defects had even been written down in the repo already. Since 13 August, another template in the same theme had carried a comment explaining why it deliberately did not copy the ?pn= pagination from this archive: get_query_var('paged', X) returns 0 on a page request, never X, so the OLDER link re-serves page one. The archive template itself kept the same line from the day it first entered the repo on 27 July until the fix commit on 21 September removed it.

get_query_var('paged', $default) never reaches its default

The first version of the archive template read its page number like this. Variable and parameter names in every snippet in this article are anonymized.

$paged = max(1, (int) get_query_var('paged', isset($_GET['pn']) ? absint($_GET['pn']) : 1));

That line asks WordPress for paged, and the ?pn= value from the URL only serves as the default in the second argument. The default is never reached. WordPress fills paged with an empty string, so a ?pn=2 request renders page 1.

The reason sits in the contract of get_query_var(). It hands the default to WP_Query::get(), which only uses the default when the key is missing from query_vars or holds null. The paged key is always there. When the main query is parsed, fill_query_vars() fills a list of public query vars, paged included, with an empty string when they are not set, and parse_query() then casts it to an integer. My notes say empty string and that comment says 0, and both are right at different stages. For the line above the result is the same either way, since (int) turns both into 0 and max(1, 0) gives 1.

A taxonomy archive template in the same theme uses a different pattern, WordPress core rewrite paging (/page/N/) with a fallback of get_query_var('paged') ?: 1 and no default argument. The ?: operator catches both an empty string and 0 because both are falsy. According to my notes, the get_query_var('paged', $default) idiom only existed in the archive template.

The fix stops asking WordPress and reads its own parameter, the same way the news feed template in that theme reads its page number straight from $_GET because it has no rewrite of its own:

$paged = isset($_GET['pn']) ? max(1, absint($_GET['pn'])) : 1;

The first version built the OLDER link like this:

<a href="<?php echo esc_url(add_query_arg('pn', $paged + 1, get_permalink())); ?>">OLDER &rarr;</a>

The link called get_permalink() after the loop and before wp_reset_postdata(). Without an argument, get_permalink() reads the global post, and the loop above it had left the global post on its last card. The result was an article URL rather than the archive URL, so OLDER pointed at the last card's article plus ?pn=2. That 2 came from the first defect: the page number always read as 1, so $paged + 1 was always 2.

This differs from the $more global bug I wrote about before, where my variable name was overwritten by setup_postdata(). Nothing was overwritten here. get_permalink() did what its contract says by reading the current post, and once the loop has finished, the current post really is the last card.

The third defect was simpler. The archive's filter is a form with a search input, a native topic select that submits on change, and a FILTER button, while the pagination links dropped both of its parameters. The next page of a filtered result would lose its filter.

The fix for both sits in one place, before the loop:

// This page's own URL plus whatever filter is active: the base for the
// pagination links and for the redirect below. Taken here, before the loop
// moves the global post. add_query_arg() does not encode values.
$page_url  = get_permalink(get_queried_object_id());
$page_args = [];
if ($q !== '')     $page_args['q']     = rawurlencode($q);
if ($topic !== '') $page_args['topic'] = $topic;

The pagination links now build on $page_url instead of get_permalink(), and the search keyword goes through rawurlencode() first because add_query_arg() does not encode values.

Forcing a navigation that never renders

A navigation that never renders cannot be tested by opening the page. So I rendered the template from the CLI with a pre_get_posts hook forcing 3 posts per page.

Before the fix, a pn=2 request produced an OLDER link to /<section>/<last-card-article>/?pn=2, with the position reading "1 / 5". Page two was requested and page one was served. The 5 matches the arithmetic: 14 articles at 3 per page make four full pages holding 12 articles, plus one page holding 2. After the fix, the same render produced pages of 3, 3, 3, 3, and 2 cards, with the right first card and the filters carried on both links. That adds up to 14, the size of the archive.

The CLI render itself nearly created a new problem. On this site the WP Rocket page cache lives on the origin as files at wp-content/cache/wp-rocket/<domain>/<path>/index-https.html and serves anonymous visitors only. One of my renders ran with an empty query string. WP Rocket's advanced-cache answered it from the existing cache file and exited, and that is the only reason the forced 3-card page was not written into the page cache. The rule for CLI renders from then on: always pass a query string and define('DONOTCACHEPAGE', true) before wp-load, then check the card count in the cache file.

Out-of-range pages answer with a 302

Before that push, a ?pn= past the last page rendered the empty state. My decision for that case: send it back to page one.

if ($paged > 1 && $paged > (int) $query->max_num_pages) {
    nocache_headers();
    wp_safe_redirect(add_query_arg($page_args, $page_url), 302);
    exit;
}

The redirect is a 302 and not a 301 because that page number becomes real once more articles publish. A cached redirect would keep bouncing a page 2 that later becomes legitimate, so I measured how the edge treats it. Cloudflare answered BYPASS on that 302 across three GETs in a row, even though this zone edge-caches query-string HTML: the same probe on a 200 response went MISS then HIT with max-age=7200. The zone honors the origin's no-cache headers on this 302, so a stale redirect cannot pin page 2 later. A follow-up note from 5 October limits that conclusion. A 404 for a missing file under wp-content/plugins is still stored despite no-cache, seen on 14 URLs in one folder, so this is not a general rule.

pn=0, pn=abc, pn=1, and a search with no results still answer 200. For that last case, the $paged > 1 half is what holds it back. When a query returns no posts at all, WP_Query never computes found_posts or max_num_pages, and both stay 0. A comment in the news feed template records this for a page past the end: page 16 gave 0 posts, found_posts 0, and max_num_pages 0, against a real total of 349. A search with no results on page one also has max_num_pages at 0, and without the $paged > 1 half that page would redirect to itself.

A 500 from ?topic[]= caught before deploy

Before deploy, the change went through an adversarial review with four lenses. The new code came back clean, including a search parameter chain that round-tripped 23 hostile inputs with no double encoding and no open redirect. The same review found a 500 already live in production and confirmed at origin: ?topic[]=x. Under PHP 8, sanitize_title() throws a TypeError on an array, because the value is handed to remove_accents() and then to preg_match(). sanitize_text_field() and absint() do not throw, which is why q[] and pn[] were fine.

// is_string() because sanitize_title() throws on an array: ?topic[]=x was a 500.
$topic = (isset($_GET['topic']) && is_string($_GET['topic'])) ? sanitize_title(wp_unslash($_GET['topic'])) : '';

The whole theme was grepped, and there is no other unguarded sanitize_title($_GET...). The guard went in as its own commit so it can be reverted alone. My notes only record the 500 from before the fix, and no measurement after the guard went live was recorded.

A default only covers a missing key

The default argument of get_query_var() only works for a key that truly does not exist, and paged always exists. When the page number comes from a parameter of your own, read it straight from $_GET. When it comes from WordPress, catch 0 and the empty string with ?:, not with the second argument.

What deserves more attention is how this bug survived. Three defects hid behind one link that never rendered, and one of them had already been explained in a comment in a neighboring template. Forcing a small page size from the CLI made that link checkable, but a CLI render can still be answered by WP Rocket's advanced-cache, as the one with an empty query string was. Proof of this kind needs a query string, DONOTCACHEPAGE, and a check of the cache file afterwards.