🛡️ Lesson 18: Error Handling
Things go wrong in programs — files don't exist, users enter bad data, databases refuse connections, APIs time out. The question isn't if errors will happen, but how your code responds when they do. PHP gives you two systems for handling problems: the legacy error system (warnings, notices, fatal errors) and the modern exception system (try/catch). In this lesson, you'll master both — and learn to build code that fails gracefully instead of crashing.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Understand PHP's error levels and what triggers each type
- Configure error reporting for development and production
- Use
try/catch/finallyto handle exceptions gracefully - Throw exceptions with
throwand create custom exception classes - Catch multiple exception types with multi-catch syntax
- Convert legacy errors to exceptions with a custom error handler
- Build a practical error-handling strategy for real applications
Estimated Time: 45 minutes
Prerequisites: Lesson 16–17 (OOP basics, inheritance)
📑 In This Lesson
PHP's Two Error Systems
PHP has two distinct mechanisms for reporting problems, and understanding the difference is essential before you start handling them.
1. Legacy Errors (Warnings, Notices, Fatal Errors)
These are PHP's original error system. When something goes wrong, PHP generates an error at a certain level (notice, warning, fatal, etc.). Some are recoverable; others kill the script immediately.
<?php
// E_NOTICE — minor issue, script continues
echo $undefinedVariable; // Notice: Undefined variable
// E_WARNING — something's wrong, script continues
$file = file_get_contents("nonexistent.txt"); // Warning: file not found
// E_ERROR (Fatal) — script stops immediately
// nonExistentFunction(); // Fatal error: Call to undefined function
2. Exceptions (Modern OOP Approach)
Exceptions are objects that represent errors. You throw them when something goes wrong, and catch them where you can handle the problem. Code between try and catch is protected — if an exception is thrown, execution jumps to the catch block instead of crashing.
<?php
try {
// Code that might fail
$data = json_decode("invalid json", true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// Handle the failure gracefully
echo "Bad JSON: " . $e->getMessage();
}
// Script continues normally after the catch block
📖 The Trend: Everything Is Moving to Exceptions
Modern PHP (8.0+) is gradually converting legacy errors into exceptions. For example, division by zero used to be a warning — now it throws a DivisionByZeroError. Type errors that used to be fatal errors are now TypeError exceptions. The future of PHP error handling is exceptions, and that's where we'll focus most of this lesson.
Error Levels & error_reporting
PHP Error Levels
| Constant | Level | Description | Fatal? |
|---|---|---|---|
E_NOTICE |
Notice | Minor issues — undefined variables, array offset issues | No |
E_WARNING |
Warning | Significant issues — missing files, wrong parameter types | No |
E_ERROR |
Fatal Error | Unrecoverable — undefined functions, out of memory | Yes |
E_PARSE |
Parse Error | Syntax errors — missing semicolons, unclosed brackets | Yes |
E_DEPRECATED |
Deprecation | Feature will be removed in a future PHP version | No |
E_STRICT |
Strict | Suggestions for forward compatibility (mostly removed in PHP 8) | No |
E_ALL |
All | All errors and warnings combined | — |
Configuring Error Reporting
<?php
// === DEVELOPMENT: Show all errors ===
error_reporting(E_ALL); // Report all error levels
ini_set("display_errors", "1"); // Show errors in browser
// === PRODUCTION: Hide errors from users, log them instead ===
error_reporting(E_ALL); // Still report all levels
ini_set("display_errors", "0"); // Don't show in browser
ini_set("log_errors", "1"); // Write to log file instead
ini_set("error_log", "/var/log/php/app_errors.log");
// You can also set these in php.ini (applies globally):
// error_reporting = E_ALL
// display_errors = Off
// log_errors = On
// error_log = /var/log/php/error.log
⚠️ Never Display Errors in Production
Error messages can reveal file paths, database credentials, code structure, and other sensitive information. Always set display_errors = Off in production and use log_errors = On instead. Check your log files to find and fix issues.
The Error Suppression Operator (@)
<?php
// The @ operator suppresses errors for a single expression
$value = @file_get_contents("maybe_missing.txt");
// If the file doesn't exist, no warning is shown
// $value will be false
// DON'T DO THIS — it hides problems and makes debugging harder
// Instead, check before you access:
if (file_exists("maybe_missing.txt")) {
$value = file_get_contents("maybe_missing.txt");
} else {
$value = "Default content";
}
// Or use try/catch with exceptions (preferred):
try {
$value = file_get_contents("maybe_missing.txt");
if ($value === false) {
throw new RuntimeException("Could not read file");
}
} catch (RuntimeException $e) {
$value = "Default content";
}
✅ Rule of Thumb
Avoid the @ operator. It silences errors entirely — including ones you'd want to know about. Use proper error checking or exception handling instead. The only defensible use of @ is with a few legacy functions where you immediately check the return value (like @fopen() followed by a check for false).
Exceptions & try/catch/finally
The try/catch/finally structure is PHP's primary mechanism for handling exceptions. It lets you attempt risky operations and respond gracefully when they fail.
Basic try/catch
<?php
try {
// Code that might throw an exception
$result = 10 / 0;
} catch (DivisionByZeroError $e) {
// Handle the specific error
echo "Can't divide by zero!\n";
echo "Error: " . $e->getMessage() . "\n";
}
echo "Script continues after the catch block.\n";
The Exception Object
When you catch an exception, you get an object with useful information about what went wrong:
<?php
try {
throw new RuntimeException("Something broke", 500);
} catch (RuntimeException $e) {
echo $e->getMessage(); // "Something broke"
echo $e->getCode(); // 500
echo $e->getFile(); // "/path/to/script.php"
echo $e->getLine(); // Line number where throw occurred
echo $e->getTraceAsString(); // Full stack trace
}
// The exception hierarchy (simplified):
// Throwable (interface)
// ├── Error (internal PHP errors — TypeError, ValueError, etc.)
// └── Exception (application-level exceptions)
// ├── RuntimeException
// ├── InvalidArgumentException
// ├── LogicException
// └── ... more built-in exceptions
Catching Multiple Exception Types
<?php
function processFile(string $path): string {
if (!file_exists($path)) {
throw new InvalidArgumentException("File not found: $path");
}
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("Could not read file: $path");
}
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
return $data["name"] ?? "Unknown";
}
// Catch different exception types with separate catch blocks
try {
$name = processFile("config.json");
echo "Name: $name\n";
} catch (InvalidArgumentException $e) {
// Handle missing file
echo "Bad input: " . $e->getMessage() . "\n";
} catch (JsonException $e) {
// Handle malformed JSON
echo "Invalid JSON: " . $e->getMessage() . "\n";
} catch (RuntimeException $e) {
// Handle read errors
echo "Read error: " . $e->getMessage() . "\n";
}
// PHP 8+ multi-catch — catch multiple types in one block
try {
$name = processFile("config.json");
} catch (InvalidArgumentException | JsonException $e) {
// Handle both input errors the same way
echo "Input problem: " . $e->getMessage() . "\n";
} catch (RuntimeException $e) {
echo "System error: " . $e->getMessage() . "\n";
}
The finally Block
The finally block runs no matter what — whether an exception was thrown, caught, or not. It's the perfect place for cleanup code.
<?php
function readConfig(string $path): array {
$handle = null;
try {
$handle = fopen($path, "r");
if ($handle === false) {
throw new RuntimeException("Cannot open: $path");
}
$content = fread($handle, filesize($path));
$config = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
return $config;
} catch (RuntimeException $e) {
echo "File error: " . $e->getMessage() . "\n";
return []; // Return empty config as fallback
} catch (JsonException $e) {
echo "Invalid config format: " . $e->getMessage() . "\n";
return [];
} finally {
// This runs regardless — exception or not
if ($handle !== null) {
fclose($handle);
echo "File handle closed.\n";
}
}
}
// Case 1: File exists and is valid JSON → returns config, finally closes handle
// Case 2: File missing → catch runs, finally still runs
// Case 3: Invalid JSON → catch runs, finally still runs
✅ When to Use finally
Use finally for cleanup that must happen regardless of success or failure: closing file handles, database connections, releasing locks, stopping timers, or restoring state. Even if you return from inside a try or catch block, the finally block still runs before the function returns.
Throwing Exceptions
You don't just catch exceptions — you throw them when your code detects a problem it can't handle locally. This passes the problem up the call stack to whoever called your function.
Basic throw
<?php
function divide(float $a, float $b): float {
if ($b == 0) {
throw new InvalidArgumentException("Cannot divide by zero");
}
return $a / $b;
}
function calculateAverage(array $numbers): float {
if (empty($numbers)) {
throw new InvalidArgumentException("Array cannot be empty");
}
return array_sum($numbers) / count($numbers);
}
// Using the functions
try {
echo divide(10, 3); // 3.333...
echo divide(10, 0); // Throws!
} catch (InvalidArgumentException $e) {
echo "Math error: " . $e->getMessage();
}
Exception Chaining
When catching one exception and throwing a new one, you can preserve the original as the previous exception. This creates a chain that helps with debugging — you can see the root cause.
<?php
class ConfigLoader {
public function load(string $path): array {
try {
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("Cannot read file");
}
return json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
// Wrap the low-level exception in a more meaningful one
// Pass $e as the third argument to preserve the chain
throw new RuntimeException(
"Config file '$path' contains invalid JSON",
0, // code
$e // previous exception
);
}
}
}
try {
$loader = new ConfigLoader();
$config = $loader->load("config.json");
} catch (RuntimeException $e) {
echo "Error: " . $e->getMessage() . "\n";
// "Config file 'config.json' contains invalid JSON"
// Access the original exception
$prev = $e->getPrevious();
if ($prev) {
echo "Caused by: " . $prev->getMessage() . "\n";
// "Syntax error" (from JsonException)
}
}
Throw Expression (PHP 8.0+)
<?php
// In PHP 8+, throw is an expression (not just a statement)
// This means you can use it in more places:
// With the null coalescing operator
$name = $config["name"] ?? throw new RuntimeException("Name is required");
// With the ternary operator
$age = $input >= 0 ? $input : throw new InvalidArgumentException("Age must be positive");
// With the null coalescing assignment
$value ??= throw new RuntimeException("Value was never set");
// In arrow functions
$validator = fn($x) => $x > 0 ? $x : throw new InvalidArgumentException("Must be positive");
📖 When to Throw vs. Return
Throw an exception when a function cannot fulfill its contract — the inputs are invalid, a required resource is unavailable, or an operation fails in a way the function can't recover from. Let the caller decide how to handle it.
Return a value (like null, false, or a default) when the absence of a result is a normal, expected outcome — not an error. For example, searching for a user who doesn't exist might return null rather than throwing.
Built-In Exception Classes
PHP provides a hierarchy of exception classes. Choosing the right one makes your error handling more precise and your code more readable.
Logic Exceptions (Programmer Mistakes)
These represent bugs — problems that should be fixed in code, not caught at runtime.
| Exception | When to Use | Example |
|---|---|---|
InvalidArgumentException |
Function received an invalid argument | Negative number for age, empty string for name |
LengthException |
Invalid length detected | Password too short, array exceeds maximum size |
OutOfRangeException |
Value is outside an expected range | Month = 13, array index beyond bounds |
BadMethodCallException |
Method called incorrectly or doesn't exist | Calling a method that requires setup that wasn't done |
LogicException |
General logic error (base class) | Program flow violation, invalid state |
Runtime Exceptions (External Failures)
These represent problems that occur during execution — even in correctly written code.
| Exception | When to Use | Example |
|---|---|---|
RuntimeException |
General runtime failure | File not found, API unreachable, operation failed |
OverflowException |
Adding to a full container | Queue at max capacity, buffer full |
UnderflowException |
Removing from an empty container | Dequeue from empty queue, pop from empty stack |
OutOfBoundsException |
Invalid key/index at runtime | Accessing a non-existent array key or collection item |
UnexpectedValueException |
Unexpected value encountered | Database returned unexpected data format |
Errors (Internal PHP Problems)
<?php
// TypeError — wrong type passed to function
function addInts(int $a, int $b): int {
return $a + $b;
}
try {
echo addInts("hello", "world"); // TypeError
} catch (TypeError $e) {
echo "Type error: " . $e->getMessage();
}
// ValueError — right type, wrong value (PHP 8+)
try {
$colors = ["red", "green", "blue"];
array_chunk($colors, -1); // ValueError: size must be > 0
} catch (ValueError $e) {
echo "Value error: " . $e->getMessage();
}
// DivisionByZeroError
try {
$result = 10 % 0; // DivisionByZeroError
} catch (DivisionByZeroError $e) {
echo "Division error: " . $e->getMessage();
}
// Catch any throwable (both Errors and Exceptions)
try {
// ... risky code ...
} catch (\Throwable $e) {
// Catches EVERYTHING — use sparingly
echo "Something went wrong: " . $e->getMessage();
}
⚠️ Catching \Throwable
Catching \Throwable catches everything — including Error subclasses like TypeError and ParseError. Only do this at the top level of your application (like a global error handler). In regular code, catch specific exception types so you don't accidentally swallow errors that indicate genuine bugs.
Custom Exception Classes
Custom exceptions let you define application-specific error types. This makes your error handling more expressive and lets callers catch exactly the errors they care about.
Creating Custom Exceptions
<?php
// Simple custom exception — just a named type
class UserNotFoundException extends RuntimeException {}
class DuplicateEmailException extends RuntimeException {}
class InsufficientFundsException extends RuntimeException {}
// Custom exception with extra data
class ValidationException extends RuntimeException {
private array $errors;
public function __construct(array $errors, int $code = 0, ?\Throwable $previous = null) {
$this->errors = $errors;
$message = "Validation failed: " . implode(", ", $errors);
parent::__construct($message, $code, $previous);
}
public function getErrors(): array {
return $this->errors;
}
}
// Custom exception with HTTP status code
class HttpException extends RuntimeException {
public function __construct(
public readonly int $statusCode,
string $message = "",
?\Throwable $previous = null,
) {
parent::__construct($message, $statusCode, $previous);
}
}
class NotFoundException extends HttpException {
public function __construct(string $message = "Not Found", ?\Throwable $previous = null) {
parent::__construct(404, $message, $previous);
}
}
class ForbiddenException extends HttpException {
public function __construct(string $message = "Forbidden", ?\Throwable $previous = null) {
parent::__construct(403, $message, $previous);
}
}
Using Custom Exceptions
<?php
class UserService {
private array $users = [];
public function register(string $name, string $email, string $password): array {
// Validate
$errors = [];
if (empty($name)) $errors[] = "Name is required";
if (empty($email)) $errors[] = "Email is required";
if (strlen($password) < 8) $errors[] = "Password must be at least 8 characters";
if (!empty($errors)) {
throw new ValidationException($errors);
}
// Check for duplicate
foreach ($this->users as $user) {
if ($user["email"] === $email) {
throw new DuplicateEmailException("Email already registered: $email");
}
}
// Create user
$user = [
"id" => count($this->users) + 1,
"name" => $name,
"email" => $email,
];
$this->users[] = $user;
return $user;
}
public function findById(int $id): array {
foreach ($this->users as $user) {
if ($user["id"] === $id) {
return $user;
}
}
throw new UserNotFoundException("User not found: #$id");
}
}
// Usage with targeted error handling
$service = new UserService();
try {
$user = $service->register("", "bad-email", "123");
} catch (ValidationException $e) {
echo "Fix these errors:\n";
foreach ($e->getErrors() as $error) {
echo " - $error\n";
}
} catch (DuplicateEmailException $e) {
echo "That email is taken. Try another or log in.\n";
}
try {
$user = $service->findById(999);
} catch (UserNotFoundException $e) {
echo $e->getMessage(); // "User not found: #999"
}
✅ Custom Exception Best Practices
- Extend the right base class: Use
InvalidArgumentExceptionfor bad inputs,RuntimeExceptionfor operational failures - Name them clearly:
UserNotFoundExceptionis more meaningful thanNotFoundException - Add useful data: Include relevant context (like
getErrors()onValidationException) - Keep the hierarchy shallow: 1–2 levels of custom exceptions is usually enough
- Group in a namespace:
App\Exceptions\UserNotFoundException
Converting Errors to Exceptions
Many built-in PHP functions still use the legacy error system (warnings and notices) instead of throwing exceptions. You can bridge the gap by registering a custom error handler that converts these errors into exceptions.
set_error_handler
<?php
// Convert PHP errors (warnings, notices) into ErrorException objects
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
// Don't convert errors that are suppressed by error_reporting setting
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
// Now warnings become catchable exceptions!
try {
$content = file_get_contents("nonexistent.txt");
// Without the handler: E_WARNING, $content = false, script continues
// WITH the handler: throws ErrorException, caught by catch block
} catch (ErrorException $e) {
echo "Caught: " . $e->getMessage() . "\n";
// "Caught: file_get_contents(nonexistent.txt): Failed to open stream: No such file..."
}
set_exception_handler (Last Resort)
<?php
// Global handler for any uncaught exception — the last line of defense
set_exception_handler(function (\Throwable $e): void {
// Log the error
error_log(sprintf(
"[%s] Uncaught %s: %s in %s:%d\nStack trace:\n%s",
date("Y-m-d H:i:s"),
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
));
// Show a user-friendly message (production)
if (ini_get("display_errors") === "0") {
http_response_code(500);
echo "<h1>Something went wrong</h1>";
echo "<p>We're looking into it. Please try again later.</p>";
} else {
// Development — show the full error
echo "<h1>" . get_class($e) . "</h1>";
echo "<p>" . $e->getMessage() . "</p>";
echo "<pre>" . $e->getTraceAsString() . "</pre>";
}
});
// If this exception isn't caught anywhere, the handler above catches it
throw new RuntimeException("Unhandled error!");
A Complete Bootstrap Setup
<?php
// File: bootstrap.php — include at the top of your application
// 1. Set error reporting
error_reporting(E_ALL);
// 2. Development vs. Production
$isDev = ($_ENV["APP_ENV"] ?? "development") === "development";
ini_set("display_errors", $isDev ? "1" : "0");
ini_set("log_errors", "1");
ini_set("error_log", __DIR__ . "/logs/error.log");
// 3. Convert errors to exceptions
set_error_handler(function (int $severity, string $message, string $file, int $line): bool {
if (!(error_reporting() & $severity)) {
return false;
}
throw new ErrorException($message, 0, $severity, $file, $line);
});
// 4. Global exception handler (safety net)
set_exception_handler(function (\Throwable $e) use ($isDev): void {
error_log("[" . date("Y-m-d H:i:s") . "] " . get_class($e) . ": "
. $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
http_response_code(500);
if ($isDev) {
echo "<h1>" . htmlspecialchars(get_class($e)) . "</h1>";
echo "<p>" . htmlspecialchars($e->getMessage()) . "</p>";
echo "<pre>" . htmlspecialchars($e->getTraceAsString()) . "</pre>";
} else {
echo "<h1>Server Error</h1><p>Please try again later.</p>";
}
});
// Now every file that includes bootstrap.php gets consistent error handling
📖 The Three Layers of Defense
- try/catch — Local handling: catch specific exceptions where you can recover
- set_error_handler — Convert legacy warnings/notices into exceptions
- set_exception_handler — Global safety net for anything uncaught
Together, these three layers ensure that no error goes unhandled — whether it's a PHP warning, a thrown exception, or an uncaught exception.
Error Handling in Practice
Let's put it all together with real-world patterns you'll use in every PHP project.
Pattern 1: Validate Input Early, Fail Fast
<?php
class ContactForm {
public static function validate(array $data): array {
$errors = [];
if (empty($data["name"] ?? "")) {
$errors["name"] = "Name is required";
}
$email = $data["email"] ?? "";
if (empty($email)) {
$errors["email"] = "Email is required";
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors["email"] = "Invalid email format";
}
$message = $data["message"] ?? "";
if (empty($message)) {
$errors["message"] = "Message is required";
} elseif (strlen($message) < 10) {
$errors["message"] = "Message must be at least 10 characters";
}
return $errors;
}
public static function submit(array $data): string {
// Validate first — fail fast if input is bad
$errors = self::validate($data);
if (!empty($errors)) {
throw new ValidationException($errors);
}
// If we get here, data is valid — proceed with confidence
// ... send email, save to database, etc.
return "Message sent successfully!";
}
}
// Usage
try {
$result = ContactForm::submit($_POST);
echo $result;
} catch (ValidationException $e) {
// Show errors next to form fields
foreach ($e->getErrors() as $field => $error) {
echo "<p class='error'>$error</p>\n";
}
}
Pattern 2: Wrap Risky Operations
<?php
class ApiClient {
public function __construct(
private string $baseUrl,
private int $timeout = 10,
) {}
public function get(string $endpoint): array {
$url = $this->baseUrl . $endpoint;
// Create a stream context with timeout
$context = stream_context_create([
"http" => [
"timeout" => $this->timeout,
"ignore_errors" => true,
],
]);
try {
$response = file_get_contents($url, false, $context);
if ($response === false) {
throw new RuntimeException("Request failed: $url");
}
$data = json_decode($response, true, 512, JSON_THROW_ON_ERROR);
// Check for API-level errors
if (isset($data["error"])) {
throw new RuntimeException("API error: " . $data["error"]);
}
return $data;
} catch (JsonException $e) {
throw new RuntimeException("Invalid response from API: $url", 0, $e);
}
}
}
// Usage with retry logic
function fetchWithRetry(ApiClient $client, string $endpoint, int $maxRetries = 3): array {
$lastException = null;
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
return $client->get($endpoint);
} catch (RuntimeException $e) {
$lastException = $e;
echo "Attempt $attempt failed: {$e->getMessage()}\n";
if ($attempt < $maxRetries) {
sleep($attempt); // Exponential-ish backoff
}
}
}
throw new RuntimeException(
"All $maxRetries attempts failed for $endpoint",
0,
$lastException
);
}
Pattern 3: Logging Errors
<?php
class SimpleLogger {
private string $logFile;
public function __construct(string $logFile = "app.log") {
$this->logFile = $logFile;
$dir = dirname($logFile);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
}
public function error(string $message, ?\Throwable $exception = null): void {
$entry = "[" . date("Y-m-d H:i:s") . "] ERROR: $message";
if ($exception) {
$entry .= "\n Exception: " . get_class($exception)
. ": " . $exception->getMessage()
. "\n File: " . $exception->getFile() . ":" . $exception->getLine()
. "\n Trace: " . $exception->getTraceAsString();
}
file_put_contents($this->logFile, $entry . "\n\n", FILE_APPEND | LOCK_EX);
}
public function info(string $message): void {
$entry = "[" . date("Y-m-d H:i:s") . "] INFO: $message\n";
file_put_contents($this->logFile, $entry, FILE_APPEND | LOCK_EX);
}
}
// Usage
$logger = new SimpleLogger("logs/app.log");
try {
// ... risky operation ...
throw new RuntimeException("Database connection timeout");
} catch (RuntimeException $e) {
$logger->error("Failed to process request", $e);
// Show user-friendly message, not the raw error
echo "Sorry, something went wrong. Please try again.";
}
✅ Do's and ❌ Don'ts
✅ Error Handling Do's
- Catch specific exceptions — not just
ExceptionorThrowable - Fail fast — validate inputs at the boundary, throw immediately on bad data
- Log errors — always log exceptions with full context for debugging
- Chain exceptions — preserve the root cause with the
$previousparameter - Clean up in finally — close resources regardless of success or failure
- Use custom exceptions — named types are more meaningful than generic messages
- Show user-friendly errors — never expose stack traces in production
❌ Error Handling Don'ts
- Don't catch and ignore — an empty catch block hides bugs:
catch (Exception $e) { } - Don't catch Throwable everywhere — catch only what you can handle
- Don't use exceptions for control flow — exceptions are for exceptional situations, not normal logic
- Don't expose internals — error messages to users should be vague; logs should be detailed
- Don't use @ suppression — it hides important warnings and makes debugging painful
- Don't re-throw without context — if you re-throw, add information or use chaining
Hands-On Exercises
🏋️ Exercise 1: Safe Calculator
Objective: Build a calculator class with comprehensive error handling.
Instructions:
- Create a
Calculatorclass with methods:add(),subtract(),multiply(),divide(),power() - Each method takes two
floatparameters and returns afloat divide()throwsInvalidArgumentExceptionon division by zeropower()throwsOverflowExceptionif the result exceedsPHP_FLOAT_MAX- Add a
calculate(string $operator, float $a, float $b)method that dispatches to the right method, throwingInvalidArgumentExceptionfor unknown operators - Keep a history of all operations (including errors) and provide a
getHistory()method
💡 Hint
Use a match expression in calculate() to dispatch operators. For the history, store each operation as an associative array with keys like operator, a, b, result, and error. Wrap each operation in try/catch inside calculate() to log both successes and failures.
✅ Solution
<?php
class Calculator {
private array $history = [];
public function add(float $a, float $b): float {
return $a + $b;
}
public function subtract(float $a, float $b): float {
return $a - $b;
}
public function multiply(float $a, float $b): float {
return $a * $b;
}
public function divide(float $a, float $b): float {
if ($b == 0) {
throw new InvalidArgumentException("Cannot divide by zero");
}
return $a / $b;
}
public function power(float $base, float $exponent): float {
$result = $base ** $exponent;
if (is_infinite($result)) {
throw new OverflowException(
"Result too large: $base ^ $exponent exceeds float maximum"
);
}
return $result;
}
public function calculate(string $operator, float $a, float $b): float {
try {
$result = match ($operator) {
"+" => $this->add($a, $b),
"-" => $this->subtract($a, $b),
"*" => $this->multiply($a, $b),
"/" => $this->divide($a, $b),
"**" => $this->power($a, $b),
default => throw new InvalidArgumentException("Unknown operator: $operator"),
};
$this->history[] = [
"expression" => "$a $operator $b",
"result" => $result,
"error" => null,
];
return $result;
} catch (\Throwable $e) {
$this->history[] = [
"expression" => "$a $operator $b",
"result" => null,
"error" => $e->getMessage(),
];
throw $e; // Re-throw so the caller can handle it
}
}
public function getHistory(): array {
return $this->history;
}
}
// Test it
$calc = new Calculator();
echo $calc->calculate("+", 10, 5) . "\n"; // 15
echo $calc->calculate("*", 3, 7) . "\n"; // 21
try {
$calc->calculate("/", 10, 0);
} catch (InvalidArgumentException $e) {
echo "Error: " . $e->getMessage() . "\n";
}
try {
$calc->calculate("**", 999999, 999999);
} catch (OverflowException $e) {
echo "Error: " . $e->getMessage() . "\n";
}
echo "\nHistory:\n";
foreach ($calc->getHistory() as $entry) {
$status = $entry["error"] ? "FAIL: {$entry['error']}" : "= {$entry['result']}";
echo " {$entry['expression']} → $status\n";
}
🏋️ Exercise 2: File Processor with Error Handling
Objective: Build a JsonFileProcessor that reads, validates, and processes JSON files with proper error handling at every step.
Instructions:
- Create custom exceptions:
FileNotFoundException,FileReadException,JsonParseException - Create a
JsonFileProcessorclass with aprocess(string $path): arraymethod - The method should: check if the file exists (throw
FileNotFoundException), read the file (throwFileReadExceptionon failure), parse JSON (throwJsonParseExceptionon invalid JSON) - Use exception chaining to preserve root causes
- Add a
processOrDefault(string $path, array $default): arraymethod that returns the default on any error
💡 Hint
Each custom exception should extend RuntimeException. In the processOrDefault() method, wrap process() in a try/catch that catches RuntimeException (the base for all your custom exceptions) and returns the default.
✅ Solution
<?php
class FileNotFoundException extends RuntimeException {}
class FileReadException extends RuntimeException {}
class JsonParseException extends RuntimeException {}
class JsonFileProcessor {
public function process(string $path): array {
// Step 1: Check existence
if (!file_exists($path)) {
throw new FileNotFoundException("File not found: $path");
}
// Step 2: Read file
$content = file_get_contents($path);
if ($content === false) {
throw new FileReadException("Could not read file: $path");
}
// Step 3: Parse JSON
try {
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new JsonParseException(
"Invalid JSON in file: $path",
0,
$e // Chain the original JsonException
);
}
return $data;
}
public function processOrDefault(string $path, array $default = []): array {
try {
return $this->process($path);
} catch (RuntimeException $e) {
// Log the error but return the default
error_log("JsonFileProcessor: " . $e->getMessage());
return $default;
}
}
}
// Test
$processor = new JsonFileProcessor();
// Happy path
try {
$data = $processor->process("data/users.json");
echo "Loaded " . count($data) . " records\n";
} catch (FileNotFoundException $e) {
echo "Missing: " . $e->getMessage() . "\n";
} catch (JsonParseException $e) {
echo "Bad JSON: " . $e->getMessage() . "\n";
echo " Caused by: " . $e->getPrevious()->getMessage() . "\n";
} catch (FileReadException $e) {
echo "Read error: " . $e->getMessage() . "\n";
}
// Safe fallback version
$config = $processor->processOrDefault("config.json", [
"debug" => false,
"name" => "Default App",
]);
echo "App name: " . $config["name"] . "\n";
🎯 Quick Quiz
Question 1: What is the purpose of the finally block?
Question 2: What should you use to catch both Error and Exception in a single catch block?
Question 3: What does set_error_handler() allow you to do?
Question 4: Why should you avoid empty catch blocks like catch (Exception $e) { }?
Question 5: What is exception chaining used for?
Summary
🎉 Key Takeaways
- Two error systems: Legacy errors (warnings, notices, fatals) and modern exceptions (
try/catch). The trend is toward exceptions. - Error reporting: Use
E_ALLalways. Show errors in development (display_errors = On), log them in production (display_errors = Off,log_errors = On). - try/catch/finally:
tryprotects risky code,catchhandles specific exceptions,finallyalways runs for cleanup. - Throwing exceptions: Use
throwwhen a function can't fulfill its contract. Chain exceptions to preserve root causes. - Built-in hierarchy:
InvalidArgumentExceptionfor bad inputs,RuntimeExceptionfor operational failures,TypeError/ValueErrorfor type/value issues. - Custom exceptions: Create named exception classes for domain-specific errors. Add extra data (like validation errors) as properties.
- Error-to-exception conversion: Use
set_error_handler()to convert legacy warnings intoErrorExceptionobjects. - Global safety net: Use
set_exception_handler()to catch any uncaught exception and log it. - Best practices: Catch specific types, never swallow exceptions silently, log everything, show friendly messages to users.
📚 Additional Resources
- PHP Manual: Exceptions
- PHP Manual: set_error_handler()
- PHP Manual: set_exception_handler()
- PHP Manual: ErrorException
- PHP Manual: Errors in PHP 7+
🚀 What's Next?
You now know how to handle errors like a professional. In Lesson 19: PDO — Connecting to MySQL & Prepared Statements, you'll use these skills immediately — database connections can fail, queries can go wrong, and proper error handling is what separates a crash from a graceful recovery. Let's connect PHP to the MySQL databases you built in the MySQL Foundations course!
🎉 Congratulations!
You've mastered PHP error handling! From error levels to exceptions to custom error classes, you can now build code that handles failure gracefully — a critical skill for the database-connected applications coming next.