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

PHP / Базы данных и SQL

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

Подключение PDO, SELECT и INSERT с параметрами, транзакции, allowlist для сортировки, пагинация и обработка ошибок.

Опубликовано 04.09.2026 6 минут чтения
Содержание статьи

PDO предоставляет единый объектный интерфейс к разным СУБД, но SQL, типы и возможности драйверов всё равно различаются. Поддержка нескольких драйверов требует отдельной проверки каждого запроса; одно использование PDO не делает SQL переносимым автоматически.

В примерах используется PDO с именованными параметрами и режимом исключений.

Подключение

PHP
<?php​declare(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 с параметром

PHP
$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 в кавычки:

PHP
// Неверно: WHERE status = ':status'// Верно:   WHERE status = :status​

INSERT

PHP
$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 и количество строк

PHP
$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-идентификаторов:

SQL
-- Не сработает как динамическая сортировка.ORDER BY :column​

Для сортировки нужен жёсткий allowlist:

PHP
$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. Простой поиск по подстроке для согласованной СУБД может выглядеть так:

PHP
$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:

PHP
$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 по устойчивому уникальному порядку.

Транзакция

Если несколько записей должны измениться вместе:

PHP
$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-драйверов.

Сообщить об ошибке в статье

Рекомендации редакции

Другие публикации из этого раздела.

Новые материалы

Получайте практические разборы без информационного шума

Одна общая рассылка о новых статьях и инструментах. Адрес будет активироваться только после подтверждения.

Подписка появится после подключения сервиса рассылок и Yandex SmartCaptcha.