php

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

PHP PDO (PHP Data Objects) — это расширение для работы с базами данных через единый интерфейс. В отличие от устаревших mysql_* функций и более низкоуровневого mysqli, PDO предоставляет абстракцию над СУБД, встроенную защиту от SQL-инъекций через подготовленные запросы и удобную работу с транзакциями. В этой статье разберём всё от подключения до продвинутых паттернов работы с данными.

Подключение к базе данных через PDO

Для создания соединения используется конструктор класса PDO, которому передаётся строка DSN (Data Source Name), логин и пароль. Важно настроить режим обработки ошибок сразу при создании соединения:

<?php

// DSN-строка: тип_бд:host=хост;dbname=имя_бд;charset=кодировка
$dsn = 'mysql:host=localhost;dbname=myapp;charset=utf8mb4';

$options = [
    // Бросать исключения при ошибках (обязательно!)
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    // Возвращать результаты в виде ассоциативных массивов по умолчанию
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    // Отключить эмуляцию подготовленных запросов (нативные prepared statements)
    PDO::ATTR_EMULATE_PREPARES   => false,
];

try {
    $pdo = new PDO($dsn, 'db_user', 'db_password', $options);
} catch (PDOException $e) {
    // Никогда не выводите $e->getMessage() пользователю — там учётные данные!
    error_log($e->getMessage());
    throw new RuntimeException('Ошибка подключения к базе данных');
}

Важно: PDO::ATTR_EMULATE_PREPARES => false заставляет PDO использовать настоящие подготовленные запросы на стороне MySQL, а не эмулировать их в PHP. Это повышает безопасность и позволяет MySQL оптимизировать повторно выполняемые запросы.

Рекомендуется вынести подключение в отдельный класс-синглтон или использовать контейнер зависимостей:

<?php

class Database
{
    private static ?PDO $instance = null;

    public static function getConnection(): PDO
    {
        if (self::$instance === null) {
            $dsn = sprintf(
                'mysql:host=%s;dbname=%s;charset=utf8mb4',
                $_ENV['DB_HOST'] ?? 'localhost',
                $_ENV['DB_NAME'] ?? 'myapp'
            );

            self::$instance = new PDO(
                $dsn,
                $_ENV['DB_USER'] ?? 'root',
                $_ENV['DB_PASS'] ?? '',
                [
                    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
                    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
                    PDO::ATTR_EMULATE_PREPARES   => false,
                ]
            );
        }

        return self::$instance;
    }

    // Запрещаем клонирование и прямое создание экземпляра
    private function __construct() {}
    private function __clone() {}
}

// Использование:
$pdo = Database::getConnection();

Подготовленные запросы: защита от SQL-инъекций

Подготовленные запросы — главная причина использовать PDO. Они полностью исключают SQL-инъекции, разделяя структуру запроса и данные. PDO поддерживает два синтаксиса плейсхолдеров:

<?php

$pdo = Database::getConnection();

// === Именованные плейсхолдеры (:name) — предпочтительный вариант ===

$stmt = $pdo->prepare(
    'SELECT id, name, email FROM users WHERE email = :email AND status = :status'
);

// Вариант 1: bindValue (значение привязывается сразу)
$stmt->bindValue(':email', $email, PDO::PARAM_STR);
$stmt->bindValue(':status', 1, PDO::PARAM_INT);
$stmt->execute();

// Вариант 2: передать массив в execute() — короче и удобнее
$stmt = $pdo->prepare(
    'SELECT id, name, email FROM users WHERE email = :email AND status = :status'
);
$stmt->execute([':email' => $email, ':status' => 1]);

// === Позиционные плейсхолдеры (?) — удобны для коротких запросов ===

$stmt = $pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$userId]);

Никогда не подставляйте пользовательские данные в SQL напрямую через конкатенацию строк. Сравните:

<?php

// НЕПРАВИЛЬНО — уязвимость SQL-инъекции!
// Если $name = "'; DROP TABLE users; --", база данных будет уничтожена
$pdo->query("SELECT * FROM users WHERE name = '$name'");

// ПРАВИЛЬНО — данные передаются отдельно от запроса
$stmt = $pdo->prepare('SELECT * FROM users WHERE name = :name');
$stmt->execute([':name' => $name]);

Динамические списки IN() — одно из неудобных мест PDO. Плейсхолдер не поддерживает массивы напрямую, поэтому нужно генерировать плейсхолдеры динамически:

<?php

// Выборка пользователей по списку ID
$ids = [1, 5, 12, 34];

// Генерируем нужное количество плейсхолдеров: ?,?,?,?
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$sql = "SELECT * FROM users WHERE id IN ($placeholders)";

$stmt = $pdo->prepare($sql);
$stmt->execute($ids);
$users = $stmt->fetchAll();

Выборка данных: fetch, fetchAll, fetchColumn

