How to Prevent SQL Injection in PHP Applications Using Prepared Statements

Why SQL Injection Still Matters in 2026

Even with modern frameworks and ORMs, SQL injection remains one of the most exploited vulnerabilities on the web. Legacy PHP code, quick prototypes, and custom queries written under deadline pressure are still everywhere, and attackers know it. In this tutorial, we will look at real vulnerable PHP patterns, demonstrate how an attacker exploits them, and then refactor each one using PDO prepared statements with bound parameters.

If you only remember one thing from this article: never concatenate user input into an SQL query. Always bind it.

What Is SQL Injection? A Quick Refresher

SQL injection (SQLi) happens when untrusted input is inserted directly into an SQL statement, allowing an attacker to alter the query’s logic. Consequences include:

  • Authentication bypass
  • Reading sensitive data (users, payment info, tokens)
  • Modifying or deleting records
  • Full database takeover or remote code execution in some setups

Vulnerable PHP Patterns and Their Secure Equivalents

Below are the most common insecure patterns we still see in code reviews, paired with the secure refactor using PDO.

1. Login Form: The Classic Authentication Bypass

Vulnerable code

<?php
$username = $_POST['username'];
$password = $_POST['password'];

$sql = "SELECT * FROM users WHERE username = '$username' AND password = '$password'";
$result = mysqli_query($conn, $sql);

if (mysqli_num_rows($result) > 0) {
    // user logged in
}
?>

The attack

The attacker submits the following as the username:

admin' --

The query becomes:

SELECT * FROM users WHERE username = 'admin' --' AND password = ''

The -- comments out the password check. The attacker is logged in as admin without knowing the password.

Secure refactor with PDO

<?php
$pdo = new PDO('mysql:host=localhost;dbname=app;charset=utf8mb4', $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);

$stmt = $pdo->prepare('SELECT id, password_hash FROM users WHERE username = :username');
$stmt->execute([':username' => $_POST['username']]);
$user = $stmt->fetch();

if ($user && password_verify($_POST['password'], $user['password_hash'])) {
    // user logged in
}
?>

Notice two things: the username is bound as a parameter, and the password is verified with password_verify() against a hash, never compared directly in SQL.

2. Search Page: Data Exfiltration Through UNION

Vulnerable code

<?php
$q = $_GET['q'];
$sql = "SELECT id, title FROM products WHERE title LIKE '%$q%'";
$rows = mysqli_query($conn, $sql);
?>

The attack

?q=' UNION SELECT username, password_hash FROM users -- 

The attacker dumps user credentials directly through the public search field.

Secure refactor

<?php
$stmt = $pdo->prepare('SELECT id, title FROM products WHERE title LIKE :q');
$stmt->execute([':q' => '%' . $_GET['q'] . '%']);
$rows = $stmt->fetchAll();
?>

The wildcard characters are part of the bound value, not the query structure. The attacker cannot break out.

3. Numeric ID: The False Sense of Security

Vulnerable code

<?php
$id = $_GET['id'];
$sql = "SELECT * FROM articles WHERE id = $id";
?>

Some developers think numeric inputs are safe. They are not.

The attack

?id=1 OR 1=1
?id=1; DROP TABLE articles
?id=1 UNION SELECT 1,2,version()

Secure refactor

<?php
$stmt = $pdo->prepare('SELECT * FROM articles WHERE id = :id');
$stmt->bindValue(':id', (int) $_GET['id'], PDO::PARAM_INT);
$stmt->execute();
$article = $stmt->fetch();
?>

4. Dynamic ORDER BY and Column Names

This is the trap many tutorials skip. You cannot bind identifiers (table names, column names, ORDER BY direction) as parameters. So this is still vulnerable:

<?php
$order = $_GET['order'];
$sql = "SELECT * FROM products ORDER BY $order";
?>

Secure pattern: whitelist

