SQL injection in 2026 should be a solved problem, and across most of a codebase it is. Eloquent, Doctrine, and every other modern ORM bind their parameters by default, so the classic login-form bypass died years ago in well-built apps. Yet I still find live SQLi during audits, and it is almost never in the ORM-driven part of the code. It hides in the gaps the ORM does not cover: a whereRaw with a concatenated filter, a dynamic ORDER BY column built from a query-string parameter, a LIKE search with unescaped wildcards, a stray PDO query someone wrote by hand under deadline. The root cause has not moved in decades: the query is built by gluing strings together instead of parameterizing values. Neither has the fix. Bind every value, and for anything you cannot bind (a column name, a sort direction, a table) validate it against an allow-list. Never interpolate it.
If ORMs bind everything, where does SQL injection still come from?
An ORM protects you exactly as far as you let it drive the query. The moment you reach past it to write raw SQL, or to hand it a fragment it treats as trusted, the guardrails are gone. In Laravel specifically, here are the five places I keep finding it.
- whereRaw / havingRaw with concatenation: DB::table('users')->whereRaw("email = '$email'") drops the input straight into the SQL string. The raw helpers take a bindings array as a second argument precisely so you never have to do this.
- selectRaw and DB::raw with user input: anything wrapped in DB::raw() is treated as a trusted literal expression. Concatenate a request value into it and you have handed the attacker the SELECT clause.
- Dynamic ORDER BY column names: you cannot bind an identifier. $query->orderBy($request->sort) feels harmless, but Laravel concatenates the column name into the SQL (it only validates the direction argument), so a crafted ?sort= value lands in the query.
- LIKE with unescaped wildcards: even with a binding, where('name','like',$term.'%') still lets a user inject % and _ as wildcards. It is rarely full SQLi, but it enables data-scraping and denial-of-service through wildcard-heavy scans.
- Raw PDO without bindings: a hand-rolled $pdo->query("... $id ...") inside a service class or an old artisan command, written to dodge the query builder, is the single most common place I find textbook injection.
The pattern across all five is identical. Untrusted input becomes part of the SQL text rather than a bound parameter. Spot that shape and you have found the bug, regardless of framework version.
What does a vulnerable Laravel query actually look like?
Here is a search-and-sort endpoint I have seen, lightly anonymized, in more than one production codebase. It looks reasonable. It even uses the query builder, which is enough to make a reviewer skim past it. Both the whereRaw filter and the orderBy column are attacker-controlled, and neither is bound.
public function search(Request $request)
{
$term = $request->query('q');
$sort = $request->query('sort', 'created_at');
$dir = $request->query('dir', 'desc');
// whereRaw with a concatenated value -> classic injection
$products = DB::table('products')
->whereRaw("name LIKE '%" . $term . "%'")
// orderBy concatenates the column name straight into raw SQL
->orderBy($sort, $dir)
->get();
return response()->json($products);
}
// Request: /search?q=x&sort=(SELECT CASE WHEN (1=1)
// THEN name ELSE id END)&dir=desc
// The sort column is now executing SQL the attacker wrote.The whereRaw is the obvious hole: ?q=' OR '1'='1 escapes the string literal immediately. The orderBy is the subtle one. People assume orderBy is safe because it is a builder method, and Laravel does validate the direction argument (it rejects anything that is not asc or desc), but it cannot bind an identifier, so the column name is concatenated into the SQL untouched. A boolean-based blind injection through that sort column will exfiltrate the database one character at a time, and it will not show up in any LIKE-focused test.
How do I fix it: bindings plus an allow-list
There are two distinct problems here and they need two distinct tools. The search term is a value, so it gets a bound parameter. The sort column and direction are identifiers, which cannot be bound, so they are validated against an allow-list of known-good values and anything else is rejected. The wildcard issue is handled by escaping the LIKE metacharacters before binding. Here is the same endpoint, fixed.
public function search(Request $request)
{
$term = (string) $request->query('q', '');
// Allow-list the identifier: only these columns may be sorted on.
$sortable = ['name', 'price', 'created_at'];
$sort = in_array($request->query('sort'), $sortable, true)
? $request->query('sort')
: 'created_at';
// Allow-list the direction too -- collapse to a known-good value.
$dir = $request->query('dir') === 'asc' ? 'asc' : 'desc';
// Escape LIKE wildcards, then BIND the value as a parameter.
$escaped = addcslashes($term, '%_\\');
$products = DB::table('products')
->where('name', 'like', '%' . $escaped . '%')
->orderBy($sort, $dir) // both values came from an allow-list
->get();
return response()->json($products);
}Three things changed and each maps to one of the failures above. The LIKE filter now uses the standard where('name','like', ...) form, which binds the value, and addcslashes escapes %, _, and the backslash escape character itself so they match literally instead of as wildcards. The sort column is checked against $sortable with a strict in_array comparison, so an unknown column falls back to a safe default rather than reaching the SQL. The direction is collapsed to exactly asc or desc. After this, $sort and $dir can only ever hold values I chose, so it no longer matters that orderBy concatenates the column. That is the whole point of an allow-list: you are not sanitizing attacker input, you are refusing to use anything that is not on a list you control.
You cannot bind a table name, a column name, or a sort direction. The instant you need a dynamic identifier, stop reaching for an escape function and reach for an allow-list instead.
If they still get a query through, how do you limit the damage?
Parameterization is the fix; everything else is blast-radius reduction for the day a fix is missed. Defense in depth here is not optional, because one missed whereRaw should not equal a full database dump.
- Run the app on a least-privilege database user. The account your web app connects with does not need DROP, ALTER, GRANT, or FILE. Give it SELECT/INSERT/UPDATE/DELETE on its own schema and nothing else, so a successful injection cannot escalate to dropping tables or reading the filesystem.
- Validate input at the edge with Laravel form-request validation. A sort parameter declared as Rule::in(['name','price','created_at']) never reaches your controller if it is malformed, so the request rule and the controller allow-list reinforce each other.
- Never return raw database errors to the client. Set APP_DEBUG=false in production so a SQL error becomes a generic 500 instead of leaking your query structure, table names, and column types straight into an attacker's error-based workflow.
- Audit raw query usage in CI. Grep the codebase for whereRaw, selectRaw, DB::raw, DB::statement, and PDO::query, and require a reviewer to justify each hit. New raw SQL should be a deliberate, reviewed decision, not something that lands unnoticed.
These layers are covered more fully in my Laravel security checklist, and the CI audit point works best when every raw query that does ship is recorded somewhere durable, which is exactly what a reusable audit-log system gives you. For the canonical defenses across every language, the OWASP SQL Injection Prevention Cheat Sheet is still the reference I send people to.
SQL injection in 2026 is not a sophisticated attack and it is not a hard bug to prevent. It survives because raw query helpers exist for legitimate reasons, deadlines are real, and an orderBy on a request parameter does not look dangerous in code review. Train yourself to see the one shape that matters, untrusted input becoming part of the query text, and the fix follows on its own: bind every value, allow-list every identifier, run as a least-privilege user, and never let a database error reach the client. Do that consistently and you stop relying on luck.

