๐Ÿ“ Web Development

PHP Exceptions: try, catch, throw and finally

Oct 22, 20265 min read1 viewsBy Flow

PHP exceptions are the difference between a page that fails politely and one that stops dead halfway through with its footer missing. A missing CSV prints a yellow warning and carries on. A dead database connection throws โ€” and unless something catches it, the script ends on that line.

This lesson works through eight files, each one a single idea: the difference between a warning and a throw, catching one, reading what the caught object knows, throwing your own, why catch order matters, what finally guarantees, writing an exception class that carries your own data, and the one that catches most people out โ€” why catch (Exception $e) quietly fails to catch a division by zero.

Every output below is a real run on PHP 8.4.21. The two pages that end in a red fatal error end there on purpose โ€” with PHP exceptions, a page dying is sometimes exactly what you are meant to see.

A warning is a note, an exception is a stop

Start with the two side by side. file() on a missing CSV raises a warning, hands back false, and lets the script reach the next line. new PDO() on a port where nothing is listening throws โ€” and because nothing catches it, the script ends right there.

Port 3307 is deliberate. Nothing is listening on it, so the connection fails on any machine whether or not MySQL is running.

<?php

// A warning: PHP complains, hands back false, and carries on
$rows = file('sales-2019.csv');
var_dump($rows);

echo "The script is still running\n";

// An exception: PHP stops right here unless something catches it
$pdo = new PDO('mysql:host=127.0.0.1;port=3307;dbname=shop', 'root', '');

echo "This line never runs\n";

Output:

php exceptions output showing a warning followed by bool false, then an uncaught PDOException fatal error

Read the order. The warning prints, var_dump() shows bool(false), and The script is still running appears โ€” PHP complained and carried on. Then the new PDO() line throws, and This line never runs never prints. That last absence is the whole point: an uncaught exception does not return a value you can test, it ends the script.

try and catch turn a dead page into a message

Same connection, same dead port. The only change is that the risky line now sits inside a try block with a catch after it.

<?php

try {
    $pdo = new PDO('mysql:host=127.0.0.1;port=3307;dbname=shop', 'root', '');
    echo "Connected to the shop database\n";
} catch (PDOException $e) {
    echo "The shop is offline for a moment. Please try again shortly.\n";
}

echo "The page carries on and can still draw a menu and a footer\n";

Output:

php exceptions output showing a catch block message where the fatal error used to be

Two things changed and neither of them is the database. Connected to the shop database never prints, because the throw happens on the line above it and the rest of the try block is abandoned. And the line after the whole try/catch does print โ€” the script survived. Nothing about the failure was fixed; only the handling was. That is what PHP exceptions buy you: a choice about what happens next.

getMessage, getCode, getFile and getLine

The variable in catch (PDOException $e) is an object, and it knows a surprising amount about what went wrong.

<?php

try {
    $pdo = new PDO('mysql:host=127.0.0.1;port=3307;dbname=shop', 'root', '');
} catch (PDOException $e) {
    echo 'Class:   ' . get_class($e) . "\n";
    echo 'Message: ' . $e->getMessage() . "\n";
    echo 'Code:    ' . $e->getCode() . "\n";
    echo 'File:    ' . $e->getFile() . "\n";
    echo 'Line:    ' . $e->getLine() . "\n";
}

Output:

php exceptions output showing the class, message, code, file and line read from the caught object

File and Line point at where the exception was thrown, not at the catch block that caught it. Line 11 is the new PDO() call, several lines above the echo that printed it. That is what makes these two useful in a log: they name the failing line, not the handling code.

Message and Code come from the driver. SQLSTATE[HY000] and MySQL error 2002 both mean the same thing โ€” nothing answered. The sentence after [2002] comes from the operating system, so it reads differently on Windows and Linux; the part worth matching on is the SQLSTATE[HY000] [2002] fragment, which is identical everywhere.

throw stops the function on the spot

Exceptions are not only for things PHP throws at you. throw is how your own function refuses to do something.

<?php

function chargeCard($amount)
{
    if ($amount <= 0) {
        throw new InvalidArgumentException('Amount must be more than zero');
    }

    return 'Charged ' . number_format($amount, 2);
}

echo chargeCard(19.99), "\n";

try {
    echo chargeCard(0), "\n";
} catch (InvalidArgumentException $e) {
    echo 'Refused: ' . $e->getMessage() . "\n";
}

