A placeholder stands for one value, never a table name, column name, or keyword like ASC. When a sort order or column comes from the request a placeholder cannot help, and injection creeps back in. The safe answer is almost always an allow-list: map the user's choice onto a fixed set of literals your code wrote, and reject anything else.
function orderByClause(string $col, string $dir): string {
if (!in_array($col, ['price', 'title', 'stock', 'created_at'], true)) {
throw new InvalidArgumentException("column not allowed: $col");
}
return "ORDER BY `$col` " . (strtoupper($dir) === 'DESC' ? 'DESC' : 'ASC');
}
function quoteIdent(string $id): string { // last resort when a whitelist is impractical
return '`' . str_replace('`', '``', $id) . '`';
}
echo orderByClause('price', 'desc'), "\n", quoteIdent('weird`col'), "\n";
try { orderByClause('price; DROP TABLE customers', 'asc'); }
catch (InvalidArgumentException $e) { echo 'rejected: ', $e->getMessage(), "\n"; }ORDER BY `price` DESC `weird``col` rejected: column not allowed: price; DROP TABLE customers
The allow-list is the real defense (the match in Subsection 4.16.8 is the same idea). quoteIdent() shows MySQL 524 identifier quoting for the rare genuinely dynamic column: backticks, doubling any backtick inside, and still validate against information_schema first. It is not PDO::quote(), which quotes a string value, not an identifier. The direction reduces to two literals, so no user text reaches the query.