PHP Exceptions
An exception in PHP is an object that represents an error or unusual condition that occurred while your script was running. Instead of a function returning a special value like false or -1 to signal failure, it can throw an exception object, which immediately stops normal execution and hands control to code specifically written to handle that failure. This makes error handling explicit, structured, and impossible to silently ignore by accident. Exceptions are the backbone of robust PHP applications, from simple form validation to complex frameworks like Laravel and Symfony.
Overview: How Exceptions Work
Every exception in PHP is an object. When you write throw new Exception("message"), PHP creates an instance of the Exception class (or one of its subclasses) and hands it to the engine’s exception-handling machinery instead of letting execution continue normally. The Zend Engine then stops executing the current line and starts unwinding the call stack: it walks back up through every function and method call that is currently active, looking for a catch block whose type matches the thrown object. If it finds one, execution resumes inside that block with the exception object bound to the catch variable. If it reaches the top of the script without finding a match, PHP reports an uncaught exception fatal error and the script terminates.
Since PHP 7, all exceptions and fatal errors implement a common interface called Throwable. Under Throwable there are two main branches: Exception, which is the class hierarchy you throw yourself for expected, recoverable problems (invalid input, failed lookups, business-rule violations), and Error, which PHP throws internally for things that were previously fatal errors — calling a method on null, a type mismatch, dividing by zero, and so on. Exception and Error do not extend one another, so a catch (Exception $e) block will never catch a TypeError or a DivisionByZeroError. If you want to catch absolutely everything, you catch Throwable instead.
Every exception object carries a message, a numeric code, a reference to the file and line where it was thrown, a full stack trace, and optionally a “previous” exception used for chaining causes together. These are all accessible through methods defined on the base Exception class: getMessage(), getCode(), getFile(), getLine(), getTrace(), getTraceAsString(), and getPrevious().
Syntax
try {
// code that might throw
} catch (SomeExceptionType $e) {
// handle it
} catch (AnotherType | YetAnotherType $e) {
// handle several types the same way
} finally {
// always runs, whether an exception was thrown or not
}
throw new Exception(string $message = "", int $code = 0, ?Throwable $previous = null);
try— wraps the block of code you want to monitor for exceptions.catch (Type $e)— runs if an exception matchingType(or one of its subclasses) is thrown inside thetryblock. You can list a union of types with|to handle several exception classes identically.finally— an optional block that always executes after the try/catch, whether an exception was thrown, caught, or not thrown at all — even if thetryorcatchblock returns early.throw— creates and raises an exception object. Since PHP 8,throwis an expression, so it can be used inside arrow functions and the null coalescing operator.- The
Exceptionconstructor accepts a message, an optional application-defined code, and an optional previous exception used for chaining causes together.
Common Built-in Exception and Error Classes
| Class | Extends | Typical Use |
|---|---|---|
Exception |
— | Base class for all “normal” exceptions you throw |
InvalidArgumentException |
LogicException |
A function received an argument of the wrong type or value |
OutOfRangeException |
LogicException |
An illegal index was requested |
RuntimeException |
Exception |
An error that can only be detected at run time |
JsonException |
Exception |
Thrown by json_encode/json_decode when JSON_THROW_ON_ERROR is set |
TypeError |
Error |
A value did not match a declared type hint |
DivisionByZeroError |
ArithmeticError |
Integer division or modulo by zero |
Examples
Example 1: Basic try/catch/finally
<?php
function divide(int $a, int $b): float {
if ($b === 0) {
throw new InvalidArgumentException("Cannot divide by zero");
}
return $a / $b;
}
try {
echo divide(10, 2) . PHP_EOL;
echo divide(5, 0) . PHP_EOL;
} catch (InvalidArgumentException $e) {
echo "Error: " . $e->getMessage() . PHP_EOL;
} finally {
echo "Division attempt finished." . PHP_EOL;
}
Output:
5
Error: Cannot divide by zero
Division attempt finished.
The first call succeeds and prints 5. The second call passes 0 as the divisor, so divide() throws an InvalidArgumentException before it ever reaches the division. Execution jumps straight to the matching catch block, which prints the error message — notice that the line echo divide(5, 0) never gets a chance to print anything itself, since the exception is thrown from inside the function call. Finally, the finally block runs regardless of whether an exception occurred.
Example 2: A Custom Exception Class
<?php
class InsufficientFundsException extends Exception
{
public function __construct(
private readonly float $requested,
private readonly float $available
) {
parent::__construct(
sprintf("Requested %.2f but only %.2f available", $requested, $available)
);
}
public function getShortfall(): float
{
return $this->requested - $this->available;
}
}
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(100.0);
try {
$account->withdraw(150.0);
} catch (InsufficientFundsException $e) {
echo $e->getMessage() . PHP_EOL;
echo "Shortfall: " . $e->getShortfall() . PHP_EOL;
}
Output:
Requested 150.00 but only 100.00 available
Shortfall: 50
Instead of throwing a generic Exception, this example defines InsufficientFundsException, which extends Exception but adds its own constructor and an extra method, getShortfall(). It calls parent::__construct() to set the standard message, but also stores the requested and available amounts as its own readonly properties so the catching code can react programmatically, not just display text. This is the real power of custom exceptions: they carry structured data, not just a string.
Example 3: Exception Chaining Across Layers
<?php
class DatabaseException extends RuntimeException {}
function connectToDatabase(string $dsn): void
{
throw new DatabaseException("Could not connect to $dsn");
}
function loadUserProfile(int $userId): array
{
try {
connectToDatabase("mysql:host=localhost;dbname=app");
return ['id' => $userId];
} catch (DatabaseException $e) {
throw new RuntimeException("Failed to load profile for user $userId", 0, $e);
}
}
try {
loadUserProfile(42);
} catch (RuntimeException | LogicException $e) {
echo "Top-level error: " . $e->getMessage() . PHP_EOL;
$previous = $e->getPrevious();
if ($previous !== null) {
echo "Caused by: " . $previous->getMessage() . PHP_EOL;
}
}
Output:
Top-level error: Failed to load profile for user 42
Caused by: Could not connect to mysql:host=localhost;dbname=app
A low-level DatabaseException is caught inside loadUserProfile() and re-thrown as a more general RuntimeException, but the original exception is passed as the third constructor argument. This is exception chaining: the outer exception keeps a reference to the inner one via getPrevious(), so nothing about the root cause is lost even though the caller only deals with a higher-level, more meaningful error type. The catch (RuntimeException | LogicException $e) line also shows a union catch, handling two unrelated exception hierarchies with one block.
Under the Hood: What Happens When You Throw
Understanding the exact sequence the engine follows removes a lot of confusion about finally and stack traces:
- Construction.
new Exception(...)runs the constructor, which stores the message, code, and previous exception, and immediately captures the current file, line, and a full backtrace. This happens at construction time, not atthrowtime — so if you build an exception object in one place and throw it somewhere else, the reported file/line is where it wasnew‘d, not wherethrowexecuted. throwtriggers unwinding. The engine stops the current instruction pointer and begins walking back up the call stack, frame by frame, looking for an enclosingtryblock whosecatchclause matches the exception’s class (viainstanceof).- Local cleanup runs during unwinding. As each stack frame is torn down, any local objects going out of scope have their destructors (
__destruct) invoked, in reverse order of creation. - Match found. If a matching
catchis found, execution resumes there with the exception object bound to the catch variable. Anyfinallyblock belonging to thattryalso runs, always, right after thetry/catchbody finishes — even if that body contains areturn,break, or anotherthrow. - No match found. If the stack unwinds all the way to the top without a matching
catch, PHP invokes the exception handler registered withset_exception_handler(), or if none is set, prints an “Uncaught Exception” fatal error with a full stack trace and terminates the process with a non-zero exit code.
Common Mistakes
Mistake 1: Swallowing Exceptions Silently
An empty catch block hides failures instead of handling them. The caller has no idea anything went wrong, and there is no log entry to help debug it later:
<?php
function saveOrder(array $order): bool
{
try {
processPayment($order);
return true;
} catch (Exception $e) {
}
return false;
}
This “works” in the sense that it doesn’t crash, but the failure vanishes into thin air. At minimum, log the exception; ideally, decide explicitly whether the caller should be told:
<?php
function saveOrder(array $order): bool
{
try {
processPayment($order);
return true;
} catch (Exception $e) {
error_log("Payment failed for order: " . $e->getMessage());
return false;
}
}
Mistake 2: Catching Exception When You Meant Throwable
Many run-time failures in PHP — a TypeError from a bad argument, a DivisionByZeroError, calling a method on null — are instances of Error, not Exception. Since Error and Exception are siblings that both implement Throwable, a catch (Exception $e) will not catch them:
<?php
function calculateTotal(?array $items): float
{
try {
$total = 0.0;
foreach ($items as $item) {
$total += $item['price'];
}
return $total;
} catch (Exception $e) {
echo "Failed to calculate total." . PHP_EOL;
return 0.0;
}
}
If $items is null, foreach raises a TypeError, which this catch (Exception $e) block will not intercept — the error propagates uncaught. Catch Throwable when you genuinely want to handle both exceptions and engine errors:
<?php
function calculateTotal(?array $items): float
{
try {
$total = 0.0;
foreach ($items as $item) {
$total += $item['price'];
}
return $total;
} catch (Throwable $e) {
echo "Failed to calculate total: " . $e->getMessage() . PHP_EOL;
return 0.0;
}
}
Best Practices
- Throw the most specific exception class available (or write your own) rather than a bare
Exception— it lets callers catch precisely what they can handle. - Never use exceptions for routine control flow, like ending a loop; reserve them for genuinely exceptional, unexpected conditions.
- Always preserve the original cause when re-throwing by passing it as the third constructor argument (
new RuntimeException($msg, 0, $e)), sogetPrevious()can reconstruct the full chain. - Catch as narrowly as possible, as close as possible to where you can actually do something useful — retry, log, or show a message — instead of catching broadly just to swallow the problem.
- Use
finallyfor cleanup that must always run, such as closing file handles or releasing locks, rather than duplicating that code in everycatchbranch. - Register a top-level
set_exception_handler()in production so uncaught exceptions are logged cleanly instead of leaking stack traces to users. - Keep exception messages informative for developers, but never leak sensitive data such as passwords, tokens, or raw SQL into a message that might reach end users.
Practice Exercises
- Write a function
parseAge(string $input): intthat throws anInvalidArgumentExceptionif the input is not a positive integer, and returns the integer otherwise. Test it with atry/catchthat prints a friendly error message. - Create a custom exception class
StockUnavailableExceptionthat stores the requested quantity and the available quantity as properties, plus a methodgetShortfall(). Throw it from areserveStock()function and catch it to print how many units are missing. - Write two functions,
readConfigFile()andloadApplication(), where the second calls the first inside atryblock and re-throws a new, more general exception while chaining the original as the “previous” exception. Catch the final exception and print both messages usinggetMessage()andgetPrevious()->getMessage().
Summary
- An exception is an object thrown with
throwthat interrupts normal flow and is caught by a matchingcatchblock. try/catch/finallycontrol how exceptions are handled;finallyalways runs regardless of what happened intryorcatch.ExceptionandErrorare separate branches under theThrowableinterface — catchThrowableif you need to handle both.- Custom exception classes that extend
Exceptionlet you attach domain-specific data and behavior to your errors. - Exception chaining via the constructor’s third argument preserves the root cause when you translate a low-level exception into a higher-level one.
- Never silently swallow exceptions — at minimum log them so failures remain visible.