Output:

php exceptions output showing a successful charge above a refused amount caught from throw

The first call returns normally. The second throws before it reaches return, so chargeCard() hands nothing back at all โ€” and because there is no return value, the echo wrapped around it never runs either. The catch block prints instead.

That is the difference between throwing and returning false. A false can be ignored by a caller who forgets to check it. A throw cannot โ€” which is why PHP exceptions are the better way for a function to refuse bad input.

Order decides which catch block wins

You can list several catch blocks. PHP takes the first one whose class matches โ€” and a parent class matches every one of its children.

<?php

function loadConfig($file)
{
    if (!is_file($file)) {
        throw new RuntimeException("Config file $file is missing");
    }

    return parse_ini_file($file);
}

// Wrong order: Exception is the parent of RuntimeException, so it wins
try {
    loadConfig('settings.ini');
} catch (Exception $e) {
    echo 'General block caught a ' . get_class($e) . "\n";
} catch (RuntimeException $e) {
    echo "Specific block never runs\n";
}

// Right order: the narrowest class first
try {
    loadConfig('settings.ini');
} catch (RuntimeException $e) {
    echo 'Specific block: ' . $e->getMessage() . "\n";
} catch (Exception $e) {
    echo "General block never runs for this one\n";
}

// Multi-catch: one block, two unrelated classes (PHP 7.1 and newer)
foreach ([new RuntimeException('Disk full'), new InvalidArgumentException('Bad id')] as $problem) {
    try {
        throw $problem;
    } catch (RuntimeException | InvalidArgumentException $e) {
        echo 'Multi-catch handled ' . get_class($e) . ': ' . $e->getMessage() . "\n";
    }
}

Output:

php exceptions output showing the general catch block winning first and the specific one winning second

Both try blocks throw exactly the same RuntimeException. The first prints from the general block, because Exception is listed first and RuntimeException is a kind of Exception. The second prints from the specific block, because that one is listed first.

Note what PHP does not do: it never warns you that Specific block never runs is unreachable. Put the broad class first and your careful handling is silently dead code. Narrowest first, always.

The last loop is multi-catch โ€” one block handling two unrelated classes, separated by |. It needs PHP 7.1 or newer.

finally runs before the value reaches the caller

finally runs whether the try block succeeded or threw. It is where the cleanup goes โ€” closing a handle, releasing a lock, deleting a temp file.

<?php

function connect($dsn)
{
    try {
        if ($dsn === '') {
            throw new RuntimeException('The DSN is empty');
        }

        return 'connected to ' . $dsn;
    } catch (RuntimeException $e) {
        return 'failed: ' . $e->getMessage();
    } finally {
        echo "finally: closing the handle\n";
    }
}

echo connect('mysql:host=127.0.0.1'), "\n\n";
echo connect(''), "\n";

Output:

php exceptions output showing the finally line printed before the returned value both times

Read the order carefully, because it looks wrong at first. finally: closing the handle appears above connected to mysql:host=127.0.0.1 โ€” even though the return comes first in the code.

That is exactly what finally guarantees. The return prepares its value, then finally runs, and only then does the value reach the echo that was waiting for it. So the function's own output prints before the caller's. It happens both times: once after a success, once after a failure.

A custom exception carries your own data

Built-in classes carry a message and a code. Extend Exception and it can carry whatever the handler actually needs.

<?php

class DatabaseUnavailable extends Exception
{
    private $dsn;

    public function __construct($message, $dsn, $previous = null)
    {
        parent::__construct($message, 503, $previous);
        $this->dsn = $dsn;
    }

    public function getDsn()
    {
        return $this->dsn;
    }
}

$dsn = 'mysql:host=127.0.0.1;port=3307;dbname=shop';

try {
    try {
        $pdo = new PDO($dsn, 'root', '');
    } catch (PDOException $e) {
        throw new DatabaseUnavailable('The shop database is not answering', $dsn, $e);
    }
} catch (DatabaseUnavailable $e) {
    echo 'Class:     ' . get_class($e) . "\n";
    echo 'Message:   ' . $e->getMessage() . "\n";
    echo 'Code:      ' . $e->getCode() . "\n";
    echo 'DSN:       ' . $e->getDsn() . "\n";
    echo 'Caused by: ' . get_class($e->getPrevious()) . "\n";
}

