Перейти к основному содержимому

PDO в PHP — подключение и безопасные запросы

Разработчику Архитектору

PDO (PHP Data Objects) — единый интерфейс доступа к разным СУБД через драйверы (pdo_mysql, pdo_pgsql, pdo_sqlite). Это выжимка для старта; расширенный разбор ORM, MySQLi и оптимизации — в Работа с базами данных из PHP.

Главная идея PDO: вы пишете одинаковый прикладной код для запросов, а различия между СУБД остаются в SQL и DSN.


Подключение

<?php
declare(strict_types=1);

$dsn = 'mysql:host=127.0.0.1;dbname=app;charset=utf8mb4';
$user = 'app_user';
$pass = 'secret';

$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);

Разбор:

  • declare(strict_types=1); включает строгую проверку скалярных типов для вызовов функций из этого файла.
  • Строка DSN задает драйвер, хост, имя базы и кодировку utf8mb4, чтобы корректно хранить Unicode и emoji.
  • Конструктор new PDO(...) открывает соединение с БД и возвращает объект доступа к запросам.
  • PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION переводит SQL-ошибки в исключения, чтобы их можно было перехватывать через try/catch.
  • PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC задает удобный формат выборки строк как ассоциативных массивов.
  • PDO::ATTR_EMULATE_PREPARES => false заставляет использовать нативные prepared statements драйвера, что улучшает предсказуемость и безопасность.
ПараметрНазначение
ERRMODE_EXCEPTIONОшибки SQL → PDOException, а не тихий сбой
FETCH_ASSOCСтроки как ассоциативные массивы
EMULATE_PREPARES falseНастоящие prepared statements на стороне MySQL

DSN для SQLite (удобно для учебных проектов без сервера MySQL):

$pdo = new PDO('sqlite:' . __DIR__ . '/data/app.sqlite', options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);

Разбор:

  • Префикс sqlite: в DSN означает подключение к файловой базе SQLite без отдельного серверного процесса.
  • __DIR__ дает абсолютный путь к каталогу текущего файла, поэтому путь к БД не зависит от текущей рабочей директории процесса.
  • Именованный аргумент options: передает массив атрибутов подключения, как и в варианте с MySQL.
  • Единственный атрибут ERRMODE_EXCEPTION сохраняет единый подход к обработке ошибок для разных СУБД.
  • Такой формат удобен для локальной разработки и учебных сценариев, где важно быстро поднять окружение.

Учётные данные хранят в переменных окружения или .env, не в репозитории.


Подготовленные запросы

Плейсхолдеры ? или именованные :name отделяют структуру SQL от данных. Это основная защита от SQL-инъекций.

Полезный практический шаблон:

  • SQL-строка хранится отдельно и не собирается конкатенацией.
  • Все пользовательские значения передаются вторым шагом в execute.
  • Любая ошибка обработки уходит в исключение и фиксируется логированием.
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email LIMIT 1');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch();

if ($user === false) {
// не найден
}

Разбор:

  • $pdo->prepare(...) компилирует SQL-шаблон с именованным параметром :email.
  • execute(['email' => $email]) безопасно привязывает значение к плейсхолдеру и запускает запрос.
  • fetch() читает одну строку результата в формате, заданном FETCH_ASSOC.
  • Проверка $user === false корректно различает состояние "строка не найдена" и "строка найдена".
  • LIMIT 1 ограничивает результат одной записью и уменьшает лишнюю работу БД.

Вставка:

