
September 09, 2026
12 min read
By Kokil Thapa | Last reviewed: September 2026
SQL injection prevention in Laravel beyond Eloquent matters the moment you leave the ORM. Eloquent and the query builder bind parameters by default, which is why many teams assume Laravel is immune. That assumption breaks down fast when you add raw SQL, dynamic ORDER BY clauses, report queries, or legacy imports. On production apps I maintain — including client portals with complex reporting — the risky code almost always lives outside standard CRUD models. This guide covers the patterns that actually keep advanced Laravel database work safe when Eloquent is not enough.
? placeholders or named bindings, whitelisting column and sort names, avoiding string concatenation in DB::raw(), and auditing all whereRaw, selectRaw, and DB::select calls during code review.Where does SQL injection still happen in Laravel if Eloquent is safe?
Eloquent protects you when you use it idiomatically. The danger appears at the edges. Raw queries, report builders, admin search screens, and third-party package overrides are common entry points.
Laravel 13.x runs on PHP 8.3 or higher. The database layer still sends SQL strings to MySQL 9.7, PostgreSQL 18, or MariaDB 12.3. If user input becomes part of that string unescaped, the database executes attacker-controlled syntax. Eloquent never sees the query.
A typical vulnerable pattern looks innocent in a controller:
public function search(Request $request)
{
$term = $request->input('q');
return DB::select("SELECT * FROM users WHERE name LIKE '%{$term}%'");
} Input like ' OR 1=1 -- changes query logic. The fix is binding, not escaping manually. Laravel passes bound values separately from the SQL template. The driver handles typing and quoting.
High-risk Laravel APIs include DB::select(), DB::statement(), whereRaw(), selectRaw(), havingRaw(), orderByRaw(), and DB::raw() inside select lists. Each accepts a SQL fragment. Values belong in the binding array, not inside the fragment string.
Authorization gaps compound the problem. SQL injection is not the same as broken access control. A safe query that returns another user's rows is still a breach. Pair database hygiene with Laravel policies and gates on every sensitive endpoint.
How do you write safe raw SQL queries in Laravel?
The rule is simple. SQL structure is code. Data is bound. Never mix them with string interpolation or concatenation.
Use positional placeholders with DB::select
use Illuminate\Support\Facades\DB;
$users = DB::select(
'SELECT id, email FROM users WHERE status = ? AND created_at >= ?',
['active', $request->date('from')]
); Each ? maps to the next binding in order. This works for DB::insert, DB::update, and DB::delete as well. Named bindings are clearer for long queries:
DB::select(
'SELECT * FROM orders WHERE user_id = :user_id AND total > :min_total',
['user_id' => $userId, 'min_total' => 1000]
); Prefer the query builder over hand-written SQL
When logic fits the builder, use it. Methods like where(), whereIn(), and join() bind values automatically:
DB::table('orders')
->join('users', 'users.id', '=', 'orders.user_id')
->where('orders.status', $status)
->whereDate('orders.created_at', '>=', $from)
->select('orders.id', 'users.email')
->get(); On legal-tech portals I have shipped, document search often needs full-text or complex joins. I still start with the builder. Raw SQL is the exception after profiling proves the builder cannot express the query cleanly.
Transactions and multi-statement work
Reporting imports and batch updates should run inside transactions. Binding rules do not change inside a transaction block. See Laravel database transactions and deadlocks for isolation details. A bound query remains safe whether or not it sits in DB::transaction().
When is DB::raw() safe and when does it create injection risk?
DB::raw() marks an expression as literal SQL. It does not bind anything. Use it only for trusted, static SQL fragments — aggregate functions, database-specific syntax, or column expressions with no user input.
Safe example for a select list:
DB::table('orders')
->select('user_id', DB::raw('COUNT(*) as order_count'))
->groupBy('user_id')
->get(); Unsafe example that looks similar:
DB::table('users')
->select(DB::raw("CONCAT(first_name, ' ', '{$request->input('suffix')}') as label"))
->get(); The suffix value must be a bound column or a separate binding. Correct approach:
DB::table('users')
->selectRaw("CONCAT(first_name, ' ', ?) as label", [$request->input('suffix')])
->get(); selectRaw, whereRaw, and siblings accept bindings as the second argument. The first argument is the SQL template. Treat it like a prepared statement.
| Method | User data in SQL string | Bindings array | Verdict |
|---|---|---|---|
where('col', $val) | No | Automatic | Safe |
whereRaw('col = ?', [$val]) | No | Required | Safe |
whereRaw("col = '$val'") | Yes | None | Vulnerable |
DB::raw('COUNT(*)') | No static SQL | N/A | Safe |
DB::raw("$userColumn") | Yes | N/A | Vulnerable |
orderBy($column) | Column whitelisted | Automatic | Safe if validated |
orderByRaw($request->sort) | Yes | None | Vulnerable |
The Laravel database documentation states that bindings protect against SQL injection. That protection disappears the moment you embed variables inside raw strings. Static analysis tools and reviewers look for that pattern first.
For PostgreSQL-specific apps, JSON operators and window functions often push teams toward raw SQL. The binding rule does not change on PostgreSQL 18. Read PostgreSQL for Laravel developers for dialect tips, but keep placeholders universal.
How do you secure dynamic filters, search, and ORDER BY clauses?
Admin panels and API list endpoints are where dynamic SQL causes the most production incidents. Users control sort columns, direction, and filter fields. Each must be validated against a whitelist.
Whitelist sort columns and directions
public function index(Request $request)
{
$allowedSorts = ['created_at', 'name', 'status'];
$sort = in_array($request->input('sort'), $allowedSorts, true)
? $request->input('sort')
: 'created_at';
$direction = $request->input('direction') === 'asc' ? 'asc' : 'desc';
return DB::table('cases')
->orderBy($sort, $direction)
->paginate(25);
} Never pass $request->sort directly into orderByRaw(). Attackers send values like id; DROP TABLE cases; --. Whitelisting blocks that class of input entirely.
Build filters with conditional where clauses
$query = DB::table('documents');
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($request->filled('client_id')) {
$query->where('client_id', (int) $request->input('client_id'));
}
if ($request->filled('q')) {
$query->where(function ($q) use ($request) {
$q->where('title', 'like', '%' . $request->input('q') . '%')
->orWhere('reference', 'like', '%' . $request->input('q') . '%');
});
}
return $query->paginate(20); The LIKE pattern uses binding through where(). Wildcards belong in the value side. Do not build LIKE '%{$term}%' inside whereRaw unless the term is bound.
Dynamic column names need maps, not sanitization
PHP addslashes() or regex "cleaning" does not make column names safe. Map external keys to real columns:
$columnMap = [
'date' => 'orders.created_at',
'amount' => 'orders.total',
'client' => 'clients.name',
];
$externalKey = $request->input('group_by', 'date');
$column = $columnMap[$externalKey] ?? $columnMap['date'];
$query->groupBy($column); On a legal information portal, public search rarely needs raw SQL at all. When it does, whitelists and bindings are non-negotiable. Public forms attract automated probes within hours of launch.
What about stored procedures, imports, and third-party packages?
Legacy integrations often call stored procedures or run bulk imports from CSV. These paths bypass Eloquent entirely.
Stored procedure calls
DB::select('CALL sp_generate_report(?, ?, ?)', [
$officeId,
$startDate->format('Y-m-d'),
$endDate->format('Y-m-d'),
]); Procedure names should be hard-coded in application code. Do not let HTTP parameters choose procedure names. That is the same class of mistake as dynamic table names.
Import pipelines
Spreadsheet imports via Laravel Excel still end up as INSERT or UPDATE statements. Validate rows in Form Requests before persistence. Batch inserts through Eloquent or the query builder with array bindings:
DB::table('products')->insert([
['sku' => 'A1', 'price' => 100],
['sku' => 'B2', 'price' => 250],
]); Each row value is bound. Avoid building one giant SQL string from CSV cells in a loop.
Package and scope audit
Global scopes, custom macros, and reporting packages sometimes ship raw SQL examples that copy poorly into production. Review vendor source when a package accepts sort or filter strings. Replace unsafe examples with whitelisted wrappers before deploy.
API endpoints deserve the same scrutiny. Public REST APIs built with Laravel expose list parameters to clients. Document allowed sort keys in OpenAPI. Reject unknown keys with 422 responses instead of passing them to the database layer.
How do you audit a Laravel codebase for SQL injection?
Prevention at write time beats emergency patches after a scan report. Build a repeatable audit pass into every release that touches database code.
- Search the codebase for
whereRaw,selectRaw,havingRaw,orderByRaw,DB::raw,DB::select, and string interpolation inside SQL. - Flag any
{variable}or concatenation (.) inside SQL strings. - Verify dynamic identifiers use whitelists, not filters or escaping.
- Run Laravel feature tests that send malicious query strings and assert 422 or safe empty results.
- Enable slow query and error logging in staging. Unexpected syntax errors often reveal injection attempts early.
- Schedule third-party dependency updates through Composer 2.10. Security fixes in database components still matter even when your app code is clean.
Useful grep-style patterns during review:
rg "whereRaw\(|selectRaw\(|orderByRaw\(|DB::raw\(|DB::select\(" app/
rg '"\s*\.\s*\$|\'\s*\.\s*\$|\{\$' app/ --glob '*.php' Pair static review with the regex tester when you build search patterns for CI. False positives are acceptable in lint rules. Missed vulnerabilities are not.
The OWASP SQL injection guide remains the reference for attack classes and test payloads. Laravel-specific mitigation is binding plus whitelisting, but knowing classic payloads helps you write better tests.
Penetration testing still has value for high-risk systems. Client portals with payments and document storage — like those covered in our enterprise application development work — benefit from annual third-party review. Automated grep plus tests catches most developer mistakes cheaply.
Do not confuse ORM usage with full coverage. A single reporting controller with string-built SQL can expose an entire database. Redis 8.10 caching and query result caching do not sanitize inputs either. They only store whatever the vulnerable query returned.
Key Takeaways
- Bind every user-supplied value with
?or named placeholders; never embed input in SQL strings. - Use
DB::raw()only for static SQL fragments; pass dynamic values throughselectRawbindings. - Whitelist sort columns, group fields, and direction — never trust request parameters as identifiers.
- Prefer the query builder over hand-written SQL unless profiling proves you need raw expressions.
- Audit
whereRaw, imports, stored procedures, and third-party packages on every database-related release. - Combine safe SQL with authorization policies so bound queries still cannot leak cross-tenant data.
People Also Ask
Is Eloquent enough to prevent SQL injection in Laravel?
For standard CRUD, yes. Eloquent and the query builder parameterize values automatically. Risk rises when you use raw methods, dynamic identifiers, or external packages that build SQL from strings. Those paths need explicit binding and whitelisting even if the rest of the app uses Eloquent.
Can Laravel Form Request validation stop SQL injection?
Validation reduces bad input but does not replace binding. A validated string can still break SQL if concatenated into a query. Use Form Requests for shape and type rules. Use placeholders for anything that reaches the database.
Are whereIn and JSON where clauses safe with user input?
Yes, when values are passed as arrays to whereIn() or as bindings to JSON methods. Unsafe patterns appear when raw JSON paths or keys come from the request without a whitelist map. Treat JSON path segments like column names.
Does Laravel 13 change SQL injection prevention compared to Laravel 12?
The binding model is unchanged. Laravel 13.x requires PHP 8.3+. Security still depends on developer discipline outside Eloquent. Upgrade for support lifecycle — Laravel 12 is supported to February 2027 — not because the ORM suddenly handles raw SQL differently.
Ship safer database code on your next Laravel release
SQL injection prevention in Laravel beyond Eloquent is not exotic security work. It is discipline at the boundaries: bind data, whitelist structure, audit raw calls, and test list endpoints with hostile input. Eloquent covers the happy path. Your reports, imports, and admin filters need the same standard.
If you want a second pair of eyes on raw SQL, API filters, or a legacy module before upgrade, review our testing and optimization services or browse related guides like Laravel API best practices and modern Laravel architecture. For ongoing hardening after launch, support and maintenance keeps production apps patched and reviewed.
Contact us to audit database-heavy Laravel code before your next deploy. You can also read more on about me, explore the full portfolio, or check SEO setup for Laravel sites so security fixes do not break indexation.
Frequently Asked Questions
0 Comments
Leave a comment
Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