Output:

php exceptions output showing a custom DatabaseUnavailable class with code 503 and its previous PDOException

Two details are doing the work here. parent::__construct() is what stores the message and the code โ€” drop that line and getMessage() returns an empty string and getCode() returns 0, no matter what you passed in.

And the third argument chains the original PDOException, which getPrevious() hands back. That is how you report a problem in your own words without throwing away the driver's version of it โ€” the handler gets a clean DatabaseUnavailable, and the log still gets the real cause underneath.

catch (Exception) does not catch an Error

This is the one that catches people out. Since PHP 7 there are two branches: Exception, and Error for things that used to be fatal errors โ€” type mismatches, calling a method on null, dividing by zero. Both implement Throwable. Neither is a parent of the other.

<?php

// intdiv() by zero throws DivisionByZeroError, and that is an Error
try {
    echo intdiv(100, 0);
} catch (Throwable $e) {
    echo 'Throwable caught a ' . get_class($e) . ': ' . $e->getMessage() . "\n";
    echo '$e instanceof Exception: ';
    var_dump($e instanceof Exception);
    echo '$e instanceof Error:     ';
    var_dump($e instanceof Error);
}

// The same problem, but only Exception is listed. Nothing here catches it.
try {
    echo intdiv(100, 0);
} catch (Exception $e) {
    echo "This line never runs\n";
}

Output:

php exceptions output showing bool false for instanceof Exception and a fatal DivisionByZeroError below

The two var_dump() lines are the proof: bool(false) for instanceof Exception, bool(true) for instanceof Error. The same object, caught successfully, is not an Exception at all.

The second try block lists only Exception, so it catches nothing, and the page dies with an uncaught DivisionByZeroError โ€” exactly as if there were no try block at all. If you want one block that catches everything a line can throw, catch Throwable.

Three mistakes with PHP exceptions

Listing the broad class first. catch (Exception $e) above catch (RuntimeException $e) makes the second one unreachable, and PHP says nothing about it. Narrowest class first.

The empty catch block. catch (Exception $e) {} makes the red block go away, which feels like fixing it. It isn't โ€” the failure still happened, you just deleted the only evidence. If you genuinely have nothing to do about it, log it: error_log($e->getMessage()); costs one line.

Assuming catch (Exception $e) covers everything. It misses every Error โ€” TypeError, DivisionByZeroError, ArgumentCountError. Code that looked protected for years can still die on a bad argument. Catch Throwable when you mean everything.

Common questions about PHP exceptions

The questions about PHP exceptions that come up most often once the eight files above make sense.

What is the difference between an error and an exception in PHP?

A warning or notice is a note: PHP prints it, carries on, and usually hands back false. An exception stops execution at that line and keeps going up through the calling functions until something catches it โ€” and if nothing does, the script ends. Since PHP 7 the old fatal errors became Error objects, which are throwable too, so most of them can now be caught.

Should I catch Exception or Throwable?

Catch the narrowest class you can actually handle โ€” PDOException if you are dealing with a database, InvalidArgumentException if you are validating input. Use Throwable only at the top level, where your job is to log the problem and show the visitor something polite rather than to fix it.

Why is my catch block not running?

Three usual causes. The class you listed is not a parent of what was thrown โ€” catch (Exception $e) never catches a DivisionByZeroError. A broader catch block is listed above yours and took it first. Or the throw happened outside the try block altogether; getLine() on the caught object will tell you where it really came from.

Does finally run if the try block returns?

Yes โ€” that is the whole point of it. The return value is prepared, then finally runs, then the value is handed to the caller. It also runs when the catch block returns, and when an exception leaves the function uncaught. That is why cleanup belongs there and not after the try block.

Do I need a custom exception class?

Not for small scripts. You want one when the handler needs something the message cannot carry โ€” a DSN, an order id, an HTTP status โ€” or when you want catch (DatabaseUnavailable $e) to mean something specific without string-matching the message. Extend Exception, call parent::__construct(), and add whatever getter the handler needs.

What is next

PHP exceptions are most useful where things genuinely fail outside your control โ€” databases, files and uploads. The PHP MySQL with PDO lesson is where PDOException turns up for real, and PHP file handling is full of operations that warn instead of throwing, which is exactly when you want to know the difference.

Comments

Loading comments...

Link copied to clipboard