try, catch, finally

try, catch, finally and Multi-Catch

PHP runs the first catch whose type matches, subclasses included; if none matches, the exception travels on up the stack. A clause may list several types with | (PHP 7.1), may omit the variable (catch (JsonException), PHP 8.0), and throw is an expression, so $row ?? throw new ... works. finally runs however the block ends: the place to release a lock or roll back a transaction.

finally runs after a return and after a throw; a return inside finally swallows the exceptionPHP
<?php
function reserve(int $qty): string {
    try {
        if ($qty < 1) throw new ValueError("quantity $qty is below 1");
        return "reserved $qty";
    } finally {
        echo "unlock; ";                        // runs after the return and after a throw
    }
}
try { echo reserve(2), "\n", reserve(0); }
catch (ValueError | TypeError $e) { echo $e::class, ": {$e->getMessage()}\n"; }
function swallow(): string {
    try { throw new RuntimeException('lost'); } finally { return 'finally wins'; }
}
echo swallow(), "\n";                           // the exception has vanished
Output
unlock; reserved 2
unlock; ValueError: quantity 0 is below 1
finally wins

The last line is the trap: a return in finally replaces whatever was in flight, so the RuntimeException vanished. PHP 8.6 deprecates it for that reason (Looking Ahead to PHP 8.6). Keep finally for cleanup, and catch only where you can actually recover.