Safeguard
Industry Analysis

SQL Injection Prevention in PHP with Parameterized Queries

Parameterized queries stop SQL injection in PHP by separating code from data. Here's how PDO and MySQLi prepared statements work, and where they still fail.

Aman Khan
AppSec Engineer
8 min read

In July 2012, attackers dumped 453,000 plaintext usernames and passwords from Yahoo Voices after finding a SQL injection flaw in the PHP-based subdomain. No exotic zero-day was involved — just a query built by concatenating user input into a string, the same pattern that still shows up in code reviews today. More than a decade later, SQL injection (CWE-89) remains a fixture of MITRE's CWE Top 25 Most Dangerous Software Weaknesses, and PHP still powers roughly three out of four websites whose server-side language is publicly known, according to W3Techs. That combination — a huge install base and a mistake that's easy to make and easy to fix — is why SQL injection prevention in PHP keeps coming up in audits, bug bounty reports, and CVE databases. Parameterized queries are the fix that actually works, but only when implemented correctly across an entire codebase, including its dependencies. This piece breaks down how they work, where they quietly fail, and how to verify they're actually in place.

What Is SQL Injection and Why Does PHP Keep Getting Hit?

SQL injection happens when untrusted input is concatenated directly into a SQL query string, letting an attacker change the query's logic instead of just supplying a value. PHP is disproportionately represented in SQL injection disclosures for a structural reason: its history of low-friction database access. The original mysql_query() API, which encouraged inline string building, shipped with PHP for over a decade before being formally removed in PHP 7.0 in December 2015 — but millions of lines of legacy code written against it are still running today on unsupported PHP 5.x installs or systems that were only partially migrated. Add to that the sheer volume of PHP in production — WordPress alone, which is built on PHP, runs on over 40% of all websites — and a small percentage of vulnerable query patterns translates into a very large number of exploitable endpoints. Patchstack's annual WordPress security reports have consistently found SQL injection among the top three vulnerability classes disclosed in plugins and themes each year, almost always traced back to raw queries built with $wpdb->query() or direct string interpolation instead of $wpdb->prepare().

How Do Parameterized Queries Actually Prevent SQL Injection?

Parameterized queries prevent injection by sending the query structure and the user-supplied values to the database as two separate channels, so the database engine never interprets input as part of the SQL syntax. In a vulnerable query like:

$result = mysqli_query($conn, "SELECT * FROM users WHERE email = '$email'");

an attacker supplying ' OR '1'='1 for $email rewrites the query's logic entirely. A parameterized version compiles the query template first, with placeholders standing in for values, and only afterward binds the actual data:

$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email");
$stmt->execute(['email' => $email]);

At this point, whatever the attacker types is treated as a literal string value to compare against the email column — it can never become part of the query's grammar, no matter how many quotes, semicolons, or UNION keywords it contains. This is why OWASP lists parameterized queries as the primary defense for the A03:2021-Injection category, which OWASP's own testing data showed present in 94% of applications assessed, with roughly 274,000 total occurrences across the dataset used to build the 2021 Top 10.

What's the Difference Between PDO and MySQLi Prepared Statements?

The difference is mostly API ergonomics, not security — both PDO and MySQLi, PHP's two supported database extensions, implement true server-side prepared statements that neutralize SQL injection identically. PDO supports both named placeholders (:email) and positional ones (?), and works across multiple database drivers (MySQL, PostgreSQL, SQLite, SQL Server) without changing the calling code:

$stmt = $pdo->prepare("SELECT * FROM orders WHERE user_id = ? AND status = ?");
$stmt->execute([$userId, $status]);

MySQLi is MySQL/MariaDB-specific and uses a bind-and-execute pattern with type specifiers:

$stmt = $mysqli->prepare("SELECT * FROM orders WHERE user_id = ? AND status = ?");
$stmt->bind_param("is", $userId, $status);
$stmt->execute();

The practical decision usually comes down to portability (PDO) versus a marginally more familiar procedural style for teams already standardized on MySQLi. What matters for security is not which extension a project chooses, but that every single query in the codebase — including the ones written by a contractor two years ago, or generated inside a plugin's admin panel — goes through one of these two paths rather than string concatenation. Mixing both across a codebase is common and fine; leaving even one raw query path in an authentication or search feature is the actual risk.

