Himanshu SharmaSoftware engineer
Let’s talk

PHP · Lesson 41 of 54

Errors and exceptions

Understand warnings, errors and exceptions, use try, catch and finally, write your own exception classes, and set up error display and logging.

14 minFiles, data and errorsUpdated 22 October 2026

Awaiting technical review. This lesson is readable but not yet listed in search engines.

By the end of this lessonHandle failures with exceptions, write custom exceptions, and configure error display safely for development and production.

When things go wrong

Programs fail in many ways: a file is missing, a database is down, a visitor sends nonsense, a bug divides by zero. PHP reports problems in two families.

Errors from the engine, at different levels:

  • Warnings (such as "Undefined variable" or "Undefined array key") report something suspicious and let the script continue.
  • Deprecation notices warn that a feature will be removed in a future version.
  • Fatal errors stop the script, for example calling a function that does not exist.

Exceptions, which are objects that code throws to say "I cannot continue", and that other code can catch and handle. Since PHP 7, most fatal engine errors are thrown as Error objects too, such as TypeError and DivisionByZeroError, which you have met in earlier lessons.

Throwing an exception

When a function cannot do its job, it should not quietly return something odd. It should throw:

php
<?php
function withdraw(float $balance, float $amount): float
{
    if ($amount <= 0) {
        throw new InvalidArgumentException("Amount must be positive.");
    }
    if ($amount > $balance) {
        throw new RuntimeException("Insufficient balance.");
    }
    return $balance - $amount;
}

echo withdraw(100, 30) . "\n";
echo withdraw(100, 500) . "\n";
echo "This line is never reached\n";
result
70

Fatal error: Uncaught RuntimeException: Insufficient balance. in /path/to/script.php:8
Stack trace:
#0 /path/to/script.php(14): withdraw(100.0, 500.0)
#1 {main}
  thrown in /path/to/script.php on line 8

The first call worked. The second threw an exception that nothing caught, so PHP stopped the whole script with a fatal "Uncaught" error showing the message and where it happened. That is the right default: better to stop than to carry on with a wrong balance.

try and catch

Code that knows how to deal with a failure wraps the risky call in try and handles it in catch. The script then continues normally after the catch block:

php
<?php
function withdraw(float $balance, float $amount): float
{
    if ($amount <= 0) {
        throw new InvalidArgumentException("Amount must be positive.");
    }
    if ($amount > $balance) {
        throw new RuntimeException("Insufficient balance.");
    }
    return $balance - $amount;
}

foreach ([30, 500, -5] as $amount) {
    try {
        $left = withdraw(100, $amount);
        echo "Withdrew $amount, $left left\n";
    } catch (InvalidArgumentException $e) {
        echo "Bad request: " . $e->getMessage() . "\n";
    } catch (RuntimeException $e) {
        echo "Declined: " . $e->getMessage() . "\n";
    }
}
echo "Done\n";
result
Withdrew 30, 70 left
Declined: Insufficient balance.
Bad request: Amount must be positive.
Done

When an exception is thrown inside try, PHP jumps straight to the first catch whose type matches, skipping the rest of the try block. You can list several catch blocks, or catch several types in one with a pipe: catch (InvalidArgumentException | RuntimeException $e). The exception object carries a message (getMessage()), an optional code (getCode()), the file and line where it was thrown, and a stack trace.

finally

A finally block runs whatever happens: after a successful try, after a catch, even when an exception is not caught. It is for clean-up such as closing files or releasing locks:

php
<?php
function readFirstLine(string $path): string
{
    $handle = fopen($path, 'w+');
    fwrite($handle, "first line\nsecond line\n");
    rewind($handle);
    try {
        $line = fgets($handle);
        if ($line === false) {
            throw new RuntimeException("Empty file");
        }
        return trim($line);
    } finally {
        fclose($handle);
        echo "(file closed)\n";
    }
}

echo readFirstLine('demo.txt') . "\n";
result
(file closed)
first line

The file was closed even though the function returned from inside try.

The exception hierarchy

