PDO в PHP: подключение к MySQL, подготовленные запросы и транзакции
Коротко
PDO в PHP - встроенный класс для работы с базами данных: MySQL, PostgreSQL, SQLite и другими. Подключение к MySQL занимает пять строк: строка DSN с charset=utf8mb4, логин, пароль и режим ошибок через исключения. Все запросы с данными от пользователя выполняйте через prepare() и execute(): это защищает от SQL-инъекций.
Содержание
Проверка, что PDO и драйвер MySQL включены
PDO PHP состоит из ядра и драйверов под каждую СУБД. Для MySQL и MariaDB нужен драйвер pdo_mysql. В большинстве сборок он уже включён, но в чистом PHP на Windows или в минимальном Docker-образе его может не быть.
# Linux и macOS
php -m | grep -i pdo
# Windows, командная строка
php -m | findstr /i pdo

Если в списке нет pdo_mysql, включите его:
- Откройте php.ini, путь покажет команда
php --ini. - Найдите строку
;extension=pdo_mysqlи уберите точку с запятой в начале. - Сохраните файл и перезапустите веб-сервер.
- На Ubuntu вместо правки php.ini установите пакет:
sudo apt install php-mysql.
Без драйвера подключение падает с ошибкой "could not find driver". В официальном образе Docker драйвер ставится строкой RUN docker-php-ext-install pdo_mysql в Dockerfile.
Подключение к MySQL через PDO
Для подключения нужны DSN (строка с адресом сервера и именем базы), пользователь и пароль. Сразу задайте три важные опции:
<?php
declare(strict_types=1);
$dsn = 'mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4';
$user = 'shop_user';
$pass = getenv('DB_PASSWORD') ?: '';
$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, // настоящие prepared statements
]);

charset=utf8mb4в DSN избавляет от "знаков вопроса" вместо русских букв и поддерживает эмодзи. Отдельный запросSET NAMESне нужен;ERRMODE_EXCEPTIONвключён по умолчанию с PHP 8.0, но явная настройка не помешает, если код запустят на чужом конфиге;EMULATE_PREPARES => falseзаставляет MySQL готовить запрос на своей стороне, и числа из базы приходят числами, а не строками.
Пароль не храните в коде, который попадает в Git: держите его в переменной окружения или в файле .env вне веб-корня. Как создать отдельного пользователя базы с нужными правами, описано в статье пользователи MySQL: создание и права.
Новый способ в PHP 8.4 и 8.5: PDO::connect и Pdo\Mysql
С PHP 8.4 у каждого драйвера есть свой подкласс: Pdo\Mysql, Pdo\Pgsql, Pdo\Sqlite. Метод PDO::connect() сам возвращает объект нужного класса по DSN. В PHP 8.5 старые константы вида PDO::MYSQL_ATTR_SSL_CA объявлены устаревшими, вместо них пишут Pdo\Mysql::ATTR_SSL_CA.
// PHP 8.4+
$pdo = PDO::connect($dsn, $user, $pass, [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
var_dump($pdo instanceof Pdo\Mysql); // bool(true)
Обычный new PDO(...) продолжает работать во всех версиях, так что переписывать старый код не обязательно.
Подготовленные запросы: prepare и execute
Никогда не вставляйте значения из $_GET, $_POST или cookie прямо в текст SQL. Вместо значения ставится плейсхолдер, а данные передаются отдельно, и база не спутает их с командой.
// именованные плейсхолдеры
$stmt = $pdo->prepare('SELECT id, name, price FROM products WHERE category_id = :cat AND price <= :max');
$stmt->execute(['cat' => 3, 'max' => 5000]);
$products = $stmt->fetchAll();
// позиционные плейсхолдеры
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$email]);
$user = $stmt->fetch(); // массив или false, если не найден

