# SQL Injection in Laravel: Where Eloquent Protects You and Where It Doesn't

> Eloquent and the Query Builder bind your values through PDO prepared statements, so a plain where() is safe. The trouble starts with whereRaw, dynamic orderBy, and LIKE wildcards. Here is exactly where the gaps are.

**Author:** Matt King | **Published:** June 9, 2026 | **Category:** Security

---

Your Laravel app uses Eloquent everywhere. No hand-written SQL. You assume SQL injection is somebody else's problem, the kind of thing that only happens in legacy PHP from 2009. Mostly, you are right. And then someone adds a sort dropdown.

Eloquent does protect you. The catch is that the protection is narrower than most developers think, and Laravel hands you half a dozen methods that punch straight through it. Let me show you exactly where the wall ends.

---

## Why `where('email', $email)` is actually safe

When you write this:

```php
// app/Http/Controllers/UserController.php
$user = User::where('email', $request->input('email'))->first();
```

Laravel does not concatenate the email into a SQL string. It compiles the query into a parameterised statement and sends two things to the database separately: the SQL text with a `?` placeholder, and the value as a bound parameter.

The query that reaches MySQL looks like this:

```sql
select * from `users` where `email` = ? limit 1
```

The value travels on a different channel. PDO uses prepared statements under the hood, so the database parses the SQL structure first, then slots the data into the placeholder. A value of `' OR 1=1 --` is just a string to compare against. It is never parsed as SQL. That is the entire reason Eloquent and the Query Builder are safe by default.

This holds for almost every standard method. `where`, `orWhere`, `whereIn`, `whereBetween`, `update`, `insert`, `having`, `value`, the lot. They all bind values. You can pass the rawest, nastiest user input you like into the *value* position and nothing happens. Test it yourself: enable the query log and dump the bindings.

```php
// routes/web.php — quick proof
\DB::enableQueryLog();
User::where('name', "Robert'); DROP TABLE students;--")->get();
dd(\DB::getQueryLog());
// bindings array contains the string verbatim; the SQL has a ? placeholder
```

The drop never executes. The whole string sits in the bindings array as a literal. So far, so good.

---

## The danger zone: raw methods

Laravel gives you escape hatches for when the fluent builder can't express what you need. Every one of them is a potential injection point if you build the string with user input.

Here is the full list to keep in your head:

| Method | Binds values? | Risk with interpolation |
|---|---|---|
| `where()`, `whereIn()` | Yes | Safe |
| `whereRaw()` | Only if you use `?` | High |
| `selectRaw()` | Only if you use `?` | High |
| `orderByRaw()` | Only if you use `?` | High |
| `havingRaw()` | Only if you use `?` | High |
| `DB::raw()` | No | High |
| `DB::statement()` | Only if you pass bindings | High |
| `DB::select()` | Only if you pass bindings | High |

The pattern that kills you is string interpolation. Look at this:

```php
// app/Http/Controllers/ReportController.php — VULNERABLE
$age = $request->input('min_age');

$users = DB::table('users')
    ->whereRaw("age > $age")
    ->get();
```

That `$age` lands directly in the SQL string. An attacker sends `min_age=0 UNION SELECT password FROM users` and your "minimum age" filter is now exfiltrating credentials. `whereRaw` did not sanitise anything. It never claimed to.

The fix is to use the binding mechanism that `whereRaw` actually supports. The second argument is an array of bindings, and `?` placeholders pull from it:

```php
// app/Http/Controllers/ReportController.php — FIXED
$age = $request->input('min_age');

$users = DB::table('users')
    ->whereRaw('age > ?', [$age])
    ->get();
```

Better still, you usually don't need raw at all. This is the same query with zero raw SQL:

```php
// app/Http/Controllers/ReportController.php — BEST
$users = DB::table('users')
    ->where('age', '>', $request->input('min_age'))
    ->get();
```

The rule for every raw method is identical. Never interpolate. Always pass values through the bindings array. `selectRaw('count(*) as total, ? as label', [$label])`, `havingRaw('total > ?', [$min])`, same shape every time. If you find a `"$variable"` inside any raw call, you have found a bug.

`DB::select` and `DB::statement` work the same way. The clean version:

```php
// app/Services/Reporting.php
DB::select('select * from orders where status = ? and total > ?', [
    $status,
    $minTotal,
]);
```