<?php
$allowed = ['name', 'price', 'created_at'];
$order   = in_array($_GET['order'], $allowed, true) ? $_GET['order'] : 'name';
$dir     = ($_GET['dir'] ?? 'asc') === 'desc' ? 'DESC' : 'ASC';

$sql = "SELECT * FROM products ORDER BY {$order} {$dir}";
$rows = $pdo->query($sql)->fetchAll();
?>

PDO vs MySQLi: Which Should You Use?

Feature PDO MySQLi
Database support 12+ drivers MySQL/MariaDB only
Named placeholders Yes No (positional only)
API consistency Clean and uniform Two APIs (procedural + OO)
Recommended for new projects Yes Acceptable

For new PHP applications, PDO is the recommended choice.

The Correct PDO Setup You Should Copy

Many SQL injection bypasses on PDO rely on emulated prepares being enabled with a wrong charset. Always initialize PDO like this:

<?php
$dsn = 'mysql:host=127.0.0.1;dbname=myapp;charset=utf8mb4';

$pdo = new PDO($dsn, $user, $pass, [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES   => false,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
?>

The three options matter:

  1. charset=utf8mb4 tells the MySQL client the correct encoding so multibyte tricks cannot break out of strings.
  2. EMULATE_PREPARES = false forces real server-side prepared statements.
  3. ERRMODE_EXCEPTION turns SQL errors into exceptions so silent failures are caught.

Defense in Depth: Beyond Prepared Statements

Prepared statements are the single most effective defense, but a hardened application combines several layers:

  • Input validation: enforce types, lengths, and formats (e.g. filter_var(), ctype_digit()).
  • Whitelist identifiers: never inject column or table names from user input.
  • Least privilege DB user: the application account should not have DROP, FILE, or admin rights.
  • Disable error display in production; log instead.
  • Use a Web Application Firewall (WAF) as an extra net.
  • Static analysis: tools like Psalm, PHPStan, or commercial scanners flag risky concatenations.
  • Keep PHP and MySQL updated with the latest stable releases.

Quick Checklist Before You Push to Production

  1. Every query touching user input uses prepare() + execute().
  2. No string concatenation or interpolation inside SQL strings.
  3. Identifiers (ORDER BY, table names) are validated against a whitelist.
  4. PDO is configured with EMULATE_PREPARES = false and charset=utf8mb4.
  5. Passwords are stored with password_hash() and verified with password_verify().
  6. The DB user has only the privileges it needs.
  7. Errors are logged, not displayed.

FAQ

Which PHP function prevents SQL injection?

There is no single function. The reliable approach is to use PDO or MySQLi prepared statements with bound parameters. Functions like mysql_real_escape_string() are deprecated and not sufficient on their own.

Are prepared statements 100% safe?

For values, yes, when emulation is disabled and the correct charset is set. For identifiers (table names, column names, ORDER BY direction) you must whitelist them in PHP code.

Is PDO better than MySQLi for preventing SQL injection?

Both support prepared statements correctly. PDO is generally preferred for its cleaner API, named placeholders, and support for multiple database engines.

Does using an ORM like Eloquent or Doctrine eliminate SQL injection?

Mostly. ORMs use prepared statements under the hood, but they also expose raw query methods (DB::raw, createQuery with string concatenation). If you concatenate user input into those, you reintroduce the vulnerability.

Can I rely on input filtering alone?

No. Input validation is useful as a layer of defense, but it cannot reliably anticipate every SQL syntax trick. Always combine validation with prepared statements.

Conclusion

Preventing SQL injection in PHP comes down to one habit: treat SQL and user data as two separate things. Write your query once, with placeholders, and let the database driver handle the data. Combine that with a properly configured PDO instance, whitelisted identifiers, and least-privilege database accounts, and SQL injection becomes a closed door in your application.

Audit your codebase today: search for $_GET, $_POST, and $_REQUEST appearing inside SQL strings. Every match is a refactor waiting to happen.