· 2 min read · Security, PHP, WordPress, Laravel, MySQL

Preventing SQL injection in PHP, WordPress and Laravel

How SQL injection works and how to stop it in PHP: prepared statements with PDO, $wpdb->prepare in WordPress, Laravel's query builder, and the places they don't protect you.

SQL injection has been understood for over twenty years, and it still shows up in real code, especially in older PHP and WordPress projects. I've had to remediate it in a legacy WordPress stack at Next Generation Solutions. The fix is well known. The hard part is finding every place it's needed.

How the attack works

The vulnerable pattern is building SQL by joining strings with user input:

$id = $_GET['id'];
$result = $db->query("SELECT * FROM orders WHERE id = $id");

If someone visits ?id=1 OR 1=1, the query becomes SELECT * FROM orders WHERE id = 1 OR 1=1 and returns every order. With a little more effort, an attacker can read other tables, including password hashes, or change data. The database can't tell which part of the string was your code and which part was their input.

The fix: prepared statements

A prepared statement sends the SQL and the values separately. The database parses the query first, with placeholders, and only then plugs in the values as data. Nothing in the value can change the structure of the query.

$stmt = $pdo->prepare('SELECT * FROM orders WHERE id = :id AND user_id = :user');
$stmt->execute(['id' => $_GET['id'], 'user' => $currentUserId]);
$order = $stmt->fetch();

With PDO, also set PDO::ATTR_EMULATE_PREPARES to false so the driver uses real server-side prepares, and PDO::ATTR_ERRMODE to exceptions so failures aren't silent.

In WordPress: $wpdb->prepare

Plugins and themes that query the database directly should always go through $wpdb->prepare:

global $wpdb;

$rows = $wpdb->get_results(
    $wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}bookings WHERE email = %s AND status = %d",
        $email,
        $status
    )
);

When auditing an old WordPress site, I search the theme and custom plugins for $wpdb->query, get_results, get_row and get_var, and check that every one with a variable in it is wrapped in prepare. Outdated third-party plugins are the other big source, which is why keeping them updated matters so much.

In Laravel: safe by default, with exceptions

Eloquent and the query builder bind values for you, so where('email', $email) is safe. The risk comes back with the raw methods:

// Unsafe: input is joined into the SQL
User::whereRaw("email = '$email'")->first();

// Safe: input is bound
User::whereRaw('email = ?', [$email])->first();

The same applies to selectRaw, orderByRaw, havingRaw and DB::statement.

What placeholders can't protect

Placeholders only work for values. Column names, table names and sort directions can't be bound. If a user can choose how a table is sorted, check their choice against a fixed list:

$allowed = ['created_at', 'total', 'status'];
$column = in_array($request->sort, $allowed, true) ? $request->sort : 'created_at';
$direction = $request->dir === 'asc' ? 'asc' : 'desc';

Order::orderBy($column, $direction)->paginate();

Limit the damage if something slips through

  • Give the application's database user only the permissions it needs. A web app rarely needs DROP or GRANT.
  • Never show raw database errors to visitors. They tell an attacker exactly what to try next.
  • Keep backups you've actually tested restoring.
  • Put a web application firewall in front of older sites while you fix the code.

Checklist

  • Never join user input into SQL strings
  • Use prepared statements: PDO, $wpdb->prepare, or Laravel bindings
  • Audit every raw query method, not just the obvious ones
  • Allow-list column names and sort directions
  • Run the database user with the least privilege it needs
  • Hide database errors from the public

Worried about an older PHP or WordPress site? I can review it.

Case studyNext Generation Solutions: A corporate site and the infrastructure underneath it

Keep reading