Blog / AI-generated code
AI-generated code · 5 min read · May 5, 2026

Why AI loves raw queries (and how to stop it)

Code displayed on a screen

Ask a code assistant for a simple Eloquent query and you get clean, parameterized Laravel. Ask it for a search box, a sortable table, or a "quick report across three tables", and the odds jump that it hands you a string with variables interpolated into SQL. This article explains why assistants drift into raw queries, exactly where injection enters a Laravel app, and the safe pattern for every case the AI claims needs raw SQL — because almost none of them do.

Why assistants reach for raw SQL

Three reasons, all structural. The training data is full of pre-framework PHP and Stack Overflow answers where string-built SQL is the norm. The query builder's "advanced" surface (subqueries, conditional clauses, fulltext) is rarer in that data, so when the builder gets awkward the statistically likely continuation is a raw string. And the assistant's success criterion is your test passing — interpolation passes tests exactly as well as bindings, so nothing in the loop pushes back. The result: raw SQL concentrated precisely in the features that take user input, which is the worst possible place for it.

Where injection actually enters a Laravel app

Laravel's query builder and Eloquent parameterize values for you — used plainly, they are not injectable. Every Laravel SQL injection we have found in an audit came through one of five doors.

1. DB::select / DB::statement with interpolated strings

$users = DB::select("SELECT * FROM users WHERE email = '{$request->email}'"); $users = DB::select('SELECT * FROM users WHERE email = ?', [$request->email]);

Anything a user influences goes in the bindings array — ? placeholders or named :email bindings. The database driver then treats it as data, never as SQL.

2. whereRaw / havingRaw with variables in the string

Order::whereRaw("total > {$request->min} AND status = '{$request->status}'")->get(); Order::whereRaw('total > ? AND status = ?', [$request->min, $request->status])->get(); // or simply: Order::where('total', '>', $request->min)->where('status', $request->status)

The raw variants all accept a bindings array as their second argument. If a *Raw call contains {$ or " ., it is a finding.

3. Column names and sort directions from the request

The subtle one. Bindings protect values — they cannot protect identifiers like column names or ASC/DESC. So this is injectable even though there are no quotes:

$orders = Order::orderByRaw("{$request->sort} {$request->direction}")->get();

Identifiers need an allow-list. Validate against the columns you actually intend to expose:

$validated = $request->validate([ 'sort' => ['sometimes', Rule::in(['created_at', 'total', 'status'])], 'direction' => ['sometimes', Rule::in(['asc', 'desc'])], ]); $orders = Order::orderBy( $validated['sort'] ?? 'created_at', $validated['direction'] ?? 'desc' )->get();

The same rule covers dynamic where columns, groupBy from the request, and table names in reports: user input never names a database object; it only selects from a list you wrote.

4. selectRaw / DB::raw fragments built from input

DB::raw() with a constant string — DB::raw('count(*) as total') — is perfectly fine. The moment request data is concatenated into the fragment, you have hand-rolled SQL injection inside an otherwise safe builder chain. Treat DB::raw( followed by anything dynamic as a red flag in review.

5. LIKE searches and wildcard input

User::where('name', 'like', "%{$q}%")->get();

This one is not SQL injection — the value is still bound if you write it as above. The problem is that % and _ in user input act as wildcards: a search for %%% matches everything (filter bypass), and pathological patterns can hammer the database. Escape the wildcards before binding:

$q = addcslashes($request->q, '%_\\'); User::where('name', 'like', "%{$q}%")->get();

"But Eloquent escapes everything, right?"

The accurate version: Eloquent and the query builder bind values when you use their structured methods. They do not — cannot — sanitize SQL fragments you hand them as strings, and they cannot parameterize identifiers, because prepared statements don't work that way. The mental model that survives an audit is: values → bindings; identifiers → allow-lists; fragments → constants only.

The "complex" cases, done without raw strings

Assistants justify raw SQL with "the query was too complex for the builder". The builder covers more than the training data remembers:

  • Conditional filters: ->when($request->status, fn ($q, $s) => $q->where('status', $s)) instead of concatenating WHERE clauses.
  • Subqueries: ->addSelect(['latest_order_at' => Order::select('created_at')->whereColumn('user_id', 'users.id')->latest()->limit(1)]).
  • Aggregates of relations: ->withSum('orders', 'total'), ->withCount('tickets').
  • Full-text search: ->whereFullText('body', $q) on MySQL/Postgres instead of hand-built MATCH … AGAINST strings.

Genuinely gnarly reporting SQL is allowed to be raw — written by you, with every value in bindings, ideally in a dedicated query class where it can be reviewed as SQL rather than hiding in a controller.

Keeping it out for good

Three cheap controls stop regressions, including the ones your assistant writes next month:

# 1. CI tripwire — fail the build on new raw-with-interpolation patterns grep -rnE 'whereRaw\(.*\$|orderByRaw\(.*\$|selectRaw\(.*\$|DB::raw\(.*\$' app && exit 1 # 2. Static analysis — Larastan + a disallowed-calls rule for the *Raw methods composer require --dev larastan/larastan spaze/phpstan-disallowed-calls

And third: put the rule in the assistant's own context. A line in your CLAUDE.md / .cursorrules"Never interpolate variables into SQL. Use query builder methods or bindings; sort columns come from an allow-list." — measurably changes what gets generated. The assistant that caused the pattern will happily follow the rule once it is written down.

Audit what's already there

grep -rnE "DB::raw|whereRaw|orderByRaw|selectRaw|havingRaw|DB::select|DB::statement" app \ | grep -vE "test|Test"

For each hit: constant string → fine; variables in bindings → fine; anything interpolated or concatenated → rewrite using the patterns above. It is mechanical work, which also makes it easy to delegate — checking every raw-SQL sink is a fixed line item in our AI-code security audit, alongside the other six flaws assistants repeat.

FAQ

Is DB::raw always a vulnerability?

No. DB::raw('count(*) as total') with a constant string is safe and sometimes necessary. The vulnerability is interpolating or concatenating anything user-influenced into the raw fragment. Review the string's construction, not the method name alone.

Can't I just sanitize the input instead of using bindings?

Escaping-by-hand is how PHP got its reputation. Bindings are not "another sanitizer" — they separate code from data at the protocol level, so there is nothing to get wrong. Use sanitization only for the cases bindings can't cover (identifiers → allow-lists, LIKE wildcards → escaping).

We already use Eloquent everywhere. Are we safe?

From injection via values, largely yes. Check the two residual doors: identifiers (sort/filter columns from the request) and any *Raw calls that crept in for "performance" or "complex" queries — that is where we find injectable code in Eloquent-only apps.

Find every injection path before an attacker does.

Every raw-SQL sink checked, every finding with a fix. Fixed price, one week.