Skip to main content
    Courses/PHP/Advanced Error Handling

    Lesson 17 • Advanced

    Advanced Error Handling 🚨

    By the end of this lesson you'll catch failures gracefully with try/catch/finally, design your own exception classes, chain causes for clean logs, and report errors safely in production — so a single bad request never takes your whole app down.

    What You'll Learn in This Lesson

    • Tell the difference between an Error and an Exception
    • Guard risky code with try / catch / finally
    • Catch specific exception types and throw your own
    • Write custom exception classes that extend Exception
    • Chain exceptions and register global handlers
    • Log with error_log and show safe errors in production

    1️⃣ try / catch / finally

    When code might fail, you wrap it in a try block. If anything inside throws an exception, PHP jumps straight to a matching catch block instead of crashing. An exception is simply an object that means "stop — something went wrong" and carries a message describing what. The optional finally block runs afterwards no matter what — success or failure — which makes it the perfect place for cleanup like closing a file or a database connection.

    Guarding risky code
    <?php
    // An EXCEPTION is an object that says "something went wrong" and stops normal
    // flow until some code 'catches' it. You guard risky code inside a try block.
    
    function divide(int $a, int $b): float
    {
        // Dividing by zero throws a DivisionByZeroError in PHP — we re-flag it as
        // an Exception so the caller can catch it like any other failure.
        if ($b === 0) {
            throw new InvalidArgumentException("Cannot divide by zero");
        }
        return $a / $b;
    }
    
    foreach ([[10, 2], [7, 0]] as [$a, $b]) {
        try {
            // 'try' = "attempt this; it might throw".
            $result = divide($a, $b);
            echo "$a / $b = $result\n";
        } catch (InvalidArgumentException $e) {
            // 'catch' runs ONLY if a matching exception was thrown.
            echo "Skipped: " . $e->getMessage() . "\n";
        } finally {
            // 'finally' ALWAYS runs — success or failure. Perfect for cleanup.
            echo "  (done with $a / $b)\n";
        }
    }
    ?>
    Output
    10 / 2 = 5
      (done with 10 / 2)
    Skipped: Cannot divide by zero
      (done with 7 / 0)
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Read the output order carefully: for the failing case, the catch runs and then the finally still runs. That guarantee is why you put cleanup in finally rather than copying it into both the success and failure paths.

    2️⃣ Errors vs Exceptions, and Catching Specific Types

    PHP has two families of "throwable" things. An Exception is a recoverable problem your code raises on purpose with throw — bad input, a missing record. An Error (like TypeError or DivisionByZeroError) is an engine-level fault, usually a bug. Both implement the Throwable interface, so catch (Throwable $e) catches either. The key habit: list your most specific catch first, because PHP uses the first one that matches.

    Specific catches, then a Throwable safety net
    <?php
    // PHP's hierarchy. Everything throwable implements the Throwable interface:
    //   Throwable
    //   ├── Error      — engine-level faults: TypeError, DivisionByZeroError, ...
    //   └── Exception  — the kind YOUR code should throw and catch
    //       ├── RuntimeException, InvalidArgumentException, LogicException, ...
    
    function parseAge(string $input): int
    {
        if (!is_numeric($input)) {
            // You throw an Exception on purpose to signal a recoverable problem.
            throw new InvalidArgumentException("'$input' is not a number");
        }
        return (int) $input;
    }
    
    // Catch the MOST SPECIFIC type first, then fall back to broader ones.
    foreach (["42", "oops"] as $value) {
        try {
            echo "Age: " . parseAge($value) . "\n";
        } catch (InvalidArgumentException $e) {
            // Specific catch: we know exactly what kind of failure this is.
            echo "Bad input: " . $e->getMessage() . "\n";
        } catch (Throwable $e) {
            // Safety net: catches anything — Error OR Exception — we didn't expect.
            echo "Unexpected: " . $e->getMessage() . "\n";
        }
    }
    
    // You can even catch an engine Error. Here a TypeError is thrown because a
    // string can't be used where an int is required.
    try {
        echo (10 / 0);                 // DivisionByZeroError
    } catch (DivisionByZeroError $e) {
        echo "Math error: " . $e->getMessage() . "\n";
    }
    ?>
    Output
    Age: 42
    Bad input: 'oops' is not a number
    Math error: Division by zero
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    Notice you can even catch an engine Error such as DivisionByZeroError. You generally shouldn't rely on that to paper over bugs, but it's invaluable as a last-resort net so one unexpected fault doesn't take down the whole request.

    3️⃣ Custom Exception Classes

    Throwing the generic Exception everywhere forces every caller to catch everything and then inspect the message string to figure out what happened. Instead, make your own types by writing class ValidationException extends Exception. Now you can catch (ValidationException $e) separately from a not-found error, and each class can carry extra data — here an HTTP status code — so the error knows how it should be handled.

    An exception hierarchy with status codes
    <?php
    // A CUSTOM EXCEPTION is just a class that 'extends Exception'. It can carry
    // extra data (here an HTTP status code) so each error knows how to be handled.
    class AppException extends Exception
    {
        public function __construct(string $message, private int $statusCode = 500)
        {
            parent::__construct($message);   // let Exception store the message
        }
    
        public function getStatusCode(): int
        {
            return $this->statusCode;
        }
    }
    
    // Child classes set their own status code — granular, type-safe errors.
    class ValidationException extends AppException
    {
        public function __construct(public string $field, string $message)
        {
            parent::__construct($message, 422);   // 422 = Unprocessable Entity
        }
    }
    
    class NotFoundException extends AppException
    {
        public function __construct(string $resource, int $id)
        {
            parent::__construct("$resource #$id not found", 404);
        }
    }
    
    $samples = [
        new ValidationException("email", "Invalid email format"),
        new NotFoundException("User", 42),
    ];
    
    echo "Exception            | Code | Message\n";
    echo "---------------------+------+----------------------------\n";
    foreach ($samples as $e) {
        // get_class() = the class name; getStatusCode() = our custom data.
        printf("%-20s | %-4d | %s\n", get_class($e), $e->getStatusCode(), $e->getMessage());
    }
    ?>
    Output
    Exception            | Code | Message
    ---------------------+------+----------------------------
    ValidationException  | 422  | Invalid email format
    NotFoundException    | 404  | User #42 not found
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    4️⃣ Exception Chaining

    Often a low-level failure (a file won't open) should become a clearer high-level one (the config can't load) — but you don't want to lose the original cause. Exception chaining solves this: when you re-throw, pass the original exception as the third constructor argument. Later, getPrevious() walks back to the root cause, so users see the friendly message while your logs keep the full trail.

    Wrap a cause, recover it with getPrevious()
    <?php
    // Exception chaining keeps the ORIGINAL cause attached while you re-throw a
    // friendlier, higher-level exception. Pass the cause as the 3rd argument.
    class ConfigException extends Exception {}
    
    function loadConfig(string $path): array
    {
        try {
            // This low-level call fails with a built-in exception.
            throw new RuntimeException("file '$path' not readable");
        } catch (RuntimeException $cause) {
            // 0 = default code; $cause = the previous exception we are wrapping.
            throw new ConfigException("Could not load config", 0, $cause);
        }
    }
    
    try {
        loadConfig("/etc/app.ini");
    } catch (ConfigException $e) {
        echo "Top:   " . $e->getMessage() . "\n";
        // getPrevious() walks back to the original root cause.
        echo "Cause: " . $e->getPrevious()->getMessage() . "\n";
    }
    ?>
    Output
    Top:   Could not load config
    Cause: file '/etc/app.ini' not readable
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    5️⃣ Global Handlers, Logging & Dev vs Production

    No matter how careful you are, something will eventually slip past every try. set_exception_handler() registers a last line of defence that runs for any uncaught exception, and set_error_handler() lets you turn old-style warnings into exceptions so they flow through the same path. The other half is where errors go: in production set display_errors to 0 so visitors never see raw errors (they leak paths and secrets), and send the details to a log with error_log() instead.

    Environment-aware handlers and logging
    <?php
    // In DEVELOPMENT you want every detail on screen; in PRODUCTION you must hide
    // it from users (it leaks paths, queries, secrets) and log it instead.
    $isProduction = true;                          // flip to false on your machine
    
    if ($isProduction) {
        ini_set("display_errors", "0");            // never show errors to visitors
        error_reporting(E_ALL);                    // but still REPORT them (to logs)
    } else {
        ini_set("display_errors", "1");            // show everything while coding
        error_reporting(E_ALL);
    }
    
    // set_error_handler(): turn old-style PHP warnings/notices into exceptions so
    // they flow through the same try/catch path as everything else.
    set_error_handler(function (int $level, string $message, string $file, int $line) {
        throw new ErrorException($message, 0, $level, $file, $line);
    });
    
    // set_exception_handler(): the LAST line of defence — runs for any exception
    // that nothing else caught, just before the script would die.
    set_exception_handler(function (Throwable $e) {
        // error_log() writes to the server log — the right place for the details.
        error_log("UNCAUGHT: " . $e->getMessage() . " in " . $e->getFile() . ":" . $e->getLine());
        http_response_code(500);
        // Users only ever see a safe, generic message.
        echo "Sorry, something went wrong. Please try again later.\n";
    });
    
    echo "Handlers registered.\n";
    
    // A leftover undefined variable would normally be a notice; our error handler
    // now turns it into an exception we can catch.
    try {
        $total = $price * 2;                       // $price was never defined
    } catch (ErrorException $e) {
        echo "Caught a converted warning: " . $e->getMessage() . "\n";
    }
    ?>
    Output
    Handlers registered.
    Caught a converted warning: Undefined variable $price
    This is real code — run it for free atonecompiler.com/phpor in your own editor.

    The golden rule sits in those two lines: log the full detail, show the user a friendly generic message. On your own machine you'd flip $isProduction to false and turn display_errors back on so you see everything immediately.

    🎯 Your Turn

    Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.

    🎯 Your turn: throw and catch
    <?php
    // 🎯 YOUR TURN — finish the try/catch so the bad call is handled gracefully.
    
    function withdraw(int $balance, int $amount): int
    {
        if ($amount > $balance) {
            // 1) throw an exception when there isn't enough money
            throw ___("Insufficient funds");   // 👉 use  new RuntimeException
        }
        return $balance - $amount;
    }
    
    foreach ([50, 200] as $amount) {
        // 2) wrap the risky call so a thrown exception doesn't crash the loop
        ___ {                                  // 👉 the keyword is  try
            echo "New balance: " . withdraw(100, $amount) . "\n";
        } catch (RuntimeException $e) {        // 3) (already done) catch it
            echo "Declined: " . $e->getMessage() . "\n";
        }
    }
    
    // ✅ Expected output:
    //    New balance: 50
    //    Declined: Insufficient funds
    ?>
    Output
    New balance: 50
    Declined: Insufficient funds
    Replace the three ___ blanks (new RuntimeException, the try keyword), then run it. The first withdrawal succeeds; the second is declined.

    One more — this time you'll define a custom exception type and throw it. Fill in the extends keyword and the class name.

    🎯 Your turn: a custom exception
    <?php
    // 🎯 YOUR TURN — build a custom exception and throw it.
    
    // 1) make PaymentException a real exception type
    class PaymentException ___ Exception {}   // 👉 the keyword is  extends
    
    function charge(int $cents): string
    {
        if ($cents <= 0) {
            // 2) throw your new exception with a message
            throw new ___("Amount must be positive");   // 👉 PaymentException
        }
        return "Charged $cents cents";
    }
    
    try {
        echo charge(-5) . "\n";
    } catch (PaymentException $e) {
        echo "Payment failed: " . $e->getMessage() . "\n";
    }
    
    // ✅ Expected output:
    //    Payment failed: Amount must be positive
    ?>
    Output
    Payment failed: Amount must be positive
    Use extends Exception to declare the class, then new PaymentException(...) to throw it. The catch block prints the message.

    Common Errors (and the fix)

    • Swallowing exceptions silently — an empty catch (Exception $e) { } hides the failure and leaves you debugging a "nothing happened" mystery for hours. At an absolute minimum, log it: error_log($e->getMessage());. Catch an exception only when you can actually do something about it.
    • Exposing errors in production — leaving display_errors on shows visitors file paths, SQL, and stack traces, which is both ugly and a security leak. Set ini_set("display_errors", "0") in production, keep error_reporting(E_ALL), and route detail to error_log().
    • Catching too broadlycatch (Throwable $e) as your only catch hides bugs you'd want to fix and treats a typo the same as a missing user. Catch the specific types first, then add a broad catch as a deliberate safety net, not a crutch.
    • "Uncaught DivisionByZeroError" (or TypeError) — these are Errors, not Exceptions, so catch (Exception $e) won't catch them. Use catch (Throwable $e) (or the specific DivisionByZeroError) when you need to handle engine-level faults.
    • Not logging at all — handling an exception but never recording it means recurring problems stay invisible. Always write something to error_log() (or a logger) so monitoring tools can surface patterns.

    Pro Tips

    • 💡 Catch only what you can handle. If a function can't sensibly recover from an exception, let it bubble up to a caller (or the global handler) that can.
    • 💡 PHP 8 lets you catch multiple types at once: catch (TypeError | ValueError $e) shares one block between related errors.
    • 💡 Use match in your global handler to map exception classes to HTTP status codes cleanly instead of a long if/elseif chain.

    📋 Quick Reference — Error Handling

    SyntaxExampleWhat It Does
    try / catchtry { ... } catch (Exception $e) { ... }Run risky code; handle a thrown exception
    finallyfinally { ... }Always runs — cleanup after try/catch
    throwthrow new RuntimeException("bad");Raise an exception on purpose
    extends Exceptionclass MyEx extends Exception {}Define a custom exception type
    getPrevious()$e->getPrevious()Get the chained root cause
    set_exception_handler()set_exception_handler($fn)Handle any uncaught exception globally
    set_error_handler()set_error_handler($fn)Turn PHP warnings into exceptions
    error_log()error_log("oops")Write a message to the server log
    display_errorsini_set("display_errors","0")Show errors (dev) or hide them (prod)

    Frequently Asked Questions

    Q: What is the difference between an Error and an Exception in PHP?

    Both implement the Throwable interface, but they signal different things. An Exception represents a recoverable problem your own code raises on purpose — bad input, a missing record, a failed payment — and you are expected to catch it. An Error (such as TypeError or DivisionByZeroError) is thrown by the PHP engine itself for serious faults, like calling a method on null. You can technically catch an Error, but it usually means a bug to fix rather than a condition to handle. Catch Throwable when you want a true catch-all that covers both.

    Q: When should I create a custom exception class instead of throwing Exception?

    Create one as soon as you want callers to react differently to different failures. A class like ValidationException or NotFoundException lets you write catch (NotFoundException $e) and respond with a 404, separately from a 422 for validation. Custom classes can also carry extra data (a field name, an HTTP status code) and read clearly in stack traces. Throwing the generic Exception everywhere forces every caller to catch everything and inspect the message string, which is fragile.

    Q: Does the finally block always run, even if I return or throw?

    Yes. finally runs after the try block whether it finished normally, hit a return, or threw an exception — which is exactly why it is the right place for cleanup like closing a file or releasing a database connection. If both the try block and the finally block return a value, the value from finally wins, so avoid returning from finally unless you mean to. The one thing that skips it is the script being killed outright (for example by exit() or a fatal error before the block is reached).

    Q: What is exception chaining and why use it?

    Exception chaining means re-throwing a higher-level exception while keeping the original one attached as its 'previous' exception. You pass the cause as the third constructor argument: throw new ConfigException("Could not load config", 0, $originalError). Later, $e->getPrevious() walks back to the root cause. This lets you show users a clean, high-level message while preserving the full technical trail in your logs for debugging.

    Q: How do I hide errors from users in production but still see them?

    Separate display from logging. In production set ini_set('display_errors', '0') so visitors never see raw errors (they can leak file paths, SQL, and secrets), but keep error_reporting(E_ALL) and route the details to a log with error_log() or a global set_exception_handler(). On your development machine flip display_errors to '1' so you see everything immediately. The golden rule: log the full detail, show the user a friendly generic message.

    Mini-Challenge: A Safe Endpoint

    No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This is the same throw → catch → cleanup loop you'll use in every real request handler.

    🎯 Mini-Challenge: custom exception + try/catch/finally
    <?php
    // 🎯 MINI-CHALLENGE: a tiny, safe API endpoint simulator.
    // No code is filled in — work from the steps below, then run it.
    //
    // 1. Make a custom exception:  class NotFoundException extends Exception {}
    // 2. Write findProduct(int $id): array
    //      - if $id is not 1, throw new NotFoundException("Product #$id not found")
    //      - otherwise return ["id" => 1, "name" => "Keyboard"]
    // 3. Loop over the ids [1, 99]. For each, use try/catch:
    //      - try:    echo  "Found: " . findProduct($id)["name"] . "\n";
    //      - catch:  echo  "404: " . $e->getMessage() . "\n";
    //      - finally: echo "  (request $id handled)\n";
    //
    // ✅ Expected output:
    //    Found: Keyboard
    //      (request 1 handled)
    //    404: Product #99 not found
    //      (request 99 handled)
    
    // your code here
    ?>
    Define a NotFoundException, throw it for unknown ids, and handle each request with try/catch/finally. Match the expected output exactly.

    🎉 Lesson Complete!

    • ✅ An Exception is a recoverable problem you throw; an Error is an engine fault — both are Throwable
    • try guards risky code, catch handles a thrown exception, and finally always runs
    • ✅ Catch the most specific type first; use throw to raise your own
    • ✅ Custom exceptions are just classes that extends Exception and can carry extra data
    • Chain a cause as the 3rd argument and recover it with getPrevious()
    • ✅ Register global handlers, error_log() the details, and hide them from users with display_errors off in production
    • Next lesson: Working with REST APIs — fetch and send data with cURL, streams, and Guzzle

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service