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.
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.
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.
DB::select / DB::statement with interpolated stringsAnything a user influences goes in the bindings array — ? placeholders or named :email bindings. The database driver then treats it as data, never as SQL.
whereRaw / havingRaw with variables in the stringThe raw variants all accept a bindings array as their second argument. If a *Raw call contains {$ or " ., it is a finding.
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:
Identifiers need an allow-list. Validate against the columns you actually intend to expose:
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.
selectRaw / DB::raw fragments built from inputDB::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.
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:
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.
Assistants justify raw SQL with "the query was too complex for the builder". The builder covers more than the training data remembers:
->when($request->status, fn ($q, $s) => $q->where('status', $s)) instead of concatenating WHERE clauses.->addSelect(['latest_order_at' => Order::select('created_at')->whereColumn('user_id', 'users.id')->latest()->limit(1)]).->withSum('orders', 'total'), ->withCount('tickets').->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.
Three cheap controls stop regressions, including the ones your assistant writes next month:
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.
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.
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.
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).
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.
Every raw-SQL sink checked, every finding with a fix. Fixed price, one week.