All exceptions and errors implement the Throwable interface. The two branches are Exception (thrown by code, including PHP's own libraries) and Error (thrown by the engine for programming mistakes):

php
<?php
$tests = [
    fn() => intdiv(1, 0),
    fn() => strlen(),
    fn() => json_decode('{', flags: JSON_THROW_ON_ERROR),
    fn() => new DateTimeImmutable('not a date'),
];

foreach ($tests as $test) {
    try {
        $test();
    } catch (Throwable $e) {
        echo get_class($e) . ": " . $e->getMessage() . "\n";
    }
}
result
DivisionByZeroError: Division by zero
ArgumentCountError: strlen() expects exactly 1 argument, 0 given
JsonException: Syntax error
Exception: Failed to parse time string (not a date) at position 0 (n): The timezone could not be found in the database

Catching Throwable catches everything, which suits a top-level safety net that logs the problem and shows a friendly error page. Inside your code, catch the specific types you can actually handle, and let the rest pass up.

Your own exception types

Extending Exception (or one of its subclasses) gives names to the failures in your application, so callers can catch exactly them:

php
<?php
class OutOfStockException extends RuntimeException
{
    public function __construct(public readonly string $product, public readonly int $available)
    {
        parent::__construct("Only $available of $product left.");
    }
}

function reserve(string $product, int $wanted, int $available): void
{
    if ($wanted > $available) {
        throw new OutOfStockException($product, $available);
    }
    echo "Reserved $wanted x $product\n";
}

try {
    reserve('Mechanical Keyboard', 2, 60);
    reserve('Headphones', 30, 25);
} catch (OutOfStockException $e) {
    echo "Sorry: " . $e->getMessage() . " (product: {$e->product})\n";
}
result
Reserved 2 x Mechanical Keyboard
Sorry: Only 25 of Headphones left. (product: Headphones)

The class syntax is explained in the object-oriented section; for now, notice that the exception carries useful data (product, available) as well as a message.

Showing and logging errors

Two settings decide what happens to error messages:

  • display_errors prints them in the output. On for your own computer, so you see problems immediately.
  • log_errors writes them to a log file (error_log sets which). On everywhere.

On a live website, display_errors must be off. Error messages reveal file paths, SQL queries and sometimes passwords, which help attackers, and they confuse visitors. Set it in php.ini (or your host's control panel):

php
display_errors = Off
log_errors = On
error_log = /home/youraccount/logs/php-errors.log
error_reporting = E_ALL

error_reporting = E_ALL means "report everything", and then each problem is either shown or logged according to the other two settings. You can also log messages of your own with error_log("Payment API timed out for order 1042").

For a last line of defence, set_exception_handler() registers a function that receives any uncaught exception, so you can log it and show a polite "something went wrong" page with a 500 status instead of a blank screen. Frameworks such as Laravel set this up for you.

Common mistakes

  • Catching an exception and doing nothing, which hides bugs. At least log it.
  • Catching Throwable deep inside the code, so real programming errors are swallowed.
  • Using exceptions for normal flow, such as "no results found", which is not an error.
  • Leaving display_errors on in production.

Exercise

Write parseQuantity(string $input): int that throws an InvalidArgumentException if the input is not a whole number from 1 to 99. Call it for "3", "0", "abc" and "12", printing either the quantity or "Invalid: " and the message.

Show one possible answer
php
<?php
function parseQuantity(string $input): int
{
    $qty = filter_var($input, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 99]]);
    if ($qty === false) {
        throw new InvalidArgumentException("'$input' is not a quantity from 1 to 99.");
    }
    return $qty;
}

foreach (["3", "0", "abc", "12"] as $input) {
    try {
        echo "Quantity: " . parseQuantity($input) . "\n";
    } catch (InvalidArgumentException $e) {
        echo "Invalid: " . $e->getMessage() . "\n";
    }
}
result
Quantity: 3
Invalid: '0' is not a quantity from 1 to 99.
Invalid: 'abc' is not a quantity from 1 to 99.
Quantity: 12

Key points

  • Warnings and notices let the script continue; fatal errors and uncaught exceptions stop it.
  • throw new Exception('message') signals a failure; try / catch handles it; finally always runs.
  • Catch the most specific exception type you can handle, and let others pass up to a top-level handler.
  • Show errors on your own machine (display_errors=1); in production hide them and log them instead (display_errors=0, log_errors=1).

Check yourself

1. What happens to an exception that no catch block handles?
2. When does a finally block run?
3. Which setting should be off on a live website?
4. What do Exception and Error have in common in PHP 8?