databases storage and caching
Error Handling
Database errors should be visible to developers and operators, but safe for users. A good PHP application uses exceptions internally, rolls back failed transactions, handles expected cases deliberately, and avoids leaking SQL details in public responses.
Error handling is not only about crashes. Missing rows, duplicate emails, invalid foreign keys, deadlocks, timeouts, and connection failures all need different responses.
Enable Exceptions
PDO should be configured to throw exceptions for database errors.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
echo 'PDO exceptions enabled' . PHP_EOL;
// Prints:
// PDO exceptions enabled
Without exception mode, database failures are easier to miss and harder to handle consistently.
Expected Missing Rows
A missing row is usually not an exception. It is an expected outcome that should become null, a 404, or a domain-specific response.
<?php
declare(strict_types=1);
function userFromRow(array|false $row): ?array
{
if ($row === false) {
return null;
}
return $row;
}
var_dump(userFromRow(false));
// Prints:
// NULL
Do not treat every "not found" as a server error.
Constraint Failures
Unique constraints, foreign keys, NOT NULL, and CHECK constraints protect data. When they fail, the application should respond with a useful message where possible.
<?php
declare(strict_types=1);
function publicDatabaseErrorMessage(PDOException $exception): string
{
$sqlState = $exception->errorInfo[0] ?? null;
return match ($sqlState) {
'23000', '23505' => 'A record with that value already exists.',
default => 'The data could not be saved.',
};
}
$exception = new PDOException('Unique constraint failed');
$exception->errorInfo = ['23000'];
echo publicDatabaseErrorMessage($exception) . PHP_EOL;
// Prints:
// A record with that value already exists.
SQLSTATE codes vary by database and driver. Use them carefully and test against the database your project actually runs.
Transactions And Rollback
If an exception happens inside a transaction, roll back before rethrowing or converting the error.
<?php
declare(strict_types=1);
function transactionFailureAction(bool $inTransaction): string
{
return $inTransaction ? 'roll back then handle error' : 'handle error';
}
echo transactionFailureAction(true) . PHP_EOL;
// Prints:
// roll back then handle error
Leaving a transaction open can hold locks and break later work.
Safe Logging
Logs should help developers diagnose the issue without exposing secrets or private data.
Log useful context such as:
- Operation name.
- Table or repository area.
- SQLSTATE or exception class.
- Request ID or job ID.
- Non-sensitive identifiers such as user ID or order ID.
Avoid logging:
- Passwords, tokens, API keys, and session IDs.
- Full payment data.
- Raw SQL with sensitive bound values.
- Full personal data unless the logging policy allows it.
What To Check
Before moving on, make sure you can:
- Enable PDO exception mode.
- Distinguish a missing row from a database failure.
- Handle unique or constraint failures deliberately.
- Roll back transactions on exceptions.
- Return safe public messages.
- Log enough context without logging secrets.
Practice
Practice: Handle Duplicate Emails Safely
Write a PHP script that inserts users and handles a duplicate email safely.
Requirements
- Use SQLite in memory.
- Enable PDO exception mode.
- Create a
userstable with a unique email column. - Insert one user successfully.
- Attempt to insert the same email again.
- Catch the database exception.
- Print a safe public message without printing raw SQL or a password.
Show solution
This solution catches the duplicate insert and returns a safe message.
<?php
declare(strict_types=1);
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE)');
$insert = $pdo->prepare('INSERT INTO users (email) VALUES (:email)');
$insert->execute(['email' => 'sam@example.com']);
try {
$insert->execute(['email' => 'sam@example.com']);
echo 'User created' . PHP_EOL;
} catch (PDOException $exception) {
echo 'A user with that email already exists.' . PHP_EOL;
echo 'log_context=' . json_encode([
'operation' => 'create_user',
'sqlstate' => $exception->errorInfo[0] ?? null,
], JSON_THROW_ON_ERROR) . PHP_EOL;
}
// Prints:
// A user with that email already exists.
// log_context={"operation":"create_user","sqlstate":"23000"}
The public message is safe. The log context is useful without exposing raw SQL, passwords, or the submitted email address.