Ghost Story-4

Debug Ghosts, Part IV

December 31, 2025

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 if is skipped

  • no ORDER BY is added

  • no LIMIT :limit is 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 <= 0
AND slug exists

NO ORDER BY
NO LIMIT :limit

arguments['limit'] still bound

PDO execute()

HY093: parameter was not defined

✅ Fixed Request Flow

Browser

/menu/9/truckee

menu.php → getAll3()

sessionSorttype <= 0
slug exists

Default ORDER BY applied
LIMIT :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 DESC
                  LIMIT :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

Comments