The version that gets you a CVE-shaped incident:

```php
// app/Services/Reporting.php — VULNERABLE, do not ship
DB::select("select * from orders where status = '$status'");
```

If you want a refresher on how SQL injection sits inside the wider threat model, the [OWASP Top 10 for Laravel](/blog/owasp-top-10-laravel) walks through where injection ranks and how it chains with other flaws.

---

## Bindings protect values, not column names

This is the trap that catches careful developers. People learn "use bindings and you're safe," then build a sortable table and reintroduce injection without writing a single raw query.

Here is the classic:

```php
// app/Http/Controllers/ProductController.php — VULNERABLE
$products = Product::query()
    ->orderBy($request->input('sort'), $request->input('dir'))
    ->paginate(20);
```

No `whereRaw`. No `DB::raw`. Looks tidy. It is still injectable, because **column names and sort directions are identifiers, and identifiers are never bound.** Laravel cannot use a `?` placeholder for a column name — SQL doesn't allow that. So `orderBy` drops the value into the query structure.

An attacker passing a crafted `sort` value can use a subquery to leak data one boolean at a time. Blind injection through an ORDER BY clause is a well-documented technique, and it does not care that the rest of your app uses Eloquent perfectly.

The only real defence for an identifier is an allowlist. Decide which columns are sortable and refuse everything else:

```php
// app/Http/Controllers/ProductController.php — FIXED
$sortable = ['name', 'price', 'created_at'];

$sort = $request->input('sort', 'created_at');
$sort = in_array($sort, $sortable, true) ? $sort : 'created_at';

$dir = $request->input('dir') === 'asc' ? 'asc' : 'desc';

$products = Product::query()
    ->orderBy($sort, $dir)
    ->paginate(20);
```

Note the direction is also locked to two literal strings, never passed through from the request. The same logic applies to any place where user input picks a column: dynamic `where` columns, `groupBy`, `pluck` on a chosen field, `whereColumn`. If a request value becomes an identifier, allowlist it. There is no binding that will save you.

This bleeds into access control too. Letting a user sort by `salary` might be an authorisation problem as much as an injection one, which is why [authorization gates and policies](/blog/laravel-authorization-gates-policies) belong in the same review pass as your query layer.

---

## LIKE queries and the wildcard problem

LIKE searches have two separate issues, and people conflate them.

The first is plain SQL injection, and binding solves it completely:

```php
// app/Http/Controllers/SearchController.php
$term = $request->input('q');

$results = User::where('name', 'like', "%{$term}%")->get();
```

The `"%{$term}%"` here is a PHP string being passed as a *value*, so it gets bound. No SQL injection. Good.

The second issue is subtler. The `%` and `_` characters are LIKE wildcards. If a user includes them in their search, they change what the query matches. A search for `100%` matches everything starting with `100`, and a lone `%` matches every row. That is not classic injection, but it can leak data and hammer your database with full table scans on large tables.

Escape the wildcards before building the pattern:

```php
// app/Http/Controllers/SearchController.php — wildcard-safe
$term = $request->input('q');
$escaped = addcslashes($term, '%_\\');

$results = User::where('name', 'like', "%{$escaped}%")->get();
```

On MySQL the default LIKE escape character is the backslash, which is why we escape `\` as well. If you set a custom escape character, declare it with `like ... escape '!'` semantics accordingly. The point: bind the value to stop injection, escape the wildcards to keep the search honest.

---

## Dynamic tables, columns, and JSON paths

The identifier rule scales to everything the user might influence. A multi-tenant report that picks a table from input:

```php
// app/Services/TenantReport.php — VULNERABLE
$table = $request->input('dataset');
$rows = DB::table($table)->get();
```

Allowlist it against the tables you actually expose:

```php
// app/Services/TenantReport.php — FIXED
$allowed = ['orders', 'invoices', 'shipments'];
$table = $request->input('dataset');

abort_unless(in_array($table, $allowed, true), 404);

