Работа с базой данных в PHP через PDO: запросы и защита от инъекций

Работа с базой данных в PHP через PDO: запросы и защита от инъекций Полезное

Работа с базой данных в PHP — это подключение скрипта к СУБД (чаще всего MySQL или PostgreSQL), отправка SQL-запросов и получение результата через встроенный слой доступа. Начиная с PHP 7 рекомендуемый способ — расширение PDO (PHP Data Objects): единый интерфейс к разным СУБД плюс подготовленные выражения, которые закрывают главную угрозу — SQL-инъекции.

Ниже — рабочий пример подключения, выборка данных, подготовленные выражения и разбор инъекции на конкретном коде. В конце — таблица PDO vs mysqli и что делать, когда подключение не работает.

Актуальность на сентябрь 2026: стабильная ветка — PHP 8.5, ветка 8.4 еще на активной поддержке. Примеры совместимы с PHP 8.1 и выше.

Подключение к базе данных: минимальный рабочий пример

Соберем весь код подключения целиком — его можно скопировать и запустить, дальше разберем по частям.

<?php
$host    = '127.0.0.1';
$db      = 'shop';
$user    = 'app_user';
$pass    = getenv('DB_PASS'); // пароль из переменной окружения, не в коде
$charset = 'utf8mb4';

$dsn = "mysql:host=$host;dbname=$db;charset=$charset";

$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION, // ошибки как исключения
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,       // выборка в ассоциативный массив
    PDO::ATTR_EMULATE_PREPARES   => false,                 // настоящие подготовленные выражения
];

try {
    $pdo = new PDO($dsn, $user, $pass, $options);
    echo "Подключение установлено\n";
} catch (PDOException $e) {
    error_log('DB connect failed: ' . $e->getMessage()); // детали - в лог
    exit('Сервис временно недоступен');                   // пользователю - общее сообщение
}

При успехе в консоли появится Подключение установлено. Разберем ключевые места.

DSN (data source name) — строка вида mysql:host=...;dbname=...;charset=.... Драйвер в начале (mysql:) определяет СУБД; для PostgreSQL это будет pgsql:. Кодировку utf8mb4 задаем в DSN, а не отдельным запросом.

Три опции стоит выставлять всегда. ERRMODE_EXCEPTION заставляет PDO бросать исключение при любой ошибке — иначе провал запроса можно молча пропустить. FETCH_ASSOC задает выборку по умолчанию в массив с именами столбцов в ключах. EMULATE_PREPARES => false включает подготовку выражений на стороне сервера, а не эмуляцию внутри PHP.

Пароль берем из переменной окружения через getenv(), а не пишем в коде: исходники попадают в репозиторий, а секреты — нет.

Выборка данных: query, fetch и fetchAll

Для запроса без внешних параметров подойдет query(). Результат можно перебрать в цикле:

$stmt = $pdo->query('SELECT id, title, price FROM products ORDER BY id LIMIT 3');

foreach ($stmt as $row) {
    echo "{$row['id']}: {$row['title']} - {$row['price']} руб.\n";
}

Ожидаемый вывод (зависит от содержимого таблицы):

1: Клавиатура - 1290 руб.
2: Мышь - 690 руб.
3: Монитор - 15990 руб.

Когда нужна одна строка, берут fetch() — он вернет массив или false, если строк нет. Для всех строк сразу есть fetchAll(), но на больших выборках он держит весь результат в памяти, поэтому для тысяч строк лучше цикл foreach.

SQL-инъекция на конкретном коде: уязвимость, результат, исправление

Главная опасность появляется, когда в запрос попадают данные от пользователя. Покажем ошибку тройкой.

Шаг 1. Уязвимый код — логин из запроса вклеен в строку SQL через конкатенацию:

$login = $_GET['login']; // пришло из формы или URL
$sql   = "SELECT id, login FROM users WHERE login = '$login'";
$stmt  = $pdo->query($sql);
$user  = $stmt->fetch();

Шаг 2. Что происходит при вредоносном вводе. Пусть пользователь передал login со значением ' OR '1'='1. Тогда итоговый SQL становится таким:

SELECT id, login FROM users WHERE login = '' OR '1'='1'

Условие '1'='1' истинно всегда, поэтому запрос вернет первую строку таблицы users независимо от логина. Более опасные варианты ввода ('; DROP TABLE users; -- и подобные) позволяют менять и удалять данные. Это и есть SQL-инъекция: пользователь дописал логику в ваш запрос.

Шаг 3. Исправление — подготовленное выражение (prepared statement). Значение передается отдельно от текста запроса, на месте данных стоит плейсхолдер ?:

$login = $_GET['login'];
$stmt  = $pdo->prepare('SELECT id, login FROM users WHERE login = ?');
$stmt->execute([$login]);
$user  = $stmt->fetch();

echo $user ? "Найден: {$user['login']}\n" : "Пользователь не найден\n";

Теперь СУБД сначала получает шаблон запроса, а потом данные. Что бы ни пришло в $login, оно трактуется как одно строковое значение, а не как часть SQL. При вводе ' OR '1'='1 запрос просто не найдет такого логина и вернет Пользователь не найден.

Плейсхолдеры бывают позиционные (?) и именованные (:login). Именованные удобнее в больших запросах:

$stmt = $pdo->prepare(
    'INSERT INTO products (title, price) VALUES (:title, :price)'
);
$stmt->execute([
    ':title' => 'Веб-камера',
    ':price' => 3990,
]);
echo 'Добавлена запись id=' . $pdo->lastInsertId() . "\n";

Метод lastInsertId() вернет идентификатор только что вставленной строки, если у таблицы есть автоинкрементный ключ.

Граница защиты: что плейсхолдеры не закрывают

Подготовленные выражения подставляют только значения. Имена таблиц, столбцов, ключевые слова и направление сортировки через плейсхолдер передать нельзя — такой код выдаст ошибку синтаксиса. Если имя столбца для ORDER BY приходит извне, его нельзя биндить; для этого нужен белый список допустимых значений:

$allowed = ['price', 'title', 'id'];         // разрешенные столбцы
$sortBy  = $_GET['sort'] ?? 'id';
$column  = in_array($sortBy, $allowed, true) ? $sortBy : 'id'; // иначе значение по умолчанию

$stmt = $pdo->query("SELECT id, title FROM products ORDER BY $column");

Здесь безопасность держится не на биндинге, а на проверке значения по списку $allowed. Прямая подстановка имени столбца допустима только после такой проверки.

Обработка ошибок через исключения PDO

С опцией ERRMODE_EXCEPTION любой сбой запроса бросает PDOException. Это позволяет обернуть операции в try/catch и не проверять код возврата после каждого вызова.

try {
    $pdo->beginTransaction();

    $stmt = $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?');
    $stmt->execute([100, 1]);

    $stmt = $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?');
    $stmt->execute([100, 2]);

    $pdo->commit(); // обе операции применяются вместе
} catch (PDOException $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack(); // откатываем, только если транзакция реально открыта
    }
    error_log('Transfer failed: ' . $e->getMessage());
}

Важное правило безопасности: текст исключения содержит фрагменты запроса и структуру БД, поэтому его пишут в лог, а пользователю показывают нейтральное сообщение. Показывать сырую ошибку на странице нельзя — это подсказка атакующему.

PDO или mysqli: что выбрать

Оба расширения поддерживают подготовленные выражения и безопасны при правильном использовании. Разница — в универсальности и API.

Критерий PDO mysqli
Поддержка СУБД много драйверов (MySQL, PostgreSQL, SQLite и другие) только MySQL и MariaDB
Стиль только объектный объектный и процедурный
Именованные плейсхолдеры (:name) есть нет, только ?
Смена СУБД без переписывания запросов да, при переносимом SQL нет
Когда брать новый проект, вероятна смена или несколько СУБД проект уже завязан только на MySQL

Для нового кода по умолчанию берут PDO: единый интерфейс и именованные параметры удобнее в поддержке. Расширение mysql_* (без i) удалено еще в PHP 7.0 и в современных проектах не используется — примеры с mysql_connect() из старых статей устарели.

Если подключение не работает

Диагностика по симптому, как обычно и ищут ошибку:

  • SQLSTATE[HY000] [1045] Access denied — неверный логин или пароль пользователя БД, либо у него нет прав на эту базу.
  • SQLSTATE[HY000] [2002] Connection refused — сервер БД не запущен или неверный host/port в DSN.
  • SQLSTATE[HY000] [1049] Unknown database — имя базы в dbname не совпадает с существующей.
  • could not find driver — не включено PDO-расширение драйвера (pdo_mysql), проверьте вывод php -m.

Отдельная защита на уровне доступов: приложение должно ходить в базу под пользователем с минимально нужными правами (например, только SELECT/INSERT/UPDATE на свои таблицы), а не под root. Тогда даже при найденной уязвимости ущерб ограничен правами этого пользователя.

Выводы

  • Для новых проектов на PHP базовый выбор — PDO: единый интерфейс к разным СУБД и удобные подготовленные выражения.
  • Данные от пользователя никогда не вклеивают в SQL через конкатенацию — только через плейсхолдеры ? или :name, иначе возможна SQL-инъекция.
  • Плейсхолдеры подставляют лишь значения; имена столбцов и таблиц проверяют по белому списку.
  • Режим ERRMODE_EXCEPTION плюс try/catch дают предсказуемую обработку ошибок; текст исключения пишут в лог, а не показывают пользователю.
  • Пароль БД держат в переменной окружения, а приложение ходит в базу под пользователем с минимальными правами.

Где применяется / связь с практикой

Работа с БД — основа почти любого backend-кода на PHP: регистрация и вход пользователей, каталоги товаров, комментарии, поиск, корзина. Именно на стыке пользовательского ввода и SQL чаще всего и появляются уязвимости, поэтому подготовленные выражения и разграничение прав — не теория, а ежедневная практика разработчика.

Освойте тему на практике

Разобрать PDO, транзакции и защиту от инъекций на реальных задачах помогает курс Разработчик PHP. Посмотреть формат занятий и темы можно на открытых уроках Otus — они бесплатные и проходят регулярно.

FAQ

Защищает ли PDO от SQL-инъекций сам по себе?
Нет. Защищают именно подготовленные выражения (prepare + execute). Если собирать запрос конкатенацией и отдавать в query(), PDO так же уязвим, как и любой другой способ.

Можно ли подставить имя таблицы или столбца через плейсхолдер?
Нет, плейсхолдеры работают только для значений. Имя столбца или таблицы проверяют по белому списку допустимых вариантов и подставляют в текст запроса вручную.

Чем query() отличается от prepare() с execute()?
query() сразу выполняет готовую строку SQL и годится только для запросов без внешних данных. Для любых значений от пользователя используют prepare() с плейсхолдерами и execute().

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)