D
P
0
← All articles Baca dalam Bahasa Indonesia

WordPress & PHP in Production

`?error=` Vanishes From `$_GET`: Never Name a Query Param After a WordPress Reserved Var

· · 5 min read
`?error=` Vanishes From `$_GET`: Never Name a Query Param After a WordPress Reserved Var

Some bugs make you doubt your own logic for hours when the logic was fine the whole time. In this case exactly one thing was wrong: the parameter name I picked already belonged to somebody else.

This happened on a membership site I was working on. Its authentication and profile request flows used the most ordinary pattern in PHP: process the form, redirect back to the originating page, append ?error=something to the URL, then render a notice based on that value. A pattern I have shipped hundreds of times.

This time the notice never appeared. Not once.

The symptom: the URL was right, the payload was not

The confusing part was that the address bar looked perfect. After a failed submit, the browser landed here:

/request-profile/?error=exists&match=x&type=company

The parameter was present, spelled correctly, and the redirect clearly worked. But the page came back blank of any feedback. No notice, no red message, nothing telling the user why their request had been rejected.

My first instinct was aimed at the wrong target entirely. I started suspecting the template, the if conditions, hook ordering, and eventually caching, because from the outside the symptom genuinely resembles a stale full page cache.

The login page was nastier. There the symptom read as a "blank refresh", and the truly misleading part was that it sometimes worked. /login/?error=captcha rendered the notice correctly the first time I tested it. A few hours later, on the same page, ?error=1 arrived with the parameter already stripped and the notice was gone again. Nondeterministic behaviour like that is the fastest way to stop trusting your own test results.

The two minute diagnosis I should have run first

After enough guessing, I stopped guessing. Instead of theorising about what reached the template, I printed it. One line, dropped into the template in question:

<!-- DBG get=<?php echo esc_html( wp_json_encode( $_GET ) ); ?> -->

I wrapped it in an HTML comment so it would not disturb the layout, reloaded that URL, and opened view-source. The output ended the entire debate at a glance:

<!-- DBG get={"match":"x","type":"company"} -->

The match key was there. The type key was there. The error key was not.

So the problem was not the notice condition, not the template, and not the cache. The parameter never reached my code at all. Every hour before that had gone into debugging an if branch whose input had been empty from the start.

The root cause: error was never mine to use

error is one of WordPress's reserved public query vars. It is not a free-form name you get to repurpose, it is part of the set that gets picked up while the request is parsed.

On this particular stack the consequence was blunt: the parameter was stripped globally before my template ever saw it. What made the diagnosis so much harder is that the stripping was not consistent across paths, which is exactly why the login page appeared to work and then quietly stopped working without a single change from me.

I was tempted to trace precisely which link in the chain removed it, but I dropped that thread because it would not change anything. Even with a name attached, the conclusion stays the same: as long as I use a reserved name, I am competing with a system that always wins.

The control case was clean. Across those same flows, the status parameter I had invented myself never went missing once. Not because it got lucky, but because nobody else had claimed that name.

The fix: give your own params a prefix

The fix is not elegant, not clever, and took ten minutes. I prefixed every parameter of my own so it could not collide with a built-in name:

// Before, colliding with a built-in query var
wp_safe_redirect( add_query_arg( 'error', 'exists', $return_url ) );
 
// After, a name that is actually mine
wp_safe_redirect( add_query_arg( 'req_error', 'exists', $return_url ) );

On the template side, only the key being read changes:

$req_error = isset( $_GET['req_error'] ) ? sanitize_key( wp_unslash( $_GET['req_error'] ) ) : '';
 
if ( 'exists' === $req_error ) {
	echo '<p class="notice notice-error">A profile with these details already exists.</p>';
}

I applied the same convention across the site: auth_error for the login flow, req_error for the request flow, and one short consistent prefix for the remaining status params. After deploying, every notice that had been mute came back, login page included. I did not have to touch a single line of notice logic, because that logic had been correct all along.

The names to never reuse

This is the part I pinned to my notes so I do not fall into it twice. For your own GET parameters in WordPress, avoid these names:

They all carry special meaning to WordPress. Some cause your value to be dropped exactly like the case above, and others are worse because they rewrite the main query or send the visitor somewhere else entirely. The rule I follow now is simple: if the parameter is mine, the name carries my prefix.

What I took away