Плейсхолдер подставляет только значение. Имена таблиц, столбцов и направление сортировки так передать нельзя, их проверяют по белому списку:
$allowed = ['name', 'price', 'created_at'];
$sort = in_array($_GET['sort'] ?? '', $allowed, true) ? $_GET['sort'] : 'name';
$stmt = $pdo->prepare("SELECT * FROM products ORDER BY {$sort} LIMIT :limit OFFSET :offset");
$stmt->bindValue('limit', 20, PDO::PARAM_INT);
$stmt->bindValue('offset', 40, PDO::PARAM_INT);
$stmt->execute();
Для LIMIT и OFFSET используйте bindValue() с PDO::PARAM_INT. При включённой эмуляции prepared statements значение из execute([...]) подставится в кавычках, и MySQL вернёт ошибку синтаксиса.
Список значений в IN
Для WHERE id IN (...) плейсхолдеров нужно столько, сколько элементов в массиве:
$ids = [4, 8, 15];
$marks = implode(',', array_fill(0, count($ids), '?')); // ?,?,?
$stmt = $pdo->prepare("SELECT id, name FROM products WHERE id IN ({$marks})");
$stmt->execute($ids);
Выборка данных: fetch, fetchAll и режимы
Результат запроса читают методами объекта PDOStatement. Режим выборки определяет, в каком виде придёт строка.
| Вызов | Что вернёт | Пример применения |
|---|---|---|
fetch() | Одну строку или false | Профиль пользователя по id |
fetchAll() | Массив всех строк | Список товаров на странице |
fetchColumn() | Значение первого столбца | COUNT(*), одно поле |
fetchAll(PDO::FETCH_KEY_PAIR) | Массив "ключ → значение" из двух столбцов | Список для select |
fetchAll(PDO::FETCH_COLUMN) | Плоский список одного столбца | Все email рассылки |
fetchAll(PDO::FETCH_CLASS, Product::class) | Объекты вашего класса | Модели без ORM |
// сколько всего заказов
$total = (int) $pdo->query('SELECT COUNT(*) FROM orders')->fetchColumn();
// id => название для выпадающего списка
$cats = $pdo->query('SELECT id, title FROM categories ORDER BY title')
->fetchAll(PDO::FETCH_KEY_PAIR);
// перебор большой выборки без загрузки всего в память
$stmt = $pdo->query('SELECT id, email FROM users');
foreach ($stmt as $row) {
echo $row['email'], PHP_EOL;
}
Метод query() без плейсхолдеров допустим только для запросов, в которых нет внешних данных. Если строки нужно отдать в JavaScript, превратите результат fetchAll() в JSON, как описано в статье JSON в PHP.
Вставка, обновление и удаление
Изменяющие запросы тоже выполняются через prepare(). После вставки lastInsertId() вернёт номер новой строки, а rowCount() покажет, сколько строк затронуто.
// INSERT
$stmt = $pdo->prepare('INSERT INTO users (name, email, created_at) VALUES (:name, :email, NOW())');
$stmt->execute(['name' => 'Ирина', 'email' => 'irina@example.com']);
$newId = (int) $pdo->lastInsertId();
// UPDATE
$stmt = $pdo->prepare('UPDATE products SET price = :price WHERE id = :id');
$stmt->execute(['price' => 3990, 'id' => 12]);
echo $stmt->rowCount(); // 1, если строка изменилась
// DELETE
$pdo->prepare('DELETE FROM sessions WHERE expires_at < NOW()')->execute();
Подготовленный запрос можно выполнить много раз с разными данными. Для массовой вставки это быстрее, чем готовить запрос заново в каждой итерации, особенно внутри транзакции:
$stmt = $pdo->prepare('INSERT INTO tags (name) VALUES (?)');
foreach (['php', 'mysql', 'pdo'] as $tag) {
$stmt->execute([$tag]);
}
Помните, что rowCount() для UPDATE в MySQL возвращает 0, если новое значение совпало со старым. Это не ошибка: строка найдена, но не изменилась.
Транзакции в PDO
Транзакция выполняет несколько запросов как одно целое: либо все, либо ни одного. Классический пример - перевод денег между счетами или создание заказа вместе со списанием остатков.
try {
$pdo->beginTransaction();
$pdo->prepare('UPDATE accounts SET balance = balance - :sum WHERE id = :from')
->execute(['sum' => 1500, 'from' => 1]);
$pdo->prepare('UPDATE accounts SET balance = balance + :sum WHERE id = :to')
->execute(['sum' => 1500, 'to' => 2]);
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack(); // отменяем обе операции
}
throw $e;
}