$rows = DB::table($table)->get();
```

JSON column paths are another quiet one. Laravel supports arrow syntax like `where('meta->settings->theme', $value)`. The value is bound, but the *path* (`settings->theme`) is part of the compiled SQL. If you let a user supply the path string, you are back in identifier-injection territory. Validate the path against a known set of keys, or restrict it to a strict pattern of word characters and arrows before it ever reaches the query.

---

## Why `composer audit` won't catch this

`composer audit` is genuinely useful, and you should run it in CI. But understand what it does: it compares your installed packages against published security advisories. It scans your *dependencies*. It does not read your controllers.

A `whereRaw("status = '$status'")` in your own `ReportController` is completely invisible to `composer audit`. The tool has no opinion on the SQL you write. It will return a clean bill of health while your hand-rolled query bleeds.

To find injection in your own code you need three things.

- **Static analysis.** Larastan (the Laravel extension for PHPStan) understands Eloquent and the Query Builder. While it won't flag every interpolated raw string out of the box, running at a high level surfaces the type-confusion and unvalidated-input patterns that usually accompany SQLi, and you can add custom rules to catch raw query usage.
- **Targeted code review.** Grep your codebase for the raw methods and read every hit. `grep -rn "whereRaw\|selectRaw\|orderByRaw\|havingRaw\|DB::raw\|DB::statement\|DB::select" app/` takes thirty seconds and gives you the exact list of lines to audit. Any of them with a `$` inside a double-quoted string is a candidate.
- **A scanner that knows Laravel.** Generic SAST tools throw noise. A Laravel-aware scanner can tell the difference between a bound `whereRaw('x = ?', [$y])` and an interpolated one, which is the distinction that actually matters.

If you want a structured way to work through the whole query layer, the [Laravel security audit guide](/blog/laravel-security-audit-guide) lays out the order to check things in.

---

## What to do tomorrow

The single most important action: grep for every raw query method in your codebase and read each call site. Replace any interpolated value with a `?` placeholder and a binding, and replace any user-supplied column, table, direction, or JSON path with an allowlist. Those two changes close almost every Laravel SQLi gap that exists in practice.

If you want a fast inventory of where the raw queries and dynamic identifiers live before you start, [run a free StackShield scan](/free-scan) and it'll flag the patterns that dependency audits skip right over. From there it's a short checklist, and the broader [Laravel security checklist for 2026](/blog/laravel-security-checklist-2026) covers the rest of the surface.

---

## Frequently Asked Questions

### Is Eloquent safe from SQL injection?

Eloquent is safe for the values you pass to standard methods like where(), update(), and insert(), because those values become bound parameters in a PDO prepared statement. The SQL text and the data travel to the database separately, so user input can never change the query structure. The protection breaks the moment you build SQL strings yourself with whereRaw, selectRaw, DB::statement, or string interpolation. It also does not cover column names, table names, or sort directions, which are never bound. Treat Eloquent as safe for values and unsafe for identifiers.

### Does whereRaw prevent SQL injection in Laravel?

whereRaw does not sanitise anything on its own. If you interpolate a variable directly into the string, like whereRaw("age > $age"), that input goes straight into the query and is fully injectable. whereRaw becomes safe only when you use placeholders and pass the values as the second argument: whereRaw('age > ?', [$age]). The placeholders are bound through PDO exactly like a normal where(). The same rule applies to selectRaw, havingRaw, and orderByRaw.

### Can you get SQL injection through orderBy in Laravel?

Yes, if you pass user input as the column or direction. Parameter binding only protects values, not identifiers such as column and table names. So orderBy($request->input('sort')) lets an attacker control part of the SQL structure, and orderByRaw with interpolated input is worse. The fix is an allowlist: map the incoming value against a fixed set of permitted columns and reject anything else. Never pass a raw request value as a column name or sort direction.

### How do I safely use LIKE queries with user input in Laravel?

Binding the value stops SQL injection, so where('name', 'like', $term) is not injectable. The remaining issue is that the LIKE wildcards % and _ inside the user input are still treated as wildcards, which is a logic and information-disclosure problem rather than classic injection. Escape those characters before building the pattern, using addcslashes($term, '%_\\') or Laravel's Str helper, then wrap the escaped term in your own % markers. That keeps a search for "100%" from matching everything.

### Will composer audit catch SQL injection in my code?

No. composer audit checks your installed packages against known security advisories, so it only finds vulnerabilities in third-party dependencies, not in the queries you write. A whereRaw with interpolated input in your own controller is invisible to it. To catch SQLi in your code you need static analysis like Larastan or PHPStan, focused code review of every raw query method, and ideally a scanner that understands Laravel's query patterns. A free StackShield scan flags the raw query and dynamic identifier patterns that dependency tools miss.

