PDO Error Handling
How PDO Reports Errors In simple terms: Database operations can fail: a duplicate email, a missing table, a lost connection, a deadlock. Good error handling means three things: the failure must never
How PDO Reports Errors
In simple terms: Database operations can fail: a duplicate email, a missing table, a lost connection, a deadlock. Good error handling means three things: the failure must never go unnoticed, the user must see a helpful message (not a technical one), and developers must get enough detail in the logs to fix the problem.
Technically: PDO has three error modes, set with PDO::ATTR_ERRMODE. Since PHP 8.0, the default is PDO::ERRMODE_EXCEPTION: every failure throws a PDOException. The exception carries a message, an SQLSTATE code (a standardized five-character code), and an errorInfo array with the database's own error code and message.
| Mode | Behavior |
|---|---|
PDO::ERRMODE_EXCEPTION | Throws PDOException (default since PHP 8.0, recommended) |
PDO::ERRMODE_WARNING | Emits a PHP warning and returns false |
PDO::ERRMODE_SILENT | Returns false; details only via errorCode() / errorInfo() |
Anatomy of a PDOException
<?php
require __DIR__ . '/src/pdo.php';
try {
pdo()->prepare('INSERT INTO customers (name, email) VALUES (?, ?)')
->execute(['Asha Again', 'asha@example.com']);
} catch (PDOException $e) {
echo $e->getMessage() . "\n\n";
var_dump($e->getCode());
print_r($e->errorInfo);
}
Output
SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'asha@example.com' for key 'customers.uq_customers_email'
string(5) "23000"
Array
(
[0] => 23000
[1] => 1062
[2] => Duplicate entry 'asha@example.com' for key 'customers.uq_customers_email'
)
| Part | Content | Use |
|---|---|---|
getMessage() | Full message including SQLSTATE, driver code, and text | Logging |
getCode() | The SQLSTATE as a string ("23000") | Broad error category |
errorInfo[0] | SQLSTATE | Same as getCode() |
errorInfo[1] | The driver-specific error code (1062 for MySQL duplicates) | Precise handling |
errorInfo[2] | The driver's error message | Logging |
The SQLSTATE 23000 covers all integrity violations — duplicates, foreign key failures, NOT NULL violations. To react to a specific problem, check the MySQL code in errorInfo[1].
Common MySQL Error Codes
errorInfo[1] | SQLSTATE | Meaning |
|---|---|---|
| 1062 | 23000 | Duplicate entry for a unique key |
| 1451 | 23000 | Cannot delete/update a row referenced by a foreign key |
| 1452 | 23000 | Foreign key points to a non-existent row |
| 1048 | 23000 | Column cannot be NULL |
| 3819 | HY000 | CHECK constraint violated |
| 1213 | 40001 | Deadlock; the transaction was rolled back — retry |
| 1205 | HY000 | Lock wait timeout exceeded |
| 1146 | 42S02 | Table does not exist |
| 1064 | 42000 | SQL syntax error |
| 2006 / 2013 | HY000 | Connection to the server lost |
Silent Mode: Why Exceptions Are Better
<?php
require __DIR__ . '/src/pdo.php';
$pdo = pdo();
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_SILENT);
$result = $pdo->query('SELECT * FROM missing_table');
var_dump($result);
print_r($pdo->errorInfo());
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
Output
bool(false)
Array
(
[0] => 42S02
[1] => 1146
[2] => Table 'gocourse_shop.missing_table' doesn't exist
)
In silent mode, query() merely returns false. Code that forgets to check — $pdo->query(...)->fetchAll() — fails later with an unrelated "Call to a member function fetchAll() on false" error. Silent and warning modes are relics of older PHP versions; keep the exception mode.
Translating Database Errors Into Application Errors
Code above the database layer should not need to know MySQL error numbers. A repository (a class responsible for loading and saving one kind of data) catches database exceptions and throws meaningful domain exceptions instead:
<?php
require __DIR__ . '/src/pdo.php';
final class EmailAlreadyRegisteredException extends RuntimeException {}
final class CustomerRepository
{
public function __construct(private PDO $pdo) {}
public function register(string $name, string $email, ?string $city): int
{
try {
$this->pdo
->prepare('INSERT INTO customers (name, email, city) VALUES (:name, :email, :city)')
->execute(['name' => $name, 'email' => strtolower($email), 'city' => $city]);
return (int) $this->pdo->lastInsertId();
} catch (PDOException $e) {
if (($e->errorInfo[1] ?? null) === 1062) {
throw new EmailAlreadyRegisteredException("Email already registered: $email", 0, $e);
}
throw $e;
}
}
}
$customers = new CustomerRepository(pdo());
foreach ([['Asha K', 'ASHA@example.com'], ['Oviya', 'oviya@example.com']] as [$name, $email]) {
try {
$id = $customers->register($name, $email, 'Chennai');
echo "Registered $name (ID $id)\n";
} catch (EmailAlreadyRegisteredException) {
echo "$email: This email address is already registered.\n";
}
}
Output (the ID depends on your data)
ASHA@example.com: This email address is already registered.
Registered Oviya (ID 18)
(The failed duplicate inserts in this lesson still used up auto-increment values 16 and 17 — see MySQL Get Last ID.)
Why This Design Helps
- The calling code catches
EmailAlreadyRegisteredException— a name that explains the problem — instead of inspectingPDOExceptioncodes. - Only the duplicate case is translated; every other database error is re-thrown unchanged and reaches the global error handler.
- The original
PDOExceptionis kept as the previous exception, so logs still show the database details (see PHP Exceptions). - The user sees a clear, actionable message.
Logging Database Errors
When an unexpected database error occurs, log enough context to investigate — but never secrets:
<?php
try {
// ... database work ...
} catch (PDOException $e) {
error_log(sprintf(
'DB error %s (%s) in %s: %s',
$e->errorInfo[1] ?? '?',
$e->getCode(),
'CheckoutService::placeOrder',
$e->getMessage()
));
throw $e; // let the global handler show a generic error page
}
| Log | Do Not Log |
|---|---|
| Error codes and messages | Passwords, password hashes, tokens |
| Which operation failed (class, method, order ID) | Full parameter lists that contain personal data |
| Request ID for correlation | Credit card or bank details |
The global exception handler (see Custom Error Handler) shows users a generic "Something went wrong" page with HTTP status 500 for anything not handled specifically.
Which Errors to Handle Where
| Error | Handle | How |
|---|---|---|
| Duplicate entries (1062) | In the repository or service | Translate into a domain exception and a friendly form error |
| Foreign key violations (1451, 1452) | In the service | Explain why the action is not possible, or deactivate instead of deleting |
| Deadlocks (1213) | Around the transaction | Retry a limited number of times (see PDO Transactions) |
| Lost connection (2006, 2013) | In long-running workers | Reconnect and retry the operation |
| Syntax errors, missing tables | Nowhere locally | These are bugs: let them reach the global handler and fix the code |
Common Mistakes
Displaying $e->getMessage() to Users
Effect: Table names, queries, and data values are exposed.
Fix: Log details; show generic or translated messages.
Comparing getCode() With Integers
Effect: $e->getCode() === 1062 is never true — getCode() returns the SQLSTATE string.
Fix: Use $e->errorInfo[1] for MySQL error numbers.
Catching and Ignoring Exceptions
Effect: Failed writes go unnoticed and data is silently lost.
Fix: Handle specific cases; re-throw the rest.
Using Silent Mode
Effect: Failures surface as confusing errors elsewhere.
Fix: Keep PDO::ERRMODE_EXCEPTION.
Losing the Original Exception
Effect: Logs show only the translated message.
Fix: Pass the PDOException as the $previous argument.
Best Practices
- Keep the exception error mode for all connections.
- Use
errorInfo[1]to detect specific database errors and SQLSTATE for broad categories. - Translate expected database errors into domain exceptions in the data-access layer.
- Log technical details with context, never secrets, and show users friendly messages.
- Let unexpected errors reach the global handler instead of hiding them.
Official reference: https://www.php.net/manual/en/pdo.error-handling.php