Debug Ghosts, Part IV
There’s a special kind of PHP prepared statement bug that doesn’t announce itself.
No warnings.
No partial render.
Just a white screen and a fatal PDO exception.
This is the story of a PDO HY093 error that only appeared when the code looked completely correct.
image-1 image-2 image-3 <========= Click a link.
The Symptom: A Menu Link That Instantly Crashed
Clicking certain menu links caused an immediate fatal error:
Fatal error: Uncaught PDOException:SQLSTATE[HY093]: Invalid parameter number: parameter was not defined
No HTML.
No Twig output.
Just a clean abort.
The failing URL looked perfectly valid:
/menu/9/truckee
Other menu pages worked.
Admin users worked.
Logged-in members worked.
Only guest users on menu routes with a slug triggered the crash.
That combination mattered.
The Stack Trace: A Familiar Function, Nothing Exotic
The stack trace pointed to a known workhorse:
getAll3(1, true, 9, null, 300, 9)
Nothing unusual:
-
website ID
-
published stories
-
menu ID
-
guest user (
null) -
page limit
-
sort type
Yet PDO insisted a bound parameter didn’t exist.
That’s the hallmark of a classic prepared statement mismatch:
A value is bound — but its placeholder never makes it into the SQL.
The Root Cause: A Conditional That Removed :limit
At a glance, every sorting branch appeared to end with:
LIMIT :limit
But the default guest path contained a nested condition:
if ($sessionSorttype <= 0) {if (empty($parts[2])) {$sql .= " ORDER BY a.landscape, RAND()LIMIT :limit;";}}
When the URL includes a slug (/menu/9/truckee):
-
$parts[2]is not empty -
the inner
ifis skipped -
no ORDER BY is added
-
no
LIMIT :limitis added
Later, however, the code still binds:
$arguments['limit'] = 300;
PDO then does exactly what it should:
“You told me to bind :limit, but that placeholder doesn’t exist.”
💥 SQLSTATE[HY093]
Request-Flow Diagram (Bad vs Good)
This is the moment the bug becomes obvious.
❌ Broken Request Flow
Browser↓/menu/9/truckee↓menu.php → getAll3()↓sessionSorttype <= 0AND slug exists↓NO ORDER BYNO LIMIT :limit↓arguments['limit'] still bound↓PDO execute()↓HY093: parameter was not defined
✅ Fixed Request Flow
Browser↓/menu/9/truckee↓menu.php → getAll3()↓sessionSorttype <= 0slug exists↓Default ORDER BY appliedLIMIT :limit always appended↓SQL placeholders match bound params↓Query executes↓Menu page renders normally
This diagram captures the essence of the bug:
a valid execution path that silently removed a required placeholder.
The Fix: Make Missing Placeholders Impossible
The solution was surgical:
if ($sessionSorttype <= 0) {if (empty($parts[2])) {$sql .= " ORDER BY a.landscape, RAND()LIMIT :limit;";} else {$sql .= " ORDER BY a.storyorder, a.landscape DESCLIMIT :limit;";}}
No path.
No role.
No URL variation.
Every execution path that binds :limit now defines it.
Why This PDO HY093 Bug Was So Hard to See
This one hid well:
-
Triggered only for guest users
-
Only on menu routes with slugs
-
Only when sorttype was unset
-
SQL looked correct in most cases
-
Failure was caused by absence, not error
These are the most dangerous PHP prepared statement bugs —
not broken code, but conditionally incomplete code.
The Lesson: SQL Construction Is Control Flow
The takeaway I’m keeping:
If a parameter is bound, its placeholder must exist on every execution path.
Even better:
-
Treat SQL like code, not strings
-
Bind parameters only after SQL is finalized
-
Optionally strip unused parameters before execution
Because PDO doesn’t guess.
It enforces.
Debug Ghosts Aren’t Random
They’re logic paths you didn’t realize were reachable.
And once you find them,
you don’t just fix the bug —
you upgrade the way you think.
Coming Up Next in the TFOL Dev Series
-
Session-State Zombies — when PHP sessions outlive their usefulness
-
Why Controllers Shouldn’t Parse URLs — a refactor story waiting to happen
-
Defensive PDO Patterns — preventing HY093 before it happens
Posted in ghost-stories by TFOL BLOG