PHP Architect logo

Want to check out an issue? Sign up to receive a special offer.

Why skipping Eloquent doesn’t mean skipping SQL injection protection

Posted by on June 17, 2026

Video version at: https://youtu.be/gAVfQhPw8Do

A few weeks ago, I posted something on LinkedIn that ruffled some feathers. Here’s what I said:

“Defaulting to the ORM for everything isn’t a best practice. It’s just the path of least resistance.”

The replies came in fast, and a good chunk of them said the same thing: “That’s unsafe! You’ll get SQL injected!”

So today I want to address that criticism head-on, because the people saying this are well-intentioned but wrong. Skipping the ORM doesn’t mean skipping safety instead, it means using prepared statements directly, which is exactly what the ORM does under the hood anyway.

Why SQL Injection Is a Real Danger

The criticism isn’t completely baseless, so let’s talk about the scary part first. SQL injection is real, and it’s still a really common vulnerability on the web. Here’s what unsafe code looks like:

$email = $_GET["email"];
$users = DB::select("SELECT * FROM users WHERE email = '$email'");

This looks like it shouldn’t be a problem, but if somebody sends a request with an email value of ' OR 1=1 --, our query becomes:

SELECT * FROM users WHERE email = '' OR 1=1 --'

Now, instead of returning a single row, you just leaked your entire users table. This is a basic example, but worse versions of this attack can drop tables, exfiltrate password hashes, and even modify records. This is why people get nervous when they see raw SQL, especially those of us who have been bitten.

However, the problem isn’t that we wrote raw SQL. The problem is that we concatenated user input directly into the query string. That’s the vulnerability, but it’s avoidable because we should never ever trust our users because they might be a bad actor.

How Prepared Statements Protect You

Prepared statements solve this by separating the query structure from the data. When you send the SQL to the database, you send it with placeholders and the values for those placeholders separately. Because the database treats those values as data and not SQL, there is no way for a malicious string to break out and become part of the query.

Here’s the safe version of what we just looked at:

$email = $_GET["email"];
$users = DB::select("SELECT * FROM users WHERE email = ?", [$email]);

That ? is a placeholder, and the $email value gets bound to it as a parameter. If somebody sends ' OR 1=1 --, the database looks for a user whose email literally equals that string and, because it’s not valid, returns an empty result.

This is exactly the same protection mechanism Eloquent uses internally, so when you write User::where("email", $email)->first(), Eloquent builds a prepared statement and binds the parameter for you. This is important because the safety isn’t coming from the ORM; it’s coming from the prepared statement underneath.

Using Prepared Statements in Laravel

Laravel gives you three main methods on the DB facade for running raw queries safely. Let’s walk through each one.

The first is DB::select(), which you use for SELECT queries that return rows:

$activeUsers = DB::select(
    "SELECT id, name, email FROM users WHERE status = ? AND created_at > ?",
    ["active", "2026-01-01"]
);

foreach ($activeUsers as $user) {
    echo $user->name . "\n";
}

The second is DB::statement(), which you use for queries that don’t return rows and don’t need an affected row count. Things like CREATE INDEX or TRUNCATE:

DB::statement("CREATE INDEX idx_users_status ON users (status)");

The third is DB::affectingStatement(), which is perfect for UPDATE and DELETE queries where you want to know how many rows changed:

$rowsUpdated = DB::affectingStatement(
    "UPDATE users SET last_login = ? WHERE id = ?",
    [now(), 1]
);

echo "Updated {$rowsUpdated} row(s)";

You can also use named bindings (my preference) if positional placeholders get confusing as they usually do:

DB::select(
    "SELECT * FROM users WHERE email = :email AND created_at > :min",
    ["email" => $email, "min" => "2026-01-01"]
);

Every one of these is a prepared statement. Every one of these is safe from SQL injection, as long as you put your variables in the bindings array and not in the query string.

We’ll have more after this word from our partners.

Doctrine’s Equivalent

If you’re working in Symfony or any Doctrine-based project, the same pattern exists. You grab the connection off the entity manager and call executeQuery() or executeStatement():

$result = $entityManager
    ->getConnection()
    ->executeQuery(
        "SELECT id, name FROM products WHERE category_id = ?",
        [$categoryId]
    );

$products = $result->fetchAllAssociative();

Same idea, same safety. The Doctrine\DBAL\Connection class wraps PDO and gives you a clean interface for parameter binding.

When to Skip the ORM

So when does it actually make sense to drop down to raw SQL?

The first is performance-heavy queries. Eloquent hydrates every row into a model object, which is fantastic for ergonomics but expensive when you’re processing fifty thousand rows in a report. A raw DB::select() returns plain stdClass objects and skips that overhead entirely.

The second is complex queries that use advanced features like window functions, recursive Common Table Expressions, and advanced grouping operations. Writing them as SQL is usually clearer and easier to maintain.

The third is bulk operations. If you need to update a million rows based on a join with another table, a single raw UPDATE statement will finish in seconds, but doing the same thing through Eloquent with chunked iteration could be an eternity.

DB::affectingStatement("
    UPDATE invoices i
    JOIN customers c ON c.id = i.customer_id
    SET i.tax_rate = ?
    WHERE c.region = ?
", [0.06, "MI"]);

That’s one query, one round trip, instead of millions.

Gotchas

As with everything that controls this much power, there are a few things to watch out for:

Never interpolate variables into the query string, even if you’re “sure” they’re safe. Consistency matters, so you should always use bindings.

You can’t bind table names or column names because they’re identifiers and not values. If you need dynamic table names, validate them against an allowlist before concatenating them, or better yet, run them through preg_replace() to only keep valid characters.

DB::select() returns an array of stdClass objects, not Eloquent models. You don’t get accessors, relationships, or toArray(). If you need those, you’ll have to use Eloquent.

Watch out for LIKE queries because the wildcard characters will need to be escaped if they’re part of user input that should be treated literally.

What You Need To Know

  1. The ORM is great, but it’s not the only safe tool. Prepared statements are safe everywhere they’re used correctly.
  2. Use DB::select(), DB::statement(), and DB::affectingStatement() with parameter bindings in Laravel.
  3. Doctrine offers the same capability through $em->getConnection()->executeQuery().
  4. Drop to raw SQL when you need performance, complex aggregates, or bulk operations.
  5. Never concatenate user input into a query string. Always bind.

 

Leave a comment

Use the form below to leave a comment:

 

Our Partners

Collaborating with industry leaders to bring you the best PHP resources and expertise