PDO предоставляет несколько методов для получения результатов запроса. Выбор метода зависит от объёма данных и структуры результата:

<?php

$stmt = $pdo->prepare('SELECT * FROM articles WHERE category_id = :cat ORDER BY created_at DESC');
$stmt->execute([':cat' => $categoryId]);

// fetchAll() — все строки сразу в массив (осторожно с большими таблицами!)
$articles = $stmt->fetchAll();
// Результат: [['id' => 1, 'title' => '...'], ['id' => 2, ...], ...]

// fetch() — по одной строке в цикле (экономит память)
$stmt->execute([':cat' => $categoryId]);
while ($row = $stmt->fetch()) {
    echo $row['title'] . "\n";
}

// fetchColumn() — одно значение (первый столбец первой строки)
$stmt = $pdo->prepare('SELECT COUNT(*) FROM articles WHERE category_id = :cat');
$stmt->execute([':cat' => $categoryId]);
$count = $stmt->fetchColumn(); // вернёт число, например 42

// PDO::FETCH_CLASS — заполнить свойства объекта
$stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $userId]);
$user = $stmt->fetchObject(User::class);
// Аналог через fetchAll:
$users = $stmt->fetchAll(PDO::FETCH_CLASS, User::class);

// PDO::FETCH_KEY_PAIR — массив ключ=>значение из двух столбцов
$stmt = $pdo->query('SELECT id, name FROM categories');
$categories = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
// Результат: [1 => 'PHP', 2 => 'JavaScript', ...]

// PDO::FETCH_COLUMN — массив значений одного столбца
$stmt = $pdo->query('SELECT title FROM articles');
$titles = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
// Результат: ['Статья 1', 'Статья 2', ...]

INSERT, UPDATE, DELETE и получение lastInsertId

Для модифицирующих запросов после execute() используйте rowCount() для проверки затронутых строк и lastInsertId() для получения ID новой записи:

<?php

// INSERT
$stmt = $pdo->prepare(
    'INSERT INTO users (name, email, password, created_at) VALUES (:name, :email, :password, NOW())'
);
$stmt->execute([
    ':name'     => $name,
    ':email'    => $email,
    ':password' => password_hash($password, PASSWORD_BCRYPT),
]);

$newUserId = (int) $pdo->lastInsertId();
echo "Создан пользователь с ID: $newUserId";

// UPDATE
$stmt = $pdo->prepare(
    'UPDATE users SET name = :name, email = :email, updated_at = NOW() WHERE id = :id'
);
$stmt->execute([':name' => $name, ':email' => $email, ':id' => $userId]);

if ($stmt->rowCount() === 0) {
    // Либо запись не найдена, либо данные не изменились
    echo 'Запись не обновлена';
}

// DELETE
$stmt = $pdo->prepare('DELETE FROM users WHERE id = :id');
$stmt->execute([':id' => $userId]);

$deleted = $stmt->rowCount(); // 1 если удалено, 0 если не найдено

Транзакции: atomарное выполнение нескольких запросов

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

<?php

// Пример: перевод средств между счетами
function transfer(PDO $pdo, int $fromId, int $toId, float $amount): bool
{
    $pdo->beginTransaction();

    try {
        // Списываем с отправителя
        $stmt = $pdo->prepare(
            'UPDATE accounts SET balance = balance - :amount WHERE id = :id AND balance >= :amount'
        );
        $stmt->execute([':amount' => $amount, ':id' => $fromId]);

        if ($stmt->rowCount() === 0) {
            throw new RuntimeException('Недостаточно средств или счёт не найден');
        }

        // Начисляем получателю
        $stmt = $pdo->prepare(
            'UPDATE accounts SET balance = balance + :amount WHERE id = :id'
        );
        $stmt->execute([':amount' => $amount, ':id' => $toId]);

        if ($stmt->rowCount() === 0) {
            throw new RuntimeException('Счёт получателя не найден');
        }

        // Записываем в историю операций
        $stmt = $pdo->prepare(
            'INSERT INTO transactions (from_id, to_id, amount, created_at) VALUES (:from, :to, :amount, NOW())'
        );
        $stmt->execute([':from' => $fromId, ':to' => $toId, ':amount' => $amount]);

        // Всё прошло успешно — фиксируем транзакцию
        $pdo->commit();
        return true;

    } catch (Exception $e) {
        // Что-то пошло не так — откатываем все изменения
        $pdo->rollBack();
        error_log('Ошибка перевода: ' . $e->getMessage());
        return false;
    }
}

// Использование:
if (transfer($pdo, fromId: 1, toId: 2, amount: 500.00)) {
    echo 'Перевод выполнен';
} else {
    echo 'Ошибка перевода';
}

PDO также поддерживает точки сохранения (savepoints) для вложенных транзакций:

<?php

$pdo->beginTransaction();

