A Security Audit Found SQL Injection in My Code, and I Genuinely Didn’t See It Coming

A client hired an external security firm to audit their platform before a big funding round, standard due diligence stuff. I got the report back a week later and one specific finding made my stomach drop, a SQL injection vulnerability in the search feature I’d built for their property listings site.
I’d used a raw query for a slightly complex search that Eloquent’s query builder felt clunky for, combining a price range, location text search, and a sort option all in one go. I built the query string by directly concatenating the user’s search input into it, something like this:
$results = DB::select("SELECT * FROM properties WHERE location LIKE '%" . $request->location . "%'");
The security firm’s proof of concept was genuinely alarming. By entering a specially crafted string into that innocent-looking location search box, they could manipulate the actual SQL query being run, potentially extracting data they had no business seeing, or worse. I’d known about SQL injection in theory for years, but seeing an actual working exploit against my own code was a completely different kind of wake-up call.
Here’s everything I learned fixing it, plus how to write raw queries safely from the start so you never end up reading a report like that yourself.
What SQL Injection Actually Is (Explained Plainly)
When you build a SQL query by directly gluing user input into a string, a malicious user can type something that isn’t just search text at all, but actual SQL code designed to change what the query does entirely.
For my property search, someone could type something like ' OR '1'='1 into the location field. Instead of searching for a location containing that literal text, it would manipulate the query’s logic itself, potentially returning every single property in the database regardless of the actual location filter, or in more severe cases, extracting completely unrelated data depending on how the query was structured.
The core problem isn’t raw SQL itself, it’s directly inserting user input into a query string without any protection. Laravel gives you tools to write raw SQL perfectly safely, I just wasn’t using them properly.
Step 1: Parameter Binding (The Actual Fix)
The fix for my vulnerable query was using parameter binding, letting the database driver handle the user input safely instead of manually gluing it into the query string myself.
// Vulnerable version
$results = DB::select("SELECT * FROM properties WHERE location LIKE '%" . $request->location . "%'");
// Fixed version using parameter binding
$results = DB::select(
"SELECT * FROM properties WHERE location LIKE ?",
['%' . $request->location . '%']
);
That ? placeholder combined with the array of actual values is the key difference. Laravel’s underlying PDO database driver handles inserting that value safely, treating it strictly as data, never as executable SQL code, no matter what the user actually types into that field. Even if someone enters that same malicious string, it just gets treated as a literal search term, nothing more.
Step 2: Named Bindings for More Complex Queries
For the actual combined search (price range, location, sorting) that originally pushed me toward raw SQL in the first place, named bindings made the query considerably more readable than a string of unnamed question marks:
$results = DB::select(
"SELECT * FROM properties
WHERE location LIKE :location
AND price BETWEEN :minPrice AND :maxPrice
ORDER BY price ASC",
[
'location' => '%' . $request->location . '%',
'minPrice' => $request->min_price,
'maxPrice' => $request->max_price,
]
);
Using :location, :minPrice, and :maxPrice as named placeholders, matched to an associative array, made it genuinely clear which value went where, especially valuable once a query has several different parameters, rather than trying to keep track of the order of several unnamed ? placeholders matching up correctly.
Step 3: Honestly Reconsidering Whether I Needed Raw SQL At All
Here’s something the whole incident made me actually stop and think about properly. My original reason for reaching for raw SQL was that Eloquent’s query builder “felt clunky” for this particular combined search. Looking at it again afterward, with fresh eyes, it genuinely wasn’t that much harder to write safely using the query builder directly:
$results = Property::where('location', 'like', '%' . $request->location . '%')
->whereBetween('price', [$request->min_price, $request->max_price])
->orderBy('price', 'asc')
->get();
Laravel’s query builder and Eloquent automatically handle parameter binding for you behind the scenes, you genuinely don’t have to think about SQL injection at all when using these methods properly, since user input never gets directly concatenated into a query string in the first place.
I ended up rewriting that entire search feature using the query builder instead of raw SQL, and it turned out just as readable, arguably more so since it doesn’t require mentally parsing a chunk of raw SQL syntax at all. Lesson learned: reach for raw SQL only when you’ve genuinely confirmed the query builder can’t handle what you need, not just because it initially feels slightly more familiar or convenient for a specific complex-feeling query.
Step 4: When You Genuinely DO Need Raw SQL
Sometimes raw SQL genuinely is the right call, certain complex reporting queries, specific database functions the query builder doesn’t have a clean method for, or performance-critical queries where you want precise control over exactly what SQL gets executed. On a different project, a financial reporting dashboard, I needed a fairly complex query involving window functions that Eloquent’s query builder simply doesn’t support cleanly.
$report = DB::select("
SELECT
department,
salary,
RANK() OVER (PARTITION BY department ORDER BY salary DESC) as salary_rank
FROM employees
WHERE department = :department
", ['department' => $request->department]);
Genuinely necessary here, and genuinely safe, since that one piece of actual user input, $request->department, is still properly bound using the named parameter rather than concatenated directly into the query string.
Step 5: Raw Expressions Inside Eloquent Queries
Sometimes you need a small piece of raw SQL inside an otherwise normal Eloquent query, not the entire query itself. Laravel’s DB::raw() handles this, but here’s where I want to add a genuine word of caution, since DB::raw() does NOT automatically protect against SQL injection the way parameter binding does.
// Dangerous if $request->sortColumn comes directly from user input
$results = Property::orderBy(DB::raw($request->sortColumn))->get();
If a user could control $request->sortColumn directly, this is genuinely just as vulnerable as my original mistake, since DB::raw() inserts that string directly into the query without any sanitization at all. I made a similar mistake on a different project allowing users to choose a sort column through a dropdown, initially passing whatever value came through directly into DB::raw().
The actual fix was validating the sort column against a genuinely fixed, known list of allowed values before ever passing it anywhere near the query:
$allowedSortColumns = ['price', 'created_at', 'bedrooms'];
$sortColumn = in_array($request->sort, $allowedSortColumns) ? $request->sort : 'created_at';
$results = Property::orderBy($sortColumn)->get();
This way, even if someone tampers with the request and sends an unexpected value for the sort parameter, it simply falls back to a safe default instead of ever reaching the query with unvalidated input, and DB::raw() isn’t even needed here at all once you validate against a whitelist like this.
Step 6: Validating Input Types Isn’t Optional, Even With Parameter Binding
Parameter binding protects against SQL injection specifically, but it’s still worth genuinely validating that user input is the actual type and format you expect, for reasons beyond just injection prevention. On the property search, I added proper validation rules alongside the parameter-bound query:
$validated = $request->validate([
'location' => 'nullable|string|max:255',
'min_price' => 'nullable|numeric|min:0',
'max_price' => 'nullable|numeric|min:0',
]);
This doesn’t replace parameter binding, they solve genuinely different problems, validation ensures the data makes logical sense for your application (a price shouldn’t be negative, for instance), while parameter binding ensures that whatever value comes through can never be interpreted as executable SQL code regardless of its content.
A Genuinely Useful Habit: Testing With Deliberately Malicious Input
After that security audit, I started actually testing my own raw queries the way an attacker might, before ever shipping them. For the fixed property search, I deliberately tried entering things like ' OR '1'='1 and '; DROP TABLE properties; -- directly into the search field myself during testing.
With proper parameter binding in place, these just get treated as literal, harmless search text, returning no matching properties (since obviously no property has that as its actual location), rather than manipulating the query’s actual behavior at all. Seeing that confirmed behavior genuinely gave me more confidence than just trusting the code looked correct on paper.
Common Mistakes I’d Warn You About
Directly concatenating user input into a raw query string. This is genuinely the core mistake behind almost every SQL injection vulnerability, mine included. Always use parameter binding, ? or named placeholders, instead.
Assuming DB::raw() automatically sanitizes input. It doesn’t. If you’re using DB::raw() with any value that comes from user input, validate that value against a known safe list first, don’t pass user input into it directly.
Reaching for raw SQL out of habit or initial convenience without checking if the query builder genuinely can’t handle it. A lot of “clunky feeling” queries turn out perfectly manageable with Eloquent or the query builder once you actually sit down and try, avoiding the entire category of risk raw SQL introduces if handled carelessly.
Not testing with deliberately malicious input yourself before shipping. Don’t wait for an external security audit to be the first time your code gets tested this way. A few minutes trying obvious injection patterns yourself during development catches a lot of these issues early.
Skipping proper input validation because parameter binding handles injection. They solve different problems. Validate data types and ranges too, not just injection safety.
Final Thoughts
That security audit report was genuinely one of the more humbling moments of my career so far, seeing an actual working exploit against code I’d written and thought was perfectly fine. But it taught me something that’s stuck ever since, raw SQL in Laravel isn’t inherently dangerous, careless raw SQL is. Parameter binding exists specifically to make raw queries just as safe as using Eloquent, you just have to actually use it every single time, not just when it feels necessary.
If you’ve got any raw queries in your codebase right now built by concatenating user input directly into a query string, even ones that feel like they’ve “worked fine so far,” fix them today using parameter binding. Don’t wait for your own version of that unsettling security report to be the thing that finally makes you take it seriously.



