PDO в PHP 8.x: рабочие приёмы против типичных граблей
Как не словить SQL-инъекцию, не молчать при ошибке и не удивляться, почему PDO вернул числа строками. Разбор приёмов с ценой каждого решения.

Вы написали $pdo->query("SELECT * FROM users WHERE id = $id"), всё работало на тесте, а в проде кто-то передал id=1 OR 1=1 и выгреб всю таблицу. Или наоборот: запрос падает, а PHP молчит и возвращает false, потому что PDO по умолчанию не бросает исключения. PDO — не «просто интерфейс к базе», а набор режимов, каждый из которых меняет поведение. Ниже — приёмы, которые реально экономят время на боевых проектах под PHP 8.1–8.3, и цена каждого.
Подключение: три настройки, которые задают тон всему
Большинство проблем с PDO решаются в момент создания объекта. Дефолтные значения выбраны консервативно и почти всегда не те, что нужны в 2024 году.
<?php
// PHP 8.x, драйвер pdo_mysql
$dsn = 'mysql:host=127.0.0.1;dbname=shop;charset=utf8mb4';
$options = [
// Бросать исключения вместо тихого false
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
// По умолчанию отдавать ассоциативные массивы
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
// Настоящие подготовленные запросы на стороне сервера
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, 'app_user', 'YOUR_DB_PASSWORD', $options);
} catch (PDOException $e) {
// Не выводите $e->getMessage() пользователю: там бывает DSN
error_log($e->getMessage());
http_response_code(500);
exit('Database unavailable');
}
Разберём, зачем каждая строка и чем платите, если её убрать.
ERRMODE_EXCEPTION — самое важное
С PHP 8.0 это поведение стало значением по умолчанию: если явно не задать режим, PDO бросает исключения. Но старый код, написанный под PHP 7.x, часто выставляет ERRMODE_SILENT руками. В тихом режиме падение запроса возвращает false, вы этого не проверяете, и ошибка всплывает через три экрана в виде «неопределённого индекса массива». Ставьте ERRMODE_EXCEPTION явно — тогда версия PHP не имеет значения.
EMULATE_PREPARES = false — не косметика
По умолчанию драйвер pdo_mysql эмулирует подготовленные запросы на стороне PHP: подставляет значения в строку сам, экранируя их. Работает, но:
- числа и даты приходят из базы как строки —
idбудет"42", а не42. При строгом сравнении (===) это ломает логику; - один и тот же именованный плейсхолдер нельзя использовать в запросе дважды при эмуляции — драйвер ругается на дубликат.
С EMULATE_PREPARES = false запрос действительно готовится на сервере MySQL, а типы возвращаются ближе к настоящим (для целочисленных колонок — как int). Цена: два обращения к серверу вместо одного на каждый уникальный запрос. Для 99% приложений эта разница незаметна; для горячего цикла из тысяч разных запросов подряд — измеряйте.
Дефолты PDO рассчитаны на совместимость с кодом 2010 года, а не на удобство. Первое, что стоит сделать в новом проекте, — переопределить все три атрибута в массиве опций конструктора.
Плейсхолдеры вместо конкатенации — всегда
Единственный надёжный способ защититься от SQL-инъекции — не собирать запрос из пользовательских данных. Подготовленный запрос с плейсхолдерами передаёт данные и текст запроса раздельно.
<?php
// Именованные плейсхолдеры
$stmt = $pdo->prepare(
'SELECT id, email, created_at
FROM users
WHERE status = :status
AND created_at > :since'
);
$stmt->execute([
'status' => 'active',
'since' => '2024-01-01',
]);
$users = $stmt->fetchAll();
Передавать значения массивом в execute() короче, чем звать bindValue() на каждое. Разница появляется, когда важен тип: execute() биндит всё как строки. Если вам нужен именно integer (например, для LIMIT при выключенной эмуляции), используйте явный бинд.
<?php
$limit = 20;
$stmt = $pdo->prepare('SELECT * FROM products LIMIT :limit');
// LIMIT требует именно int, строка тут вызовет синтаксическую ошибку
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
Чего плейсхолдеры не умеют
Плейсхолдер подставляет значение, но не имя таблицы, колонки или ключевое слово. Динамическую сортировку нельзя сделать так:
<?php
// НЕ РАБОТАЕТ: ORDER BY нельзя параметризовать
$stmt = $pdo->prepare('SELECT * FROM products ORDER BY :column');
Здесь единственный безопасный путь — белый список. Пользователь присылает ключ, вы сами сопоставляете его с разрешённым именем колонки:
<?php
$allowedSort = [
'price' => 'price',
'newest' => 'created_at',
'name' => 'title',
];
$sortKey = $_GET['sort'] ?? 'newest';
// Если ключа нет в списке — берём безопасный дефолт
$column = $allowedSort[$sortKey] ?? 'created_at';
$stmt = $pdo->prepare("SELECT * FROM products ORDER BY $column DESC");
$stmt->execute();
Подстановка $column в строку здесь безопасна: значение приходит не от пользователя, а из вашего массива.
Плейсхолдер для IN(...) — частая ловушка
Один плейсхолдер не разворачивается в список. WHERE id IN (:ids) с массивом не сработает — нужно сгенерировать столько плейсхолдеров, сколько элементов.
<?php
$ids = [4, 8, 15, 16];
// Генерируем строку вида ?,?,?,?
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$stmt = $pdo->prepare(
"SELECT * FROM orders WHERE id IN ($placeholders)"
);
// Позиционные плейсхолдеры принимают простой список значений
$stmt->execute($ids);
$orders = $stmt->fetchAll();
Проверьте, что массив не пустой: IN () — синтаксическая ошибка MySQL. Пустой список обрабатывайте отдельной веткой.
Режимы выборки: что выбрать
Способ получить данные влияет и на удобство, и на память.
| Метод / режим | Что возвращает | Когда использовать |
|---|---|---|
fetchAll(FETCH_ASSOC) | Массив ассоциативных массивов | Небольшая выборка, которую сразу отдаёте в шаблон |
fetch() в цикле while | По одной строке за итерацию | Большие выборки: не держит всё в памяти сразу |
fetchColumn() | Одно скалярное значение | COUNT(*), проверка существования |
FETCH_KEY_PAIR | Массив ключ => значение из двух колонок | Справочники: id => название |
FETCH_CLASS | Объекты заданного класса | Когда работаете с сущностями, а не массивами |
Главное правило по памяти: fetchAll() вытаскивает весь результат в PHP-массив. Для отчёта на 500 000 строк это гарантированный Allowed memory size exhausted. Там нужен построчный fetch() в цикле.
Транзакции: не только про деньги
Если одна бизнес-операция состоит из нескольких запросов и половина изменений без другой половины оставит базу в неверном состоянии — оборачивайте в транзакцию.
<?php
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE accounts SET balance = balance - :sum WHERE id = :id')
->execute(['sum' => 100, 'id' => 1]);
$pdo->prepare('UPDATE accounts SET balance = balance + :sum WHERE id = :id')
->execute(['sum' => 100, 'id' => 2]);
$pdo->commit();
} catch (\Throwable $e) {
// Откатываем всё, что успели сделать до ошибки
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
Проверка inTransaction() перед откатом не лишняя: вызов rollBack() без активной транзакции сам бросит исключение и замаскирует исходную ошибку.
Если не сработало
SQLSTATE[HY000] [2002] Connection refused— база недоступна по указанному host/port, либо неверный сокет. Проверьте, что MySQL запущен и DSN указывает на правильный адрес.SQLSTATE[42S22]: Column not foundпри использовании плейсхолдера вORDER BYилиLIMIT— вы пытаетесь параметризовать то, что параметризовать нельзя. См. раздел про белый список.Invalid parameter number— число плейсхолдеров не совпало с числом переданных значений. Частый случай —INс массивом или лишняя запятая.- Данные пришли как строки, хотя в базе int — включена эмуляция подготовленных запросов. Поставьте
ATTR_EMULATE_PREPARES = false. - Кириллица превратилась в «крякозябры» — забыли
charset=utf8mb4в DSN. Указывать кодировку в DSN надёжнее, чем отдельным запросомSET NAMES.
Что делать дальше
Вынесите создание PDO в одну фабрику или контейнер — не плодите подключения по файлам. Не показывайте текст исключения пользователю: логируйте через error_log() или PSR-3 логгер, а наружу отдавайте нейтральное сообщение. Если приложение растёт, посмотрите в сторону тонкой обёртки поверх PDO или query builder — но помните, что под капотом почти у всех PHP-инструментов лежит тот же PDO, и понимание его режимов остаётся полезным. Инвалидация подключений и пулинг — отдельная тема, здесь мы её не трогаем.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: PHP Manual: PDO — PHP Data Objects, PHP Manual: Prepared statements and stored procedures
Частые вопросы
У меня PHP 7.4, а не 8.x — приёмы подойдут?
Что безопаснее: именованные плейсхолдеры или позиционные (?)
Нужно ли закрывать соединение вручную через null?
Почему запрос падает с 'Invalid parameter number'?
EMULATE_PREPARES выключать всегда или бывают исключения?
Транзакция откатилась, а деньги списались — как так?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.