$stmt = $pdo->prepare(
'INSERT INTO users (email, name, created_at) VALUES (:email, :name, :created_at)'
);
$stmt->execute([
'email' => $email,
'name' => $name,
'created_at' => (new DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
$newId = (int) $pdo->lastInsertId();

Разбор:

  • prepare(...) отделяет структуру INSERT от пользовательских данных.
  • execute([...]) передает значения полей и исключает ручную конкатенацию SQL-строк.
  • (new DateTimeImmutable())->format('Y-m-d H:i:s') формирует timestamp в формате, ожидаемом DATETIME.
  • lastInsertId() возвращает идентификатор только что вставленной строки для текущего соединения.
  • Явное приведение (int) делает тип идентификатора предсказуемым в прикладном коде.

Нельзя подставлять имена таблиц и колонок через плейсхолдеры — только значения. Для динамических имён — белый список в коде.


Выборка нескольких строк

$stmt = $pdo->prepare('SELECT id, title FROM posts WHERE user_id = :uid ORDER BY id DESC');
$stmt->execute(['uid' => $userId]);
$posts = $stmt->fetchAll();

Разбор:

  • Запрос выбирает посты только одного пользователя по параметру :uid.
  • ORDER BY id DESC сортирует выборку от новых записей к старым.
  • fetchAll() загружает весь результат сразу в память как массив строк.
  • Этот вариант удобен для небольших выборок, которые нужно показать целиком на странице.
  • Для больших наборов данных лучше переходить на поштучное чтение через fetch() в цикле.

Итерация без загрузки всего результата в память (большие таблицы):

$stmt = $pdo->query('SELECT id, body FROM logs'); // только доверенный SQL без пользовательских частей
while ($row = $stmt->fetch()) {
processLogLine($row);
}

Разбор:

  • query(...) выполняет SQL сразу, без отдельного шага prepare/execute.
  • Этот подход допустим только для полностью статичного, доверенного SQL без пользовательского ввода.
  • fetch() внутри while читает строки по одной и снижает пиковое потребление памяти.
  • Каждая строка сразу передается в processLogLine($row), что удобно для потоковой обработки логов.
  • Такой шаблон полезен для batch-задач и фоновых скриптов.

Обновление и удаление

$stmt = $pdo->prepare('UPDATE users SET name = :name WHERE id = :id');
$stmt->execute(['name' => $name, 'id' => $id]);
$updated = $stmt->rowCount();

$stmt = $pdo->prepare('DELETE FROM sessions WHERE expires_at < :now');
$stmt->execute(['now' => date('Y-m-d H:i:s')]);

Разбор:

  • Первый блок обновляет имя конкретного пользователя по id через безопасные параметры.
  • rowCount() после UPDATE показывает число затронутых строк и помогает проверить эффект операции.
  • Второй блок удаляет все просроченные сессии по сравнению с текущим временем сервера.
  • date('Y-m-d H:i:s') формирует строку в формате, совместимом с типом DATETIME.
  • Оба запроса остаются инъекционно-безопасными за счет prepare + execute.

rowCount() для SELECT в MySQL может вести себя неинтуитивно; для проверки "есть ли строка" надёжнее fetch().


Транзакции

Группа операций "всё или ничего":

$pdo->beginTransaction();
try {
$pdo->prepare('UPDATE accounts SET balance = balance - :sum WHERE id = :id')
->execute(['sum' => $sum, 'id' => $fromId]);
$pdo->prepare('UPDATE accounts SET balance = balance + :sum WHERE id = :id')
->execute(['sum' => $sum, 'id' => $toId]);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}

Разбор:

  • beginTransaction() открывает транзакцию и откладывает окончательную фиксацию изменений.
  • Первый UPDATE списывает сумму у отправителя, второй UPDATE зачисляет сумму получателю.
  • commit() фиксирует обе операции как единый атомарный шаг.
  • При любой ошибке управление переходит в catch, где rollBack() отменяет частично выполненные изменения.
  • Повторный throw $e поднимает исходную ошибку выше по стеку, сохраняя трассировку и причину сбоя.

При ERRMODE_EXCEPTION сбой execute прерывает блок и уходит в catch.

Транзакции особенно важны для денежных операций, изменения нескольких взаимосвязанных таблиц и миграций данных, где "частичный успех" недопустим.


Обработка ошибок

try {
$pdo = new PDO($dsn, $user, $pass, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
// ...
} catch (PDOException $e) {
error_log('Database: ' . $e->getMessage());
http_response_code(503);
echo 'Сервис временно недоступен';
}

Разбор:

  • try оборачивает подключение и рабочие операции, где может возникнуть PDOException.
  • В catch ошибка логируется через error_log(...), чтобы разработчик увидел детали в серверных логах.
  • http_response_code(503) возвращает корректный статус временной недоступности сервиса.
  • Пользователю выводится нейтральное сообщение без технических деталей, паролей и DSN.
  • Такой шаблон разделяет внутреннюю диагностику и безопасный внешний ответ.

Детали подключения (пароль, хост) не показывают пользователю. См. Исключения в прикладном коде.


PDO и MySQLi

PDOMySQLi
СУБДMySQL, PostgreSQL, SQLite, …В основном MySQL
СтильОдин API для всехПроцедурный и ООП
Подготовленные запросыДаДа

Для новых проектов на MySQL чаще выбирают PDO из-за единообразия и переносимости. MySQLi остаётся в легаси и узкоспециализированных случаях — см. полный раздел по БД.


Чек-лист перед продакшеном

  1. ATTR_ERRMODE = EXCEPTION
  2. Все пользовательские значения — только через prepare + execute
  3. Кодировка utf8mb4 в DSN MySQL
  4. Отдельный пользователь БД с минимальными правами (не root)
  5. Пароль и DSN — из окружения, не из git

Что изучить дальше