Exceptions #
Exceptions are the mechanism that lets code signal that something unexpected has happened — and move the responsibility of handling it to a caller that understands the context better. Without exceptions, every function would have to return error codes and every caller would have to check return values, creating code full of repeated checks that obscure the main logic. PHP has two error handling hierarchies: Exception for conditions that can be recovered and Error for internal PHP failures that usually can’t be recovered. Knowing the difference, when to throw an exception vs return null, how to design a meaningful custom exception hierarchy, and how finally works as resource cleanup — these are what separate good error handling from code that merely doesn’t crash.
The PHP Throwable Hierarchy #
Since PHP 7, everything that can be thrown implements the Throwable interface. There are two main branches:
flowchart TD
T[Throwable\ninterface] --> E[Exception]
T --> Err[Error]
E --> RE[RuntimeException]
E --> LA[LogicException]
E --> OE[OverflowException]
E --> UE[UnexpectedValueException]
E --> IO[InvalidArgumentException]
E --> BO[BadMethodCallException]
Err --> TE[TypeError]
Err --> PE[ParseError]
Err --> AE[ArithmeticError]
Err --> VE[ValueError]
RE --> OOR[OutOfRangeException]
RE --> OOB[OutOfBoundsException]
RE --> UOE[UnderflowException]
style T fill:#fef9c3,stroke:#ca8a04
style E fill:#dcfce7,stroke:#16a34a
style Err fill:#fee2e2,stroke:#dc2626Exception and its subclasses are used for conditions the application can know about and handle — failed validation, missing resources, denied permissions. Error and its subclasses represent internal PHP failures like TypeError (wrong argument types), ParseError (bad file syntax), or ArithmeticError (integer division by zero). You’ll almost never need to catch (Error $e) except for top-level logging.
Basic Syntax: try, catch, finally
#
<?php
try {
// Code that might throw an exception goes here
$result = divide(10, 0);
echo "Result: $result"; // Never reached if divide() throws
} catch (\InvalidArgumentException $e) {
// Catch a specific exception type — higher priority
echo "Invalid argument: " . $e->getMessage();
} catch (\RuntimeException $e) {
// Catch a broader type
echo "Runtime error: " . $e->getMessage();
} catch (\Exception $e) {
// Catch any Exception not caught above
echo "Unexpected error: " . $e->getMessage();
} finally {
// Always runs — whether or not an exception occurred
echo "\nProcess finished.";
}
How the Exception Flow Works #
sequenceDiagram
participant Caller
participant try_block as try block
participant catch_block as catch block
participant finally_block as finally block
Caller->>try_block: Execute code
alt No exception
try_block-->>finally_block: Continue to finally
finally_block-->>Caller: Normal return
else Exception thrown
try_block->>catch_block: Exception forwarded
catch_block-->>finally_block: After catch finishes
finally_block-->>Caller: Return after handling
else Exception not caught
try_block->>finally_block: finally still runs
finally_block->>Caller: Exception propagates up
endfinally — Always Executed
#
finally runs under all conditions: when the code succeeds, when an exception is caught, even when an exception isn’t caught and is propagating upward. This makes it the right place for resource cleanup:
<?php
function readFile(string $path): string
{
$handle = fopen($path, 'r');
if ($handle === false) {
throw new \RuntimeException("Failed to open file: $path");
}
try {
$content = fread($handle, filesize($path));
if ($content === false) {
throw new \RuntimeException("Failed to read file: $path");
}
return $content;
} finally {
// Always closes the file handle — even with an exception or return above
fclose($handle);
// No return needed here — the return value from try is still returned
}
}
// The file handle is ALWAYS closed, no matter what happens
try {
$content = readFile('/etc/hosts');
echo strlen($content) . " bytes read";
} catch (\RuntimeException $e) {
echo "Error: " . $e->getMessage();
}
Areturninside afinallyblock will replace the return value from thetryorcatchblock. This is unintuitive behavior and can hide the value that was meant to be returned. Usefinallyonly for side effects (closing connections, deleting temp files, logging) — neverreturnfromfinally.
Exception Object Properties #
Every Exception object carries useful information for debugging:
<?php
try {
throw new \RuntimeException("Database connection failed", 500);
} catch (\RuntimeException $e) {
echo $e->getMessage(); // "Database connection failed"
echo $e->getCode(); // 500 — optional error code
echo $e->getFile(); // "/var/www/app/Database.php"
echo $e->getLine(); // 42 — the line where it was thrown
echo $e->getTraceAsString(); // Full stack trace as a string
// Stack trace as an array (easier to process)
$trace = $e->getTrace();
foreach ($trace as $frame) {
echo "{$frame['file']}:{$frame['line']} — {$frame['function']}()\n";
}
}
Exception Chaining — Preserving the Original Cause #
When catching a low-level exception and throwing a high-level one, always include the original exception as the previous exception so the stack trace isn’t lost:
<?php
class OrderException extends \RuntimeException {}
function saveOrder(array $data): int
{
try {
$stmt = $pdo->prepare("INSERT INTO orders ...");
$stmt->execute($data);
return (int) $pdo->lastInsertId();
} catch (\PDOException $e) {
// ANTI-PATTERN: the original exception is lost, debugging gets hard
throw new OrderException("Failed to save order");
// CORRECT: include $e as the previous exception (third argument)
throw new OrderException("Failed to save order", 0, $e);
}
}
try {
saveOrder($data);
} catch (OrderException $e) {
echo $e->getMessage(); // "Failed to save order"
echo $e->getPrevious()->getMessage(); // "SQLSTATE[...]: ..." — the original detail
}
Creating Meaningful Custom Exceptions #
PHP’s built-in exceptions (RuntimeException, InvalidArgumentException, etc.) are often enough for simple code. But for larger applications, specific custom exceptions make error handling much easier and error messages far more informative.
Domain Exception Hierarchy #
Design an exception hierarchy that mirrors your application’s domain:
<?php
// Base exception for the whole application domain
class AppException extends \RuntimeException {}
// Domain-specific exceptions — extend AppException
class NotFoundException extends AppException
{
public function __construct(string $resource, int|string $id)
{
parent::__construct(
"$resource with ID '$id' not found",
404
);
}
}
class ValidationException extends AppException
{
private array $errors;
public function __construct(array $errors)
{
$this->errors = $errors;
parent::__construct(
"Validation failed: " . implode(', ', array_keys($errors)),
422
);
}
public function getErrors(): array
{
return $this->errors;
}
public function getFirstError(): string
{
return reset($this->errors) ?: '';
}
}
class AuthorizationException extends AppException
{
public function __construct(string $action, string $resource = '')
{
$message = $resource
? "Not allowed to '$action' on '$resource'"
: "Not allowed to '$action'";
parent::__construct($message, 403);
}
}
class DomainException extends AppException {}
// More specific sub-domain exceptions
class InsufficientStockException extends DomainException
{
public function __construct(
private string $productName,
private int $requested,
private int $available,
) {
parent::__construct(
"Insufficient stock for '$productName': $requested requested, $available available",
409
);
}
public function getProductName(): string { return $this->productName; }
public function getRequested(): int { return $this->requested; }
public function getAvailable(): int { return $this->available; }
}
// Usage
function reduceStock(int $productId, int $quantity): void
{
$product = findProduct($productId);
if ($product === null) {
throw new NotFoundException('Product', $productId);
}
if ($quantity > $product['stock']) {
throw new InsufficientStockException(
$product['name'],
$quantity,
$product['stock']
);
}
// reduce stock...
}
Catching the Exception Hierarchy in Layers #
With a well-designed hierarchy, you can catch at the right level:
<?php
try {
reduceStock(42, 100);
} catch (InsufficientStockException $e) {
// The most specific level — can access details
echo "Insufficient stock: " . $e->getProductName();
echo " Requested: " . $e->getRequested();
echo " Available: " . $e->getAvailable();
// Show a form to change the quantity
} catch (NotFoundException $e) {
// Broader — only knows the resource doesn't exist
http_response_code(404);
echo $e->getMessage();
} catch (AppException $e) {
// Catch all domain exceptions — generic response
http_response_code($e->getCode() ?: 500);
echo "Error: " . $e->getMessage();
} catch (\Throwable $e) {
// The broadest — catches everything including Errors (TypeError, etc.)
// Only for logging — don't expose details to the user
error_log($e->getMessage() . "\n" . $e->getTraceAsString());
http_response_code(500);
echo "An internal error occurred";
}
Multi-Catch — Catching Several Types at Once #
Since PHP 8.0, several exception types can be caught in a single catch block with the | operator:
<?php
try {
$result = processInput($input);
} catch (ValidationException | \InvalidArgumentException $e) {
// Catch both with the same handling
http_response_code(422);
echo "Invalid input: " . $e->getMessage();
} catch (NotFoundException | \OutOfBoundsException $e) {
http_response_code(404);
echo "Data not found";
} catch (\PDOException | \RuntimeException $e) {
// Database or runtime error — log and show a generic message
error_log($e->getTraceAsString());
http_response_code(500);
echo "A server error occurred";
}
Re-throwing — Throwing an Exception Again #
Sometimes you need to catch an exception to do something (logging, cleanup) but still want it to propagate upward:
<?php
function executeTransaction(callable $operation): mixed
{
$pdo->beginTransaction();
try {
$result = $operation($pdo);
$pdo->commit();
return $result;
} catch (\Throwable $e) {
// Roll back before the exception is re-thrown
$pdo->rollBack();
// Log technical details (not shown to the user)
error_log("Transaction failed: " . $e->getMessage());
error_log($e->getTraceAsString());
// Re-throw — let the caller decide how to handle it
throw $e;
// Or wrap it in a more descriptive exception:
// throw new TransactionException("Transaction failed", 0, $e);
}
}
// The caller gets the original exception (or its wrapper)
try {
$orderId = executeTransaction(function(\PDO $db) use ($data) {
$id = saveOrder($db, $data);
reduceStock($db, $data['product_id'], $data['qty']);
return $id;
});
} catch (InsufficientStockException $e) {
echo "Out of stock: " . $e->getProductName();
} catch (\PDOException $e) {
echo "Database error";
}
throw as an Expression (PHP 8.0+)
#
Since PHP 8.0, throw is an expression — it can be used inside ternaries, null coalescing, and arrow functions:
<?php
// throw in null coalescing
$name = $_GET['name'] ?? throw new \InvalidArgumentException("The 'name' parameter is required");
// throw in a ternary
$age = is_numeric($_GET['age'] ?? '')
? (int) $_GET['age']
: throw new \InvalidArgumentException("Age must be a number");
// throw in an arrow function
$parse = fn(string $json) => json_decode($json, true)
?? throw new \ValueError("Invalid JSON: $json");
// throw inside match
$status = match($code) {
200 => 'ok',
404 => 'not_found',
default => throw new \UnexpectedValueException("Unknown HTTP code: $code"),
};
// throw as a short-circuit guard
function findUserOrFail(int $id): array
{
return findUser($id)
?? throw new NotFoundException('User', $id);
}
Global Exception Handler #
For exceptions not caught by any try/catch block, PHP provides a global handler mechanism. This is important for logging and showing user-friendly error pages:
<?php
// set_exception_handler — for uncaught Exceptions
set_exception_handler(function (\Throwable $e): void {
// Log technical details
$message = sprintf(
"[%s] %s at %s:%d\nStack trace:\n%s",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
);
error_log($message);
// Appropriate response based on context
if (php_sapi_name() === 'cli') {
fwrite(STDERR, "Fatal: " . $e->getMessage() . "\n");
exit(1);
}
// For web — show a clean error page
$code = $e instanceof AppException ? $e->getCode() : 500;
http_response_code($code ?: 500);
if (getenv('APP_ENV') === 'development') {
// In development — show details for debugging
echo "<pre>" . htmlspecialchars($message) . "</pre>";
} else {
// In production — show a generic error page
echo "Something went wrong. Please try again later.";
}
});
// set_error_handler — convert traditional PHP errors to Exceptions
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
if (!(error_reporting() & $errno)) {
return false; // This error is ignored by the error_reporting setting
}
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
Exceptions vs Error Codes — When to Use Which #
Not every abnormal situation should use exceptions. This guide helps you choose:
Use an Exception when:
✓ A genuinely exceptional condition — rare in the normal flow
✓ A failure the caller can't proceed without (file missing, DB down)
✓ A function contract is violated (invalid arguments, inconsistent state)
✓ A failure that needs to propagate several levels up
Don't use an Exception when:
✗ Conditions that happen frequently in the normal flow (user not logged in, empty form)
✗ Validation that's expected to fail (wrong input format)
✗ A "not found" search result is a valid case
✗ For flow control (like breaking out of a loop)
<?php
// ANTI-PATTERN: Exceptions for normal flow control
function findUserByEmail(string $email): array
{
$user = queryDatabase($email);
if (!$user) {
throw new NotFoundException('User', $email); // a missing email is normal!
}
return $user;
}
// The caller is forced to use try/catch for normal flow:
try {
$user = findUserByEmail($email);
// user exists
} catch (NotFoundException $e) {
// user doesn't exist — this is normal flow, not exceptional!
}
// CORRECT: return null for valid "not found"
function findUserByEmail(string $email): ?array
{
return queryDatabase($email) ?: null;
}
// The caller uses a regular null check:
$user = findUserByEmail($email);
if ($user === null) {
// user doesn't exist
}
// Exceptions remain appropriate for: clearly invalid arguments
function findUserById(int $id): ?array
{
if ($id <= 0) {
throw new \InvalidArgumentException("ID must be positive, received: $id");
}
return queryDatabase($id) ?: null;
}
The Result Type Pattern — An Exception Alternative #
For operations that can fail in predictable ways, the Result type pattern (borrowed from functional programming) can be more expressive than exceptions:
<?php
class Result
{
private function __construct(
private readonly bool $success,
private readonly mixed $value,
private readonly string $error = '',
) {}
public static function ok(mixed $value): static
{
return new static(true, $value);
}
public static function fail(string $error): static
{
return new static(false, null, $error);
}
public function succeeded(): bool { return $this->success; }
public function failed(): bool { return !$this->success; }
public function value(): mixed { return $this->value; }
public function error(): string { return $this->error; }
}
// An operation returning a Result
function validateEmail(string $email): Result
{
if (empty($email)) {
return Result::fail("Email must not be empty");
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return Result::fail("Invalid email format: $email");
}
return Result::ok($email);
}
function registerUser(string $name, string $email): Result
{
$validation = validateEmail($email);
if ($validation->failed()) {
return Result::fail($validation->error());
}
// Continue processing...
return Result::ok(['id' => 1, 'name' => $name, 'email' => $email]);
}
// The caller doesn't need try/catch
$result = registerUser("Budi", "budi-not-valid");
if ($result->failed()) {
echo "Failed: " . $result->error();
} else {
$user = $result->value();
echo "Success: user #{$user['id']} created";
}
Common Exception Anti-Patterns #
<?php
// ✗ Anti-pattern 1: catch everything and ignore it
try {
complexProcess();
} catch (\Exception $e) {
// Silently ignoring errors — very dangerous!
// Hidden bugs with no trace left to debug
}
// ✓ At minimum, log before continuing
try {
complexProcess();
} catch (\Exception $e) {
error_log("Process failed: " . $e->getMessage());
// Continue with a default value or re-throw
}
// ✗ Anti-pattern 2: uninformative error messages
throw new \Exception("Error"); // which error?
throw new \Exception("Something happened"); // what happened?
// ✓ Specific, actionable messages
throw new \RuntimeException(
"Failed to send email to '{$email}': SMTP server not responding (30s timeout)"
);
// ✗ Anti-pattern 3: catching too broadly in the wrong place
function calculateTotal(array $items): float
{
try {
return array_sum(array_column($items, 'price'));
} catch (\Exception $e) {
return 0; // Hides errors — this function needs no try/catch at all!
}
}
// ✓ Let exceptions propagate — catch in the right place (the caller)
function calculateTotal(array $items): float
{
return array_sum(array_column($items, 'price'));
}
// ✗ Anti-pattern 4: using Exceptions for ordinary user input validation
function processLoginForm(string $email, string $password): array
{
if (empty($email)) throw new \Exception("Email empty");
if (empty($password)) throw new \Exception("Password empty");
// ...
}
// ✓ Regular validation doesn't need Exceptions — return an errors array
function processLoginForm(string $email, string $password): array
{
$errors = [];
if (empty($email)) $errors['email'] = "Email must not be empty";
if (empty($password)) $errors['password'] = "Password must not be empty";
if (!empty($errors)) {
return ['success' => false, 'errors' => $errors];
}
// process login...
return ['success' => true, 'user' => $user];
}
Summary #
- PHP’s hierarchy:
Exceptionfor conditions the application can recover;Errorfor internal PHP failures (TypeError, ParseError). Catch\Throwableonly in a global handler for logging.finallyalways runs — whether or not an exception occurred. Use it for resource cleanup (closing files, rolling back transactions) but don’treturnfromfinallybecause it will replace thetryblock’s return value.- Exception chaining — when re-throwing, include the original exception as the third argument
new MyException("...", 0, $e). UsegetPrevious()to access the original cause.- Informative custom exceptions carry domain-specific data (
getErrors(),getProductName(),getRequested()) so callers can respond appropriately without parsing message strings.- Multi-catch
catch (TypeA | TypeB $e)lets you handle several exception types with the same code without duplicatingcatchblocks.throwas an expression (PHP 8.0+) allows throwing in null coalescing??, ternaries,match, and arrow functions — making guard conditions more concise.- Exceptions for exceptional conditions — not for normal flow like “not found” or “form validation failed”. For those cases, return
null, an errors array, or use the Result pattern.- Global handlers via
set_exception_handler()andset_error_handler()ensure all uncaught exceptions are logged and produce an appropriate response, rather than PHP’s default error page exposing sensitive information.