Skip to main content

🛡️ 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/finally to handle exceptions gracefully
  • Throw exceptions with throw and 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
flowchart LR A[Problem Occurs] --> B{Which system?} B -->|Legacy Error| C[PHP generates error at a level] C --> D{Fatal?} D -->|Yes| E[Script dies] D -->|No| F[Warning/Notice shown, script continues] B -->|Exception| G[Exception object thrown] G --> H{Caught?} H -->|Yes| I[catch block handles it] H -->|No| J[Uncaught exception — script dies]

📖 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
flowchart TD A["try block starts"] --> B{Exception thrown?} B -->|No| C[try block completes normally] B -->|Yes| D{Matching catch block?} D -->|Yes| E[catch block runs] D -->|No| F[Exception propagates up] C --> G["finally block runs (always)"] E --> G F --> G G --> H[Continue execution]

✅ 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.

classDiagram class Throwable { <<interface>> } class Error { TypeError ValueError DivisionByZeroError ArithmeticError } class Exception { RuntimeException LogicException InvalidArgumentException OverflowException UnderflowException RangeException LengthException OutOfRangeException OutOfBoundsException BadMethodCallException } Throwable <|.. Error Throwable <|.. Exception

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 InvalidArgumentException for bad inputs, RuntimeException for operational failures
  • Name them clearly: UserNotFoundException is more meaningful than NotFoundException
  • Add useful data: Include relevant context (like getErrors() on ValidationException)
  • 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

  1. try/catch — Local handling: catch specific exceptions where you can recover
  2. set_error_handler — Convert legacy warnings/notices into exceptions
  3. 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 Exception or Throwable
  • 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 $previous parameter
  • 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:

  1. Create a Calculator class with methods: add(), subtract(), multiply(), divide(), power()
  2. Each method takes two float parameters and returns a float
  3. divide() throws InvalidArgumentException on division by zero
  4. power() throws OverflowException if the result exceeds PHP_FLOAT_MAX
  5. Add a calculate(string $operator, float $a, float $b) method that dispatches to the right method, throwing InvalidArgumentException for unknown operators
  6. 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:

  1. Create custom exceptions: FileNotFoundException, FileReadException, JsonParseException
  2. Create a JsonFileProcessor class with a process(string $path): array method
  3. The method should: check if the file exists (throw FileNotFoundException), read the file (throw FileReadException on failure), parse JSON (throw JsonParseException on invalid JSON)
  4. Use exception chaining to preserve root causes
  5. Add a processOrDefault(string $path, array $default): array method 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_ALL always. Show errors in development (display_errors = On), log them in production (display_errors = Off, log_errors = On).
  • try/catch/finally: try protects risky code, catch handles specific exceptions, finally always runs for cleanup.
  • Throwing exceptions: Use throw when a function can't fulfill its contract. Chain exceptions to preserve root causes.
  • Built-in hierarchy: InvalidArgumentException for bad inputs, RuntimeException for operational failures, TypeError/ValueError for 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 into ErrorException objects.
  • 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

🚀 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.