Let the query builder bind values: DB::table('orders as o')->where('c.name', $name)->where('o.status', $status) sends both as PDO parameters, and raw SQL takes ? placeholders with a bindings array. Never interpolate into DB::select(), whereRaw() or orderByRaw(). Map the requested sort onto an allowlist of public columns before orderBy() and answer 400 otherwise.
- Find every raw entry point: DB::select(), DB::statement(), DB::unprepared(), DB::raw(), selectRaw(), whereRaw(), orWhereRaw(), havingRaw(), orderByRaw() and groupByRaw(), and every orderBy(), groupBy() or select() whose column name comes from the request.
- Prefer the query builder and Eloquent: where('c.name', $name), whereIn('id', $ids) and their friends bind each value through PDO.
- When you need raw SQL, keep the SQL text constant and pass the values separately: DB::select('... WHERE c.name = ? AND o.status = ?', [$name, $status]), or named bindings with :name. The *Raw() methods take the same bindings array as their second argument.
- For the sort column and direction, map each public option to a column with a PHP array (customer to c.name, desc to desc) and answer 400 for anything else. Laravel quotes the column you pass to orderBy() as an identifier, but it does not restrict which column.
- Read request values with $request->query() or validated input and check they are strings: ?customer[]=x arrives as an array.
- Add regression tests with this card's payloads (tautology, UNION, comment, sort expression) next to functional tests for names such as O'Brien and non-ASCII text, and run them with PHPUnit.
- Keep APP_DEBUG=false in production: with debug on, the error page for a QueryException shows the SQL and the bindings.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public function forCustomer(string $customerName, string $status): array
{
return DB::select("
SELECT o.id, o.status, o.total_cents, o.created_at
FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id
WHERE c.name = '$customerName' AND o.status = '$status'
ORDER BY o.id");
}With IMPL=vulnerable, 7 of 11 tests fail (5 exploit tests).
The fix
public function forCustomer(string $customerName, string $status): array
{
// where() never puts values into the SQL text: SQLite receives them as parameters.
// Raw SQL works too, with placeholders: DB::select('... WHERE c.name = ?', [$customerName]).
return DB::table('orders as o')
->join('customers as c', 'c.id', '=', 'o.customer_id')
->where('c.name', $customerName)
->where('o.status', $status)
->orderBy('o.id')
->get(['o.id', 'o.status', 'o.total_cents', 'o.created_at'])
->all();
}With IMPL=fixed, 11 of 11 tests pass.
The fix: sort allowlist
// Placeholders cannot stand in for column names, and orderBy() accepts any column, including
// c.email, which the API never returns. Map the caller's choice onto public columns.
private const SORT_COLUMNS = [
'created_at' => 'o.created_at',
'total_cents' => 'o.total_cents',
'customer' => 'c.name',
];
private const SORT_DIRECTIONS = ['asc' => 'asc', 'desc' => 'desc'];
public function search(string $status, string $sort, string $direction): array
{
$column = self::SORT_COLUMNS[$sort] ?? null;
$order = self::SORT_DIRECTIONS[$direction] ?? null;
if ($column === null || $order === null) {
throw new InvalidArgumentException('unsupported sort column or direction');
}
return DB::table('orders as o')
->join('customers as c', 'c.id', '=', 'o.customer_id')
->where('o.status', $status)
->orderBy($column, $order)
->orderBy('o.id')
->get(['o.id', 'c.name as customer', 'o.status', 'o.total_cents', 'o.created_at'])
->all();
}With IMPL=fixed, 11 of 11 tests pass.
A fix that looks safe but is not
public function forCustomer(string $customerName, string $status): array
{
return DB::table('orders as o')
->join('customers as c', 'c.id', '=', 'o.customer_id')
->whereRaw("c.name = '".addslashes($customerName)."'")
->whereRaw("o.status = '".addslashes($status)."'")
->orderBy('o.id')
->get(['o.id', 'o.status', 'o.total_cents', 'o.created_at'])
->all();
}With IMPL=wrong, 5 of 11 tests fail (3 exploit tests).
A fix that still fails: orderBy() with the requested column
public function search(string $status, string $sort, string $direction): array
{
// orderBy() quotes the column as an identifier and refuses a direction other than asc
// or desc, so this looks safe.
return DB::table('orders as o')
->join('customers as c', 'c.id', '=', 'o.customer_id')
->whereRaw("o.status = '".addslashes($status)."'")
->orderBy($sort, $direction)
->orderBy('o.id')
->get(['o.id', 'c.name as customer', 'o.status', 'o.total_cents', 'o.created_at'])
->all();
}With IMPL=wrong, 5 of 11 tests fail (3 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
// Exploit: each payload must stay data and leave the query's meaning unchanged.
public function test_exploit_tautology_does_not_return_other_customers_orders(): void
{
$this->assertSame([], $this->orderIds('/orders', ['customer' => "' OR '1'='1", 'status' => 'shipped']));
}
public function test_exploit_union_cannot_read_another_customers_orders(): void
{
$customer = "' UNION SELECT id, status, total_cents, created_at FROM orders WHERE customer_id = 2 --";
$this->assertSame([], $this->orderIds('/orders', ['customer' => $customer, 'status' => 'shipped']));
}
public function test_exploit_comment_cannot_cut_off_the_status_filter(): void
{
$this->assertSame([], $this->orderIds('/orders', ['customer' => "Alice Martin'--", 'status' => 'shipped']));
}
public function test_exploit_sort_expression_is_rejected_not_used_as_an_oracle(): void
{
// Row order would reveal whether each customer's hidden email starts with "b".
$sort = 'CASE WHEN substr(c.email, 1, 1) = char(98) THEN 0 ELSE 1 END';
$this->getJson('/admin/orders?'.http_build_query(['status' => 'shipped', 'sort' => $sort, 'dir' => 'asc']))
->assertStatus(400);
}
public function test_exploit_direction_cannot_add_a_second_sort_expression(): void
{
$direction = 'ASC, CASE WHEN substr(c.email, 1, 1) = char(98) THEN 0 ELSE 1 END';
$this->getJson('/admin/orders?'.http_build_query(['status' => 'shipped', 'sort' => 'created_at', 'dir' => $direction]))
->assertStatus(400);
}- The Laravel docs warn that PDO cannot bind column names, so user input must never choose the columns a query uses, including ORDER BY columns. orderBy($sort) wraps $sort as a quoted identifier: the CASE payload becomes an unknown column and a 500, but c.email, which the API never returns, sorts the rows and leaks its order. This card checked both on Laravel 13.34.0.
- On SQLite, an unknown double-quoted identifier falls back to a string literal, so orderBy('nickname') sorts by a constant and answers 200 instead of failing; that is why this card's wrong implementation passes an unknown sort column.
- addslashes() is MySQL-style backslash escaping. SQLite, PostgreSQL with standard_conforming_strings and SQL Server treat a backslash as an ordinary character, so O'Brien becomes a syntax error and a quote in a UNION payload still closes the string.
- DB::raw() and the *Raw() methods put their first argument into the SQL text as it is. Bindings passed as the second argument are safe; the text itself must be a constant.
- orderBy() accepts only asc or desc as the direction and throws InvalidArgumentException otherwise; catch it and answer 400, or validate the direction first, rather than letting it become a 500.
- LIKE: where('name', 'like', "%{$term}%") binds the value, but % and _ in $term are still wildcards; escape them if users should not get pattern matching.
- Eloquent JSON column paths (column->key) and select() lists are identifiers too; allowlist them like sort columns.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs sql-injection/php-laravel does this for you in the pinned composer image, so it needs Docker).
- Against your own endpoint, send customer=' OR '1'='1 and expect an empty result, and send a CASE expression or c.email as the sort and expect 400.
- grep for DB::raw, Raw(, DB::select( and orderBy($ with request data; Semgrep's Laravel rules only see $_GET-style sources, while Psalm with the Laravel plugin traced the Request object to the sinks here.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | php.laravel.security.laravel-sql-injection.laravel-sql-injection | No findings on any implementation. Its taint sources are $_GET, $_POST, $_COOKIE, $_REQUEST and $_SERVER only, so request data read through Illuminate\Http\Request, as Laravel code does, never reaches its whereRaw(), orderByRaw() and orderBy() sinks. A one-file probe that reads $_GET into DB::table()->whereRaw() was flagged; the same probe with $request->query() was not.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/<id> app routes |
| Semgrep | php.laravel.security.laravel-api-route-sql-injection.laravel-api-route-sql-injection | No findings. It covers one shape: a parameter of a Route::get() closure interpolated into DB::raw(). A probe in that shape was flagged; this card builds its SQL in a repository class.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3) |
| Semgrep | php.lang.security.injection.tainted-sql-string.tainted-sql-string | No findings: superglobal sources only, as above.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3) |
| Semgrep | r/php (every PHP rule in the Semgrep registry, 47 rules) | No findings on any implementation.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3); semgrep scan --config r/php app routes config bootstrap resources |
| Psalm | TaintedSqlPsalm taint analysis with the Laravel plugin | 3 TaintedSql issues in VulnerableOrderRepository.php (the DB::select() string, whereRaw() and orderByRaw()) and 4 in WrongOrderRepository.php (both whereRaw() calls with addslashes(), the status whereRaw() and orderBy($sort)), none in FixedOrderRepository.php. The plugin follows the value from $request->query() in the controller through the repository interface; addslashes() is not treated as a sanitizer.Checked: Psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6; psalm --taint-analysis over app/ and routes/ |
CodeQL has no PHP support: the CodeQL supported languages page lists C/C++, C#, GitHub Actions, Go, Java, Kotlin, JavaScript, Python, Ruby, Rust, Swift and TypeScript.
| Component | Version |
|---|---|
| Language | PHP |
| Runtime | PHP 8.5.11 (CLI) |
| Framework | Laravel 13.34.0 (query builder over SQLite) |
| laravel/framework | 13.34.0 |
| phpunit/phpunit | 13.4.0 |
| pdo_sqlite (bundled with PHP) | PHP 8.5.11; SQLite 3.53.4 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the sql-injection/php-laravelfolder.
composer install --no-interaction --no-progress# Expected: every test passes
IMPL=fixed vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: an exploit test fails
IMPL=vulnerable vendor/bin/phpunit --log-junit reports/junit.xml
# Expected: a test fails
IMPL=wrong vendor/bin/phpunit --log-junit reports/junit.xmlWithout PHP installed, run the same commands with Docker in the image the card was verified in, pinned by its digest. The named volumes keep the downloaded dependencies and the build cache between runs, so the test runs need no network.
docker run --rm --init \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'composer install --no-interaction --no-progress'# Expected: every test passes
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=fixed \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: an exploit test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=vulnerable \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'
# Expected: a test fails
docker run --rm --init --network=none \
-v "$PWD":/card -w /card \
-v fix-laravel-composer-cache:/tmp/cache \
-e IMPL=wrong \
composer:2.10.3@sha256:af98f42dfff7c68ba8d53c2164fd9fde1087b7d449514baa38c418b1f6bc4bac \
sh -c 'vendor/bin/phpunit --log-junit reports/junit.xml'Each run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs sql-injection/php-laravel runs all three modes and checks the expected results, in the container, so it needs Docker and Node.js but not the toolchain.
Verified
- Machine
- Alpine Linux 3.24 in the composer:2.10.3 image (Docker 29.4.3 on macOS 27.0, arm64)
- Toolchain
- php 8.5.11 (CLI)
- laravel/framework 13.34.0
- phpunit/phpunit 13.4.0
- composer 2.10.3
- semgrep 1.180.0
- psalm 7.0.0-beta24 with psalm/plugin-laravel 4.16.6
- Matrix
- fixed: 11 of 11 tests pass; vulnerable: 7 of 11 tests fail (5 exploit tests); wrong: 5 of 11 tests fail (3 exploit tests)
- Test results
- fixed 11/11 pass; vulnerable 7/11 fail (5 exploit tests); wrong 5/11 fail (3 exploit tests), each test run in a container with --network=none after composer install
- Dependency freshness
- Laravel 13.35.0 (2026-10-06) is newer but was five days old and is not a security release, so the cards pin 13.34.0 (2026-09-29); laravel/prompts is held at 0.3.24 for the same reason (0.3.25 is from 2026-10-06), and PHPUnit 13.4.1 (2026-10-05) was six days old, so the cards use 13.4.0 (2026-10-02). Every other locked package is at least seven days old. composer audit reported no advisories for the lock file. PHP 8.5.11 (2026-09-24) is the current 8.5 security release.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP SQL Injection Prevention Cheat Sheet
- Laravel 13.x: Query builder (PDO parameter binding, and never letting input choose column names)
- Laravel 13.x: Raw expressions and raw methods
- Laravel 13.x: Ordering (orderBy and orderByRaw)
- Laravel 13.x: Running raw SQL queries with bindings
- PHP manual: Prepared statements and stored procedures (PDO)
- PHP manual: addslashes()
- SQLite: Quirks (double-quoted string literals are accepted)
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')
Attack patterns: