PHP / Базы данных и SQL
Работа с базой данных в PHP через PDO: безопасные запросы и транзакции
Подключение PDO, SELECT и INSERT с параметрами, транзакции, allowlist для сортировки, пагинация и обработка ошибок.
Содержание статьи
PDO предоставляет единый объектный интерфейс к разным СУБД, но SQL, типы и возможности драйверов всё равно различаются. Поддержка нескольких драйверов требует отдельной проверки каждого запроса; одно использование PDO не делает SQL переносимым автоматически.
В примерах используется PDO с именованными параметрами и режимом исключений.
Подключение
<?phpdeclare(strict_types=1);$dsn = getenv('DB_DSN');$user = getenv('DB_USER') ?: null;$password = getenv('DB_PASSWORD') ?: null;if (!is_string($dsn) || $dsn === '') { throw new RuntimeException('Переменная DB_DSN не настроена.');}$database = new PDO($dsn, $user, $password, [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false,]);Пример DSN для MySQL:
mysql:host=127.0.0.1;port=3306;dbname=app;charset=utf8mb4Для SQLite:
sqlite:C:\absolute\path\database.sqliteСекреты хранятся в environment или игнорируемой локальной конфигурации. Не добавляйте пароль в Git и не показывайте DSN в публичной ошибке.
SELECT с параметром
$statement = $database->prepare( 'SELECT id, title, status, created_at FROM articles WHERE status = :status ORDER BY created_at DESC');$statement->execute(['status' => 'published']);$articles = $statement->fetchAll();Параметр передаётся отдельно от SQL. Не заключайте placeholder в кавычки:
// Неверно: WHERE status = ':status'// Верно: WHERE status = :statusINSERT
$statement = $database->prepare( 'INSERT INTO articles (title, slug, status, created_at) VALUES (:title, :slug, :status, :created_at)');$statement->execute([ 'title' => $title, 'slug' => $slug, 'status' => 'draft', 'created_at' => gmdate('Y-m-d H:i:s'),]);$articleId = (int) $database->lastInsertId();Перед запросом значения всё равно валидируют. Prepared statement защищает границу SQL, но не проверяет длину заголовка, формат slug и права редактора.
UPDATE и количество строк
$statement = $database->prepare( 'UPDATE articles SET status = :status WHERE id = :id AND status = :expected_status');$statement->bindValue(':status', 'published', PDO::PARAM_STR);$statement->bindValue(':id', $articleId, PDO::PARAM_INT);$statement->bindValue(':expected_status', 'draft', PDO::PARAM_STR);$statement->execute();if ($statement->rowCount() !== 1) { throw new DomainException('Статья не найдена или её статус уже изменился.');}Поведение rowCount() для SELECT не переносимо между драйверами. Используйте его для изменяющего запроса только с пониманием особенностей выбранной СУБД.
Нельзя параметризовать имя столбца
Placeholder предназначен для значений, а не SQL-идентификаторов:
-- Не сработает как динамическая сортировка.ORDER BY :columnДля сортировки нужен жёсткий allowlist:
$allowedSorts = [ 'newest' => 'created_at DESC', 'oldest' => 'created_at ASC', 'title' => 'title ASC',];$sortKey = is_string($_GET['sort'] ?? null) ? $_GET['sort'] : 'newest';$orderBy = $allowedSorts[$sortKey] ?? $allowedSorts['newest'];$sql = 'SELECT id, title, created_at FROM articles ORDER BY ' . $orderBy;$articles = $database->query($sql)->fetchAll();В SQL попадает только заранее написанный фрагмент, а не строка пользователя.
Поиск с LIKE
Символы % и _ имеют специальное значение. Если поиск должен трактовать ввод буквально, экранируйте их согласно выбранному драйверу и объявите ESCAPE. Простой поиск по подстроке для согласованной СУБД может выглядеть так:
$query = is_string($_GET['q'] ?? null) ? trim($_GET['q']) : '';if (mb_strlen($query) > 100) { throw new InvalidArgumentException('Слишком длинный поисковый запрос.');}$statement = $database->prepare( 'SELECT id, title FROM articles WHERE title LIKE :query ORDER BY title ASC LIMIT 50');$statement->execute(['query' => '%' . $query . '%']);Этот пример разрешает % и _ как wildcard. Если продукт ожидает буквальный ввод, это поведение нужно изменить, а не скрывать.
Пагинация
Числа сначала валидируют, затем привязывают как integer:
$page = filter_input(INPUT_GET, 'page', FILTER_VALIDATE_INT, [ 'options' => [ 'min_range' => 1, 'max_range' => 1_000_000, ],]);$page = is_int($page) ? $page : 1;$limit = 20;$offset = ($page - 1) * $limit;$statement = $database->prepare( 'SELECT id, title FROM articles ORDER BY id DESC LIMIT :limit OFFSET :offset');$statement->bindValue(':limit', $limit, PDO::PARAM_INT);$statement->bindValue(':offset', $offset, PDO::PARAM_INT);$statement->execute();Для очень больших наборов offset-пагинация становится дорогой; тогда рассматривают keyset pagination по устойчивому уникальному порядку.
Транзакция
Если несколько записей должны измениться вместе:
$database->beginTransaction();try { $order = $database->prepare( 'INSERT INTO orders (user_id, total) VALUES (:user_id, :total)' ); $order->execute([ 'user_id' => $userId, 'total' => $total, ]); $orderId = (int) $database->lastInsertId(); $item = $database->prepare( 'INSERT INTO order_items (order_id, product_id, quantity) VALUES (:order_id, :product_id, :quantity)' ); foreach ($items as $productId => $quantity) { $item->execute([ 'order_id' => $orderId, 'product_id' => $productId, 'quantity' => $quantity, ]); } $database->commit();} catch (Throwable $error) { if ($database->inTransaction()) { $database->rollBack(); } throw $error;}Проверки существования товаров, цен и остатков должны выполняться в согласованной транзакционной модели выбранной СУБД.
Ошибки и логирование
В production пользователю показывают нейтральное сообщение и request ID. PDOException с SQL и деталями подключения нельзя выводить в браузер. Записывайте исключение в защищённый лог, избегая паролей и чувствительных значений.
Не подавляйте ошибки оператором @ и не продолжайте выполнение после частичной записи.
Миграции и ограничения
Схема должна задавать NOT NULL, внешние ключи, UNIQUE и индексы. Проверка приложения улучшает сообщение пользователю, но база остаётся последней защитой целостности.
Применённые миграции не редактируют: изменение оформляют новой forward-only миграцией и проверяют на копии данных.
Что запомнить
Передавайте значения через prepared statements, а динамические идентификаторы выбирайте только из allowlist. Валидируйте ввод до SQL, используйте транзакцию для связных операций и не показывайте внутренние исключения пользователю. PDO унифицирует API подключения, но не отменяет различия SQL-драйверов.