Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix SQL injection in PHP and Laravel

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language PHP
  • Runtime PHP 8.5.11 (CLI)
  • Framework Laravel 13.34.0 (query builder over SQLite)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks9 cards
Download the project ZIP, 52 KB
On this page

Steps

  1. 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.
  2. Prefer the query builder and Eloquent: where('c.name', $name), whereIn('id', $ids) and their friends bind each value through PDO.
  3. 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.
  4. 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.
  5. Read request values with $request->query() or validated input and check they are strings: ?customer[]=x arrives as an array.
  6. 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.
  7. Keep APP_DEBUG=false in production: with debug on, the error page for a QueryException shows the SQL and the bindings.

Vulnerable, fixed and wrong

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

Vulnerableapp/Orders/VulnerableOrderRepository.phpPHP
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

Fixedapp/Orders/FixedOrderRepository.phpPHP
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

Fixedapp/Orders/FixedOrderRepository.phpPHP
// 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

Wrong fixapp/Orders/WrongOrderRepository.phpPHP
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

Wrong fixapp/Orders/WrongOrderRepository.phpPHP
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 regression test

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.

Testtests/Feature/OrdersTest.phpPHP
// 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);
}

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix SQL injection in PHP and Laravel
ToolRuleWhat it found here
Semgrepphp.laravel.security.laravel-sql-injection.laravel-sql-injectionNo 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
Semgrepphp.laravel.security.laravel-api-route-sql-injection.laravel-api-route-sql-injectionNo 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)
Semgrepphp.lang.security.injection.tainted-sql-string.tainted-sql-stringNo findings: superglobal sources only, as above.Checked: Semgrep 1.180.0 (CE, semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3)
Semgrepr/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
PsalmTaintedSqlPsalm taint analysis with the Laravel plugin3 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.

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePHP
RuntimePHP 8.5.11 (CLI)
FrameworkLaravel 13.34.0 (query builder over SQLite)
laravel/framework13.34.0
phpunit/phpunit13.4.0
pdo_sqlite (bundled with PHP)PHP 8.5.11; SQLite 3.53.4

Run the tests

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.

Shell
composer install --no-interaction --no-progress
Shell
# 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.xml

Without 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.

Shell
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'
Shell
# 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.

Download the project ZIP, 52 KB

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

  1. OWASP SQL Injection Prevention Cheat Sheet
  2. Laravel 13.x: Query builder (PDO parameter binding, and never letting input choose column names)
  3. Laravel 13.x: Raw expressions and raw methods
  4. Laravel 13.x: Ordering (orderBy and orderByRaw)
  5. Laravel 13.x: Running raw SQL queries with bindings
  6. PHP manual: Prepared statements and stored procedures (PDO)
  7. PHP manual: addslashes()
  8. SQLite: Quirks (double-quoted string literals are accepted)
  9. CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection')

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.