What Mistakes Still Break Parameterized Query Protection?

The most common mistake is assuming prepared statements protect everything, when in fact they only protect data values, not SQL identifiers like table names, column names, or ORDER BY directions. This code is still vulnerable even though it "uses" PDO:

$stmt = $pdo->prepare("SELECT * FROM users ORDER BY $sortColumn");

Because placeholders can't stand in for identifiers, $sortColumn must be validated against an allowlist of known-safe column names before it ever reaches the query string — there is no parameterized way to bind it. A second frequent gap is LIKE clause construction, where developers concatenate wildcard characters into the value before binding ("%$term%"), which is safe from injection but often gets combined with unsafe patterns elsewhere in the same function out of habit. A third, increasingly common source of regressions is ORM and query-builder "escape hatches": Laravel's DB::raw() and whereRaw(), or Doctrine's native SQL support, reintroduce raw string concatenation inside a framework that otherwise parameterizes everything by default, and they're frequently used precisely because a developer needed to build a dynamic identifier or complex expression the ORM's normal API didn't support. Finally, third-party Composer packages are a blind spot — a project's own code can be fully parameterized while a pulled-in dependency builds queries unsafely internally, which is exactly the pattern behind many of the plugin-level CVEs Patchstack and WPScan catalog every year.

What Do Real Incidents Show About How This Actually Fails in Production?

Real incidents show that SQL injection breaches are rarely caused by developers being unaware of parameterized queries — they're caused by one unparameterized code path surviving in an otherwise well-protected application. The 2012 Yahoo Voices breach came from a single vulnerable script serving a legacy subdomain that hadn't been rebuilt when the rest of Yahoo's authentication stack matured. WordPress plugin disclosures follow the same shape: a plugin will use $wpdb->prepare() correctly across most of its functions, then miss it in one AJAX handler or REST endpoint added later, and that single gap becomes a CVE. This is precisely why CWE-89 has stayed near the top of MITRE and CISA's CWE Top 25 list in recent years alongside cross-site scripting and out-of-bounds writes: the fix has been well understood since the early 2000s, but consistency across an entire, growing, multi-contributor codebase is an organizational problem, not a knowledge problem. A team can train every engineer on PDO and still ship a vulnerability six months later through a rushed feature, an inherited plugin, or a Composer package nobody re-reviewed after an update.

How Can Teams Verify Parameterized Queries Are Actually in Place?

Teams verify this by treating SQL injection prevention as something to continuously scan for, not something to check once during a code review. Manual review catches obvious string concatenation but misses raw queries buried inside ORM calls, dynamic identifiers assembled across multiple functions, or vulnerable patterns shipped inside a Composer dependency that was pulled in without a security review. Static analysis tools that understand PHP's data flow — tracing a value from $_GET or $_POST through to a query call — can flag CWE-89 patterns automatically on every commit, and dependency scanning can catch when a pulled-in package has a disclosed SQL injection CVE before it ever ships to production. The goal is a CI/CD gate that fails a build the same way a linter would, rather than relying on every contributor remembering to check.

How Safeguard Helps

Safeguard approaches SQL injection prevention in PHP as a software supply chain problem, not just an application security one, because in practice the vulnerability is as likely to arrive through a dependency as through first-party code. Safeguard's SAST scanning traces untrusted input through PHP data flow — request parameters, form fields, headers — and flags query construction that concatenates that input instead of using PDO or MySQLi placeholders, surfacing CWE-89 findings directly in pull requests before merge. Because parameterized queries only close half the gap, Safeguard also flags risky identifier-interpolation patterns (dynamic ORDER BY, table names, column lists) that placeholders can't cover, so teams know exactly where an allowlist is still required. On the dependency side, Safeguard's software composition analysis continuously checks Composer packages against known CVE databases, so a SQL injection disclosure in a third-party library — the same pattern behind many WordPress plugin breaches — is caught and reported as soon as it's published, rather than discovered after an incident. Combined with CI/CD policy gates that block builds on newly introduced injection risk and an SBOM that gives security teams a real inventory of what's actually running, Safeguard turns SQL injection prevention from a one-time training exercise into a continuously enforced control across both the code teams write and the packages they depend on.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.