Транзакции работают только с таблицами InnoDB, это движок по умолчанию в MySQL 8. Команды, меняющие структуру (CREATE TABLE, ALTER TABLE), MySQL фиксирует сразу, откатить их через rollBack() нельзя.
Ошибки PDO и SQLSTATE: что означают
Все ошибки приходят исключением PDOException. В сообщении есть код SQLSTATE и номер ошибки MySQL, по ним легко найти причину.
| Сообщение | Причина | Как исправить |
|---|---|---|
| could not find driver | Не включён pdo_mysql | Включить расширение и перезапустить сервер |
| [2002] Connection refused | MySQL не запущен или неверный порт | Запустить службу, проверить host и port |
| [1045] Access denied for user | Неверный логин, пароль или хост пользователя | Проверить данные и права пользователя |
| [1049] Unknown database | Нет базы с таким именем | Создать базу или исправить dbname |
| [42S02] Base table not found | Нет таблицы | Проверить имя и выбранную базу |
| [23000] Duplicate entry | Повтор значения в уникальном поле | Проверять заранее или ловить код 1062 |

Пользователю не показывайте текст исключения: в нём бывают имя базы и куски запроса. Пишите его в лог, а на экран выводите короткое сообщение. Как настроить лог и вывод на этапе разработки, рассказано в статье про вывод ошибок PHP.
try {
$stmt = $pdo->prepare('INSERT INTO users (email) VALUES (?)');
$stmt->execute([$email]);
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) === 1062) {
echo 'Такой email уже зарегистрирован';
} else {
error_log($e->getMessage());
echo 'Не удалось сохранить данные';
}
}
Ошибка 2002 на локальном сервере часто связана с тем, что localhost заставляет PHP искать сокет. Замена на 127.0.0.1 в DSN обычно решает проблему, подробности в статье ERROR 2002: Can't connect to MySQL server (на английском).
PDO или mysqli: что выбрать
| Критерий | PDO | mysqli |
|---|---|---|
| Базы данных | MySQL, PostgreSQL, SQLite, SQL Server и другие | Только MySQL и MariaDB |
| Именованные плейсхолдеры | Есть | Нет, только ? |
| Стиль | Только объектный | Объектный и процедурный |
| Возможности MySQL | Основные | Полный набор, включая асинхронные запросы |
Для нового проекта на чистом PHP берите PDO: код проще перенести на другую СУБД, и он же используется внутри Laravel и Symfony. mysqli оправдан в старом коде, который уже на нём написан. Функции mysql_* удалены ещё в PHP 7.0 и не работают.
Одно подключение на весь проект
Создавать новое подключение в каждой функции не нужно: это лишняя нагрузка на сервер базы. Удобно вынести его в отдельный файл и подключать там, где нужна база:
// src/db.php
function db(): PDO
{
static $pdo = null;
if ($pdo === null) {
$cfg = require __DIR__ . '/../config/database.php';
$pdo = new PDO($cfg['dsn'], $cfg['user'], $cfg['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
return $pdo;
}
// где угодно в коде
$user = db()->prepare('SELECT * FROM users WHERE id = ?');
Соединение закрывается само в конце скрипта. Закрыть раньше можно присвоением $pdo = null, если на объект не осталось других ссылок. Посмотреть структуру таблиц и проверить, что данные записались, удобно в phpMyAdmin.
Частые вопросы
Как подключиться к MySQL через PDO?
Создайте объект new PDO('mysql:host=127.0.0.1;dbname=имя;charset=utf8mb4', $user, $pass) и передайте опцию PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, чтобы ошибки приходили исключениями.
Что делать при ошибке could not find driver?
Включите расширение pdo_mysql: раскомментируйте extension=pdo_mysql в php.ini или установите пакет php-mysql на Ubuntu, затем перезапустите сервер.
Чем prepare отличается от query в PDO?
query() сразу выполняет готовый текст SQL, а prepare() с execute() передаёт данные отдельно от запроса. Для любых внешних данных используйте только prepare().Как получить id добавленной записи?
Сразу после INSERT вызовите $pdo->lastInsertId(). Метод возвращает строку, при необходимости приведите её к int.
Почему вместо русских букв в базе знаки вопроса?
В DSN не указана кодировка. Добавьте charset=utf8mb4 и проверьте, что таблица и столбцы тоже в utf8mb4.



