try/catch/finally, Custom Exceptions, and Rethrowing
Write robust try/catch/finally blocks, design a custom exception hierarchy with meaningful classes, chain exceptions with 'previous', rethrow safely, and decide when a failure is domain-level versus infrastructure-level.
Catching an exception is easy; catching the right exception, at the right layer, with the right amount of context, is the actual skill. This lesson covers PHP's full try/catch/finally control flow, how and why to build your own exception classes instead of always throwing a generic Exception, how to preserve a chain of causation with 'previous' exceptions, and the practical difference between a domain error (a business rule was violated) and an infrastructure error (a database, network, or filesystem failed).
try, multiple catch blocks, and finally
A try block wraps code that might throw. Each catch block after it declares a Throwable type it wants to handle; PHP checks them in order and runs the first one whose type matches (including via inheritance), so specific types must be listed before general ones. Since PHP 8.0, a single catch block can list several types separated by | (e.g. catch (TypeError | ValueError $e)) when they should be handled identically, and the caught variable itself is optional if you don't need to inspect it (catch (SomeException)).
A finally block, if present, always runs after the try/catch, whether an exception was thrown or not, whether it was caught or not, and even if the try or catch block returns or continues/breaks out of a loop. This makes finally the correct place for cleanup that must happen unconditionally — closing a file handle, releasing a lock, committing or rolling back a transaction — logic that should not be duplicated across every exit path of the try block.
Building a custom exception hierarchy
Every user-defined exception should extend Exception (or a more specific built-in subclass) rather than being thrown as a bare Exception, because the class itself becomes part of your API: callers can catch exactly the failure they know how to handle and let everything else propagate. A well-designed hierarchy usually has one or a few base classes for your application/domain (e.g. abstract class AppException extends Exception {}), and specific leaf classes beneath them (UserNotFoundException, InsufficientFundsException) that callers can catch individually.
Custom exception classes commonly add typed properties and named constructors to carry structured context beyond the plain string message — for example a NotFoundException that stores which ID was looked up, so a catch block can log or display that ID without parsing it out of a message string. Since exceptions are ordinary classes, you get constructor promotion, readonly properties, and all normal OOP tools.
Previous exceptions and safe rethrowing
Every Throwable constructor (and every SPL/custom exception that calls parent::__construct correctly) accepts an optional third argument, ?Throwable $previous = null, which is retrievable later via getPrevious(). This lets you catch a low-level exception, wrap it in one that's meaningful at your current layer, and still preserve the original cause for logging/debugging — this is called exception chaining, and getPrevious() can be walked repeatedly to reconstruct the full causal chain, similar to a 'Caused by' stack trace in other languages.
Rethrowing means catching an exception, doing something (logging, partial cleanup, adding context), and then throwing it again — either the same object (throw $e;) to preserve its original stack trace and type exactly, or a new, wrapping exception with the original set as previous. A bare throw; (no expression) is not valid PHP as it is in some languages — PHP always requires throw $e; with an explicit exception variable.
Domain errors versus infrastructure errors
A domain error means a business rule was violated in a way your application fully understands and expects as a normal possibility — insufficient funds for a withdrawal, a duplicate email during signup, a discount code that's expired. These deserve specific, well-named exception classes because calling code (or a user-facing layer) is expected to catch them and respond meaningfully, often without it being a 'bug' at all.
An infrastructure error means something the application depends on but does not control has failed — a database connection timed out, a third-party API returned 503, a disk is full. These are usually not meaningful to handle differently case-by-case at the point they occur; the common pattern is to let them propagate (or wrap them once in a generic 'service unavailable' type exception) up to a single top-level handler that logs the full technical detail and returns a generic, safe response to the end user. Conflating the two — for example catching a PDOException deep in business logic and silently returning a default value — hides real outages and makes debugging production incidents far harder.
Full try/catch/finally with typed multi-catch and rethrow
If finally itself contains a return, it overrides any return value from try/catch -- a well-known gotcha, so avoid returning from finally. PHP has no bare throw; for rethrow-in-place -- you must reference the caught variable explicitly, e.g. throw $e;.
<?php
try {
step1();
step2();
} catch (InvalidArgumentException | TypeError $e) {
// handle two related failure types the same way
logWarning($e->getMessage());
} catch (DomainException $e) {
throw new ApplicationException('Business rule violated', previous: $e);
} finally {
releaseResources(); // always runs: success, caught failure, or uncaught failure
}Code Examples
The finally block guarantees the file handle is closed regardless of whether fread() succeeds, throws, or the function returns early -- this is the idiomatic use of finally for deterministic resource cleanup.
InsufficientFundsException is a domain error: it uses constructor promotion to carry structured, typed data (requested and available amounts) rather than forcing callers to parse a string message, and extends DomainException so it slots into the standard SPL hierarchy while still being specific enough for callers to catch individually. Expected result: Requested 75.00 but only 50.00 available Shortfall: 25
This shows the domain-vs-infrastructure split in one method: a missing row is a DomainException (expected, business-meaningful, safe to show a message about), while a PDOException is an infrastructure failure that gets wrapped in a generic UserRepositoryException with the raw PDOException preserved as 'previous' for logging -- callers of findById() never need to know or catch PDO-specific exception types.
throw new UserRepositoryException('Database unavailable', previous: $e);
Common mistake: Catching PDOException and returning an empty array or null instead of throwing would silently mask a real outage as 'user not found', corrupting business logic and hiding the incident from monitoring.
<?php
function readConfig(string $path): string {
$handle = fopen($path, 'r');
if ($handle === false) {
throw new RuntimeException("Could not open config file: {$path}");
}
try {
return fread($handle, filesize($path));
} finally {
fclose($handle); // guaranteed to run even if fread() throws
}
}<?php
final class InsufficientFundsException extends DomainException
{
public function __construct(
public readonly float $requested,
public readonly float $available,
) {
parent::__construct(
sprintf('Requested %.2f but only %.2f available', $requested, $available)
);
}
}
final class Account
{
public function __construct(private float $balance) {}
public function withdraw(float $amount): void
{
if ($amount > $this->balance) {
throw new InsufficientFundsException($amount, $this->balance);
}
$this->balance -= $amount;
}
}
$account = new Account(50.00);
try {
$account->withdraw(75.00);
} catch (InsufficientFundsException $e) {
echo $e->getMessage() . "\n";
echo "Shortfall: " . ($e->requested - $e->available) . "\n";
}<?php
final class UserRepositoryException extends RuntimeException {}
final class UserRepository
{
public function __construct(private PDO $pdo) {}
public function findById(int $id): array
{
try {
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
throw new DomainException("User {$id} not found");
}
return $row;
} catch (PDOException $e) {
// Infrastructure failure: wrap once, preserve the cause, don't leak
// driver-specific details (like table/column names) to callers.
throw new UserRepositoryException('Database unavailable', previous: $e);
}
}
}try/catch/finally control flow
finally executes on every path out of try/catch -- normal completion, a matched catch, or an exception left uncaught and propagating further up the call stack.
try {
A
B <-- throws here
C (skipped)
}
catch (SpecificException $e) { handled? -> yes: run handler }
catch (Throwable $e) { handled? -> no, falls through }
finally {
D <-- ALWAYS runs: success, caught, or even uncaught
}
-----------------------------------------------
Outcome: if a catch matched -> continue after finally
if no catch matched -> finally runs, then exception propagates upImportant Rules
List catch blocks from most specific to most general -- PHP uses the first type match, so a general type listed first shadows every specific one after it.
finally always runs, including when try or catch returns -- but a return inside finally silently overrides any earlier return value, so avoid returning from finally.
Preserve causation with the 'previous' constructor argument whenever you catch one exception and throw a different one.
Custom exceptions should extend a meaningful built-in (LogicException, RuntimeException, DomainException) or your own base class, not be thrown as bare Exception.
Give domain errors specific, catchable classes; let infrastructure errors propagate to (or be wrapped once, high up, for) a single centralized handler rather than swallowing them locally.
Where this shows up
Frameworks like Symfony and Laravel ship a base 'validation exception' and 'model not found' exception specifically so application code can throw expressive, specific exceptions while the framework's central exception handler decides the HTTP status code and response shape by catching those types (e.g. NotFoundHttpException -> 404, ValidationException -> 422) -- one place, one policy, instead of every controller writing its own try/catch/response logic.
try { $user = $repository->findById($id); } catch (Exception $e) { return null; // swallow everything, including DB outages } try { $user = $repository->findById($id); } catch (UserNotFoundException $e) { return null; // expected domain outcome } // UserRepositoryException (infrastructure) is NOT caught here -- it propagates
Catching a broad Exception type and returning a default value conflates 'the user genuinely doesn't exist' (a normal, expected domain outcome) with 'the database is down' (a serious infrastructure failure). Only catch the specific exception type you actually know how to handle at this layer; let everything else propagate to code that can log or alert on it.
[multiple-choice] What is the purpose of the 'previous' argument when constructing an exception? A. It sets the exception's error code B. It preserves a reference to the exception that caused this one, forming a chain retrievable via getPrevious() C. It determines which catch block will match D. It automatically logs the previous exception to error_log
B. It preserves a reference to the exception that caused this one, forming a chain retrievable via getPrevious() — The previous argument (third constructor parameter on Throwable) lets you wrap a lower-level exception in a more meaningful one while preserving the original cause, retrievable later with getPrevious() -- essential for debugging without losing context.
[true-false] A finally block only runs if an exception was thrown and caught inside the preceding try block. A. True B. False
B. False — False. finally always runs -- on normal completion of try, when an exception is caught, and even when no catch block matches and the exception is about to propagate further up the call stack.
Create a custom exception class NegativeDepositException extends InvalidArgumentException that stores the invalid amount as a readonly property. Write a function deposit(float $amount): float that throws it when $amount is negative, and returns $amount otherwise. Call it inside a try/catch that prints a friendly message including the stored amount.
final class NegativeDepositException extends InvalidArgumentException { public function __construct(public readonly float $amount) { parent::__construct("Cannot deposit a negative amount: {$amount}"); } } function deposit(float $amount): float { if ($amount < 0) { throw new NegativeDepositException($amount); } return $amount; } try { deposit(-10.0); } catch (NegativeDepositException $e) { echo "Rejected deposit of {$e->amount}: {$e->getMessage()}\n"; }
Use constructor promotion for the readonly property and call parent::__construct() with a message built from it.