Как безопасно обрабатывать ошибки PDO при работе с MySQL

Работа с базой через PDO без обработки ошибок — это риск: при сбое пользователь увидит либо пустую страницу, либо «500 Internal Server Error», а разработчик не поймёт, в чём дело. Разберём, как сделать так, чтобы приложение тихо логировало проблему, а пользователю не показывало технические детали.


Главное правило: не показывать пользователю SQL‑ошибки

SQL‑ошибки содержат имена таблиц, колонок, иногда части логики. Их нельзя выводить в браузер. Пользователь должен видеть нейтральное сообщение вроде «Не удалось загрузить данные. Попробуйте позже». А всё техническое — только в лог.


Включаем правильный режим ошибок в PDO

Самый надёжный вариант — PDO::ERRMODE_EXCEPTION. Тогда любая ошибка базы сразу превращается в исключение, которое можно перехватить.

php
$dsn = 'mysql:host=localhost;dbname=test;charset=utf8mb4';
$user = 'user';
$pass = 'pass';
 
$options = [
    PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
    PDO::ATTR_EMULATE_PREPARES   => false,
];
 
try {
    $pdo = new PDO($dsn, $user, $pass, $options);
} catch (PDOException $e) {
    // Логируем ошибку, пользователю — ничего не показываем
    error_log('DB connection failed: ' . $e->getMessage());
    die('Не удалось подключиться к базе данных.');
}

Почему именно ERRMODE_EXCEPTION:

  • Проще контролировать поток: всё падает в catch, нет разбросанного кода с проверками.
  • Удобно оборачивать отдельные запросы в свои блоки try/catch, если нужно по‑разному реагировать на разные ошибки.

Как правильно логировать ошибки

В лог нужно писать не только сообщение, но и контекст: тип операции, параметры (без чувствительных данных), стек.

Пример простой функции логирования:

php
function logDbError(string $context, \Throwable $e): void
{
    $message = sprintf(
        '[DB] %s | Message: %s | Code: %s | Trace: %s',
        $context,
        $e->getMessage(),
        $e->getCode(),
        $e->getTraceAsString()
    );
    error_log($message);
}

Теперь используем её при запросах:

php
try {
    $stmt = $pdo->prepare('SELECT * FROM users WHERE id = :id');
    $stmt->execute(['id' => $userId]);
    $user = $stmt->fetch();
} catch (PDOException $e) {
    logDbError('Fetch user by ID', $e);
    // Для пользователя — нейтральное сообщение
    http_response_code(500);
    echo 'Не удалось загрузить профиль. Попробуйте позже.';
}

Что важно:

  • Не логируйте пароли, токены, персональные данные.
  • В продакшене лучше писать в файл или в систему сбора логов (Monolog, Graylog, Sentry), а не просто в error_log.

Частые сценарии и как их обрабатывать

1. Ошибка подключения к БД

Уже показан выше: лог + короткое сообщение пользователю. Если это критично (например, главная страница), можно показать заглушку и отправить алерт администратору.

2. Ошибка запроса (синтаксис, несуществующая таблица и т.п.)

Это почти всегда проблема кода, а не данных. Лог с контекстом поможет быстро найти баг.

php
try {
    $stmt = $pdo->query('SELECT * FROM non_existent_table');
} catch (PDOException $e) {
    logDbError('Query error in dashboard stats', $e);
    http_response_code(500);
    echo 'Ошибка загрузки статистики.';
}

3. Нарушение уникальности (дубликат, уникальный индекс)

Тут ошибка — это часть бизнес‑логики. Например, пользователь пытается зарегистрироваться с уже занятым email.

php
try {
    $stmt = $pdo->prepare('INSERT INTO users (email, password_hash) VALUES (:email, :pass)');
    $stmt->execute([
        'email' => $email,
        'pass'  => $passwordHash,
    ]);
} catch (PDOException $e) {
    if ($e->getCode() === '23000') { // нарушение уникальности
        logDbError('Duplicate email on registration', $e);
        http_response_code(409);
        echo 'Такой email уже зарегистрирован.';
        return;
    }
    // Остальные ошибки — как обычно
    logDbError('Registration DB error', $e);
    http_response_code(500);
    echo 'Произошла ошибка при регистрации.';
}

4. Транзакции и частичный откат

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

php
$pdo->beginTransaction();
 
try {
    $stmt1 = $pdo->prepare('UPDATE accounts SET balance = balance - :amount WHERE id = :from');
    $stmt1->execute(['amount' => $amount, 'from' => $fromId]);
 
    $stmt2 = $pdo->prepare('UPDATE accounts SET balance = balance + :amount WHERE id = :to');
    $stmt2->execute(['amount' => $amount, 'to' => $toId]);
 
    $pdo->commit();
} catch (PDOException $e) {
    $pdo->rollBack();
    logDbError('Transfer failed', $e);
    http_response_code(500);
    echo 'Не удалось выполнить перевод. Попробуйте позже.';
}

Практические советы под ваши задачи (админки, статьи, каталоги)

  • В админке при сохранении статьи или товара ошибка БД — это критичная ситуация. Логируйте с контекстом (ID статьи/товара, тип операции) и покажите пользователю: «Не удалось сохранить. Проверьте соединение или обратитесь к администратору».
  • На публичной странице каталога ошибка выборки товаров — не должна ломать всю страницу. Можно показать пустой список или заглушку «Товары не найдены» и залогировать проблему.
  • При массовых операциях (импорт, пересчёт цен) оборачивайте каждую итерацию или блок в try/catch и продолжайте обработку, пропуская сбойные строки. Так сайт не «падает», а только часть данных не обрабатывается.

Чего избегать

  • Выводить $e->getMessage() пользователю. Это раскрытие внутренней структуры.
  • Игнорировать исключения. Если не ловить PDOException, приложение может вести себя непредсказуемо.
  • Логировать чувствительные данные. Никогда не пишите в лог пароли, токены, полные данные карт.
  • Думать, что ERRMODE_WARNING или ERRMODE_SILENT — это проще. В итоге вы теряете контроль над ошибками и усложняете отладку.

Минимальный шаблон для проекта

php
class Db
{
    private PDO $pdo;
 
    public function __construct()
    {
        $dsn = 'mysql:host=localhost;dbname=test;charset=utf8mb4';
        $options = [
            PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES   => false,
        ];
 
        try {
            $this->pdo = new PDO($dsn, $_ENV['DB_USER'], $_ENV['DB_PASS'], $options);
        } catch (PDOException $e) {
            logDbError('DB init', $e);
            die('Сервис временно недоступен.');
        }
    }
 
    public function getPdo(): PDO
    {
        return $this->pdo;
    }
}
 
function logDbError(string $context, \Throwable $e): void
{
    error_log(sprintf(
        '[DB] %s | %s | %s',
        $context,
        $e->getMessage(),
        $e->getTraceAsString()
    ));
}

Дальше все запросы делаются через $db->getPdo() и оборачиваются в try/catch с вызовом logDbError.

Добавить комментарий