try {
    $pdo->exec('INSERT INTO orders (user_id, total) VALUES (1, 1500)');
    $orderId = $pdo->lastInsertId();

    // Создаём точку сохранения перед рискованной операцией
    $pdo->exec('SAVEPOINT before_items');

    try {
        foreach ($cartItems as $item) {
            $stmt = $pdo->prepare(
                'INSERT INTO order_items (order_id, product_id, qty, price) VALUES (:order, :product, :qty, :price)'
            );
            $stmt->execute([
                ':order'   => $orderId,
                ':product' => $item['product_id'],
                ':qty'     => $item['qty'],
                ':price'   => $item['price'],
            ]);
        }
    } catch (PDOException $e) {
        // Откатываемся только к savepoint, заказ сохраняется
        $pdo->exec('ROLLBACK TO SAVEPOINT before_items');
        // Помечаем заказ как проблемный
        $pdo->exec("UPDATE orders SET status = 'items_error' WHERE id = $orderId");
    }

    $pdo->commit();

} catch (Exception $e) {
    $pdo->rollBack();
    throw $e;
}

Практический паттерн: простой репозиторий на PDO

Для реальных проектов удобно инкапсулировать работу с таблицей в класс-репозиторий. Это упрощает тестирование и избавляет от дублирования кода:

<?php

class UserRepository
{
    public function __construct(private PDO $pdo) {}

    public function findById(int $id): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id LIMIT 1');
        $stmt->execute([':id' => $id]);
        $result = $stmt->fetch();
        return $result !== false ? $result : null;
    }

    public function findByEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare('SELECT * FROM users WHERE email = :email LIMIT 1');
        $stmt->execute([':email' => $email]);
        $result = $stmt->fetch();
        return $result !== false ? $result : null;
    }

    /**
     * @return array[]
     */
    public function findActive(int $limit = 20, int $offset = 0): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, name, email, created_at FROM users WHERE status = 1
             ORDER BY created_at DESC LIMIT :limit OFFSET :offset'
        );
        // Для LIMIT/OFFSET нужно явно указывать PDO::PARAM_INT
        $stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
        $stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
        $stmt->execute();
        return $stmt->fetchAll();
    }

    public function create(string $name, string $email, string $password): int
    {
        $stmt = $this->pdo->prepare(
            'INSERT INTO users (name, email, password, created_at) VALUES (:name, :email, :password, NOW())'
        );
        $stmt->execute([
            ':name'     => $name,
            ':email'    => $email,
            ':password' => password_hash($password, PASSWORD_BCRYPT),
        ]);
        return (int) $this->pdo->lastInsertId();
    }

    public function update(int $id, array $data): bool
    {
        // Динамически строим SET часть из переданных полей
        $allowed = ['name', 'email', 'status']; // белый список полей
        $fields = array_intersect_key($data, array_flip($allowed));

        if (empty($fields)) {
            return false;
        }

        $setParts = array_map(fn($key) => "$key = :$key", array_keys($fields));
        $sql = 'UPDATE users SET ' . implode(', ', $setParts) . ', updated_at = NOW() WHERE id = :id';

        $params = $fields;
        $params[':id'] = $id;

        $stmt = $this->pdo->prepare($sql);
        $stmt->execute($params);
        return $stmt->rowCount() > 0;
    }

    public function delete(int $id): bool
    {
        $stmt = $this->pdo->prepare('DELETE FROM users WHERE id = :id');
        $stmt->execute([':id' => $id]);
        return $stmt->rowCount() > 0;
    }
}

// Использование:
$repo = new UserRepository(Database::getConnection());

$user = $repo->findByEmail('user@example.com');
if ($user && password_verify($inputPassword, $user['password'])) {
    echo 'Авторизация успешна';
}

$id = $repo->create('Иван', 'ivan@example.com', 'secret123');
$repo->update($id, ['status' => 1, 'name' => 'Иван Петров']);

Итог: чеклист правильной работы с PDO

  • Подключение: всегда устанавливайте ERRMODE_EXCEPTION, FETCH_ASSOC и EMULATE_PREPARES => false
  • Запросы: используйте prepare() + execute() для любых запросов с переменными данными
  • Плейсхолдеры: именованные (:name) предпочтительнее позиционных (?) для читаемости
  • LIMIT/OFFSET: обязательно указывайте PDO::PARAM_INT через bindValue()
  • Транзакции: оборачивайте в try/catch, всегда вызывайте rollBack() при исключении
  • Ошибки: логируйте через error_log(), никогда не показывайте детали SQL пользователю
  • Пароли: храните только хеш через password_hash(), проверяйте через password_verify()
  • Белый список полей: при динамическом построении запроса фильтруйте имена полей через явный список

PDO — фундамент, на котором строятся все современные PHP-фреймворки. Понимание его работы помогает лучше разобраться в Eloquent (Laravel), Doctrine (Symfony) и других ORM, поскольку все они в конечном счёте работают поверх PDO.