Contents
- 1 PHP exception handling best practices
- 1.1 Why exceptions beat old-school errors
- 1.2 The holy trinity: try, throw, catch
- 1.3 Custom exceptions: make errors speak your language
- 1.4 Re-throwing and propagation: let exceptions bubble wisely
- 1.5 Finally: the cleanup crew
- 1.6 Global handlers: catch the uncaught
- 1.7 Advanced: checked vs unchecked, and real-world strategy
- 1.8 Common traps I've fallen into (and escaped)
PHP exception handling best practices
Friends, have you ever stared at a screen at 2 AM, coffee gone cold, while some cryptic PHP error crashes your entire app? That moment when a simple division by zero or a bad database query turns into a production nightmare. I have. Too many times. Those late nights taught me something crucial: exception handling isn't just syntax—it's the difference between code that breaks silently and code that whispers exactly what went wrong, letting you fix it fast.
In PHP, exceptions are your safety net. They're not for flow control, but for those real "oh shit" moments—invalid input, failed connections, unexpected states. Get this right, and your apps become resilient. Ignore it, and you're playing Russian roulette with every request. Let's dive deep into best practices that have saved my bacon, drawn from years of debugging legacy messes and building bulletproof services. We'll cover the basics, then level up to strategies that make your code sing.
Why exceptions beat old-school errors
Remember PHP's pre-exception days? error_reporting, @ suppression, dying on warnings. Chaos. Exceptions changed everything since PHP 5, and PHP 8+ made them even smarter with Throwable catching both exceptions and errors.
Think about it: a fopen fails on a missing file. Old way? Script halts, vague notice. New way? You throw, catch, and handle gracefully. Like this classic division example from the PHP manual:
function inverse($x) {
if (!$x) {
throw new Exception('Division by zero.');
}
return 1 / $x;
}
try {
echo inverse(5) . "\n"; // 0.2
echo inverse(0) . "\n"; // Throws!
} catch (Exception $e) {
echo 'Caught: ' . $e->getMessage() . "\n"; // Clean error
}
See? Precise. No stack trace vomit unless you want it. But here's the kicker: don't use exceptions for normal flow. "Value must be 1 or below"? That's validation, not exceptional. Use if-statements.
The holy trinity: try, throw, catch
Every exception dance starts here. Wrap risky code in try. Throw when shit hits the fan. Catch to recover or log.
function checkNum($number) {
if ($number > 1) {
throw new Exception("Value must be 1 or below.");
}
return true;
}
try {
checkNum(2);
echo 'All good.';
} catch (Exception $e) {
echo 'Message: ' . $e->getMessage();
}
Pro tip: Every throw needs a catch. PHP enforces it—no dangling grenades.
Multiple catches? Stack them for specificity. PHP 8's union types make it elegant:
try {
// Risky operation
} catch (DivideByZeroException | DivideByNegativeException $e) {
// Handle math fails
} catch (Exception $e) {
// Catch-all
}
Questions for you: What happens if you forget the catch? App dies. Always pair them.
Custom exceptions: make errors speak your language
Built-in Exception is fine, but custom ones? They're poetry. Extend Exception, add methods for context. Perfect for domain logic.
I once built an email validator. Generic "Invalid email" sucks. Custom? Tells the story:
class CustomException extends Exception {
private $badEmail;
public function __construct($email) {
$this->badEmail = $email;
parent::__construct("Email '{$email}' is invalid or blacklisted.");
}
public function errorMessage() {
return $this->badEmail . ' failed validation.';
}
}
$email = "someone@example...com"; // Valid but blacklisted
try {
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new CustomException($email);
}
if (strpos($email, 'example') !== false) {
throw new CustomException($email);
}
} catch (CustomException $e) {
echo $e->errorMessage();
}
Feel that? Your exceptions now carry your app's voice. Use them for APIs, user input, DB ops. Name them smartly: DatabaseConnectionFailed, UserNotFoundException. Readers (and future you) will thank you.
Re-throwing and propagation: let exceptions bubble wisely
Caught it, but can't fix it? Re-throw. Or let it propagate up the call stack. This is exception propagation—PHP's way of saying "handle at the right level."
Picture a chain: employee() throws, manager() catches and re-throws, boss() handles. Encapsulation intact.
function employee() {
throw new Exception("I'm just an employee!");
}
function manager() {
try {
employee();
} catch (Exception $e) {
// Log, then escalate
error_log($e->getMessage());
throw $e; // Re-throw
}
}
function boss() {
try {
manager();
} catch (Exception $e) {
echo "Boss handles: " . $e->getMessage();
}
}
boss(); // "Boss handles: I'm just an employee!"
Best practice: Catch low (specific), re-throw high (abstract). Wrap in richer context:
catch (PDOException $e) {
throw new DatabaseQueryFailed("Query failed for user ID 123: " . $e->getMessage(), 0, $e);
}
Chaining with previous? Gold. Preserves the full trace.
Finally: the cleanup crew
Finally runs always—exception or not. Close files, DB connections, rollbacks. Indispensable.
function inverse($x) {
if (!$x) throw new Exception('Division by zero.');
return 1/$x;
}
try {
echo inverse(5) . "\n";
} catch (Exception $e) {
echo 'Caught: ' . $e->getMessage() . "\n";
} finally {
echo "Cleanup done.\n"; // Always fires
}
Nested? Works. Returns? Finally still runs first. Test it—mind blown.
Global handlers: catch the uncaught
No try-catch? Don't panic—set_exception_handler. Your last line of defense. Log, notify Slack, show user-friendly page.
function globalHandler(Throwable $exception) { // Throwable catches errors too (PHP7+)
error_log("Uncaught: " . $exception->getMessage() . "\n" . $exception->getTraceAsString());
echo "Something went wrong. We're on it.";
}
set_exception_handler('globalHandler');
throw new RuntimeException("Boom!"); // Triggers global
Pair with Monolog for pro logging:
require_once __DIR__ . '/vendor/autoload.php';
use Monolog\Logger;
use Monolog\Handler\StreamHandler;
$logger = new Logger('app');
$logger->pushHandler(new StreamHandler('app.log', Logger::DEBUG));
try {
throw new RuntimeException('Test fail');
} catch (Throwable $ex) {
$logger->error('Exception!', [
'message' => $ex->getMessage(),
'file' => $ex->getFile(),
'line' => $ex->getLine(),
'trace' => $ex->getTraceAsString(),
]);
}
PDO bonus: Set PDO::ERRMODE_EXCEPTION for DB errors as exceptions.
Advanced: checked vs unchecked, and real-world strategy
Distinguish checked exceptions (expected, like DB down—handle or declare @throws) from unchecked (bugs, like null deref—let crash).
In practice:
- Log everything with context (user ID, request data).
- Don't swallow—always do something.
- User-facing? Generic messages, log details.
- Testing: Mock exceptions. PHPUnit loves
expectException.
For APIs, HTTP status codes:
catch (ValidationException $e) {
http_response_code(422);
echo json_encode(['error' => $e->getMessage()]);
}
Common traps I've fallen into (and escaped)
- Suppressing with @: Kills visibility. Never.
- Catch-all too early: Loses specificity.
- No finally: Leaks resources.
- Warnings as exceptions: Use
set_error_handlerto escalate.
Have you nested try-catches too deep? Refactor upward.
Exception handling feels heavy at first. But one prod outage avoided? Worth every line. It's not about perfect code—it's about code that fails predictably, teaches you, and keeps running.
Next time you're in that 2 AM fog, remember: a well-thrown exception is a quiet guide back to sanity. Write it that way, and watch your PHP heart beat stronger.