PHPFlowPHP, MySQL, Laravel и JavaScript

PDO в PHP: подключение к MySQL, подготовленные запросы и транзакции

Обновлено:

Коротко

PDO в PHP - встроенный класс для работы с базами данных: MySQL, PostgreSQL, SQLite и другими. Подключение к MySQL занимает пять строк: строка DSN с charset=utf8mb4, логин, пароль и режим ошибок через исключения. Все запросы с данными от пользователя выполняйте через prepare() и execute(): это защищает от SQL-инъекций.

Содержание
  1. Проверка, что PDO и драйвер MySQL включены
  2. Подключение к MySQL через PDO
  3. Подготовленные запросы: prepare и execute
  4. Выборка данных: fetch, fetchAll и режимы
  5. Вставка, обновление и удаление
  6. Транзакции в PDO
  7. Ошибки PDO и SQLSTATE: что означают
  8. PDO или mysqli: что выбрать
  9. Одно подключение на весь проект

Проверка, что 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 и pdo_mysql в командной строке Windows
Команда php -m показывает подключённые модули: для работы с MySQL в списке должен быть pdo_mysql

Если в списке нет pdo_mysql, включите его:

  1. Откройте php.ini, путь покажет команда php --ini.
  2. Найдите строку ;extension=pdo_mysql и уберите точку с запятой в начале.
  3. Сохраните файл и перезапустите веб-сервер.
  4. На 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
]);
Код подключения PDO к MySQL с опциями
Подключение к MySQL через PDO: DSN с кодировкой и три базовые опции
  • 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, если не найден
Схема работы подготовленного запроса PDO
Подготовленный запрос: текст SQL и данные идут в базу раздельно, поэтому инъекция невозможна

Плейсхолдер подставляет только значение. Имена таблиц, столбцов и направление сортировки так передать нельзя, их проверяют по белому списку:

$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;
}
Схема транзакции PDO с commit и rollBack
Транзакция: оба запроса применяются вместе, а при ошибке отменяются целиком

Транзакции работают только с таблицами InnoDB, это движок по умолчанию в MySQL 8. Команды, меняющие структуру (CREATE TABLE, ALTER TABLE), MySQL фиксирует сразу, откатить их через rollBack() нельзя.

Ошибки PDO и SQLSTATE: что означают

Все ошибки приходят исключением PDOException. В сообщении есть код SQLSTATE и номер ошибки MySQL, по ним легко найти причину.

СообщениеПричинаКак исправить
could not find driverНе включён pdo_mysqlВключить расширение и перезапустить сервер
[2002] Connection refusedMySQL не запущен или неверный портЗапустить службу, проверить host и port
[1045] Access denied for userНеверный логин, пароль или хост пользователяПроверить данные и права пользователя
[1049] Unknown databaseНет базы с таким именемСоздать базу или исправить dbname
[42S02] Base table not foundНет таблицыПроверить имя и выбранную базу
[23000] Duplicate entryПовтор значения в уникальном полеПроверять заранее или ловить код 1062
Ошибки could not find driver и Access denied при подключении PDO
Типичные ошибки подключения PDO и результат после исправления

Пользователю не показывайте текст исключения: в нём бывают имя базы и куски запроса. Пишите его в лог, а на экран выводите короткое сообщение. Как настроить лог и вывод на этапе разработки, рассказано в статье про вывод ошибок 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: что выбрать

КритерийPDOmysqli
Базы данных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.