Query Builder і Eloquent будують SQL із двох частин: текст запиту з плейсхолдерами ? і масив біндінгів, які PDO передає окремо. Значення з where('email', $email), whereIn(), insert() чи update() потрапляють саме в біндінги, тому ' OR 1=1 -- у полі email так і лишиться дивною адресою. Захист працює рівно доти, доки текст запиту пише фреймворк. Щойно розробник сам формує шматок SQL рядком, Laravel вставляє його як є: це стосується DB::raw(), усіх *Raw()-методів (whereRaw, selectRaw, orderByRaw, havingRaw, groupByRaw), а також DB::select(), DB::statement() і DB::unprepared().
Raw-методи не забороняють, вони потрібні для функцій бази, JSON-операторів і звітів. Правило для них одне: у рядку лише SQL і плейсхолдери, дані другим аргументом. whereRaw('lower(email) = ?', [$email]) безпечний, whereRaw("lower(email) = '$email'") ні, і різницю видно одразу: інтерполяція або крапка-конкатенація всередині raw-виклику є головним маркером на код-ревʼю. Те саме з DB::select(): метод справді готує запит через prepare(), але якщо $id уже вклеєний у рядок, prepared statement отримує готову інʼєкцію.
Окремий клас проблем створюють ідентифікатори. Плейсхолдер може замінити лише значення, тому імʼя колонки, таблиці чи напрям сортування в біндінг не передати: orderByRaw('? desc', [$column]) дасть сортування за рядковою константою, тобто ніякого. orderBy() у Laravel 13 перевіряє напрям і кидає InvalidArgumentException на будь-що, крім asc, desc або SortDirection, а колонку бере як є і загортає в лапки-ідентифікатори. На ці лапки не варто спиратись: документація прямо каже не передавати ввід користувача в імена колонок, і навіть без класичної інʼєкції людина отримує змогу сортувати за password чи remember_token, а за порядком рядків іноді вдається вгадати значення. Правильний шлях один: мапа дозволених ключів на справжні колонки і значення за замовчуванням.
Ще дві речі відрізняють сильну відповідь. Перша: second-order injection, коли значення збережено безпечно, а потім фонова задача читає його з бази і вклеює в raw-запит; у момент склеювання дані виглядають «своїми», і саме тут їх пропускають. Друга: LIKE-шаблони. where('title', 'like', "%$q%") від інʼєкції захищений, але % і _ у пошуковому рядку працюють як спецсимволи, тому для точного пошуку їх екранують, памʼятаючи, що SQLite потребує явного ESCAPE. Базовий механізм prepared statements, емуляцію в PDO MySQL і bindValue() проти bindParam() розібрано в картці про PDO.
// Було: значення вклеєне в рядок SQL, Eloquent тут нічим не допоможе.
// ?min=0 OR 1=1 перетворює фільтр на «всі рядки».
$vacancies = Vacancy::whereRaw("salary_from >= {$request->input('min')}")->get();
// Стало: SQL окремо, значення окремо.
$vacancies = Vacancy::whereRaw('salary_from >= ?', [$request->integer('min')])->get();
// Ще краще без raw: where() сам передає значення біндінгом.
$vacancies = Vacancy::where('salary_from', '>=', $request->integer('min'))->get();
// Імʼя колонки не біндиться, тому лише перелік дозволених.
$sortable = ['date' => 'published_at', 'salary' => 'salary_from'];
$request->validate([
'sort' => ['nullable', Rule::in(array_keys($sortable))], // інакше 422
'dir' => ['nullable', 'in:asc,desc'],
'q' => ['nullable', 'string', 'max:100'],
]);
Vacancy::query()
->where('title', 'like', '%'.$request->query('q').'%') // значення: біндінг
->orderBy($sortable[$request->query('sort') ?? 'date'], // колонка: лише з мапи
$request->query('dir') === 'asc' ? 'asc' : 'desc') // напрям: два варіанти
->paginate(20);
Скажіть, що Laravel захищає значення, а не текст SQL: де ви самі пишете рядок запиту, там і ваша відповідальність. Одразу покажіть мапу для сортування, бо це питання майже завжди йде наступним.