PHP 7 unified error and exception handling under the
Throwableinterface, which changed the way failures are caught and handled in PHP applications. Before PHP 7, fatal errors like calling an undefined function were not catchable at all. Now bothErrorandExceptionimplementThrowable, and a single catch block can handle either.This guide covers how
Throwable,try/catch/finally, and custom exceptions work, with examples drawn from common application scenarios: file handling, API calls, and payment processing.What this covers:
The
Throwableinterface and what it unifiesThe difference between
ExceptionandError
try,catch, andfinallysyntax and behaviorMultiple
catchblocks and catch orderCreating custom exception classes
Best practices for logging, rethrowing, and production error messages
Real-world example: file upload validation
The Throwable Interface
In PHP 7 and above, both Error and Exception implement the Throwable interface:
interface Throwable {
public function getMessage(): string;
public function getCode(): int;
public function getFile(): string;
public function getLine(): int;
public function getTrace(): array;
public function getPrevious(): ?Throwable;
}
This means any failure in PHP, whether an application-level exception or a PHP engine error, can be caught by a single catch block:
try {
// risky operation
} catch (Throwable $e) {
echo "Caught: " . $e->getMessage();
}
Catching Throwable is the broadest possible net. In most application code, catching specific types is preferable because it makes the handling intent explicit. Throwable is most useful at the outer boundary of an application, such as a global error handler or a middleware layer, where the goal is to prevent unhandled failures from reaching the user.
Exception vs. Error
The two concrete implementations of Throwable serve different purposes.
Exception is thrown by application code to signal expected failure conditions: invalid input, a failed API call, a business rule violation, a missing resource. These are conditions the application knows about and can handle.
Error is thrown by the PHP engine to signal problems with the code itself: calling an undefined function, type errors, parse errors, division by zero. Before PHP 7, these were fatal and not catchable.
try {
notAFunction(); // throws Error
} catch (Error $e) {
echo "PHP error: " . $e->getMessage();
}
The practical implication: catch Exception when handling application logic failures. Catch Error when defensive code needs to handle engine-level problems. Catch Throwable when either type is possible and the handler should treat them the same way.
try, catch, and finally
Basic structure
try {
echo intdiv(10, 0); // throws DivisionByZeroError
} catch (DivisionByZeroError $e) {
echo "Cannot divide by zero.";
} finally {
echo "This always runs.";
}
The try block contains the code that may throw. The catch block handles a specific thrown type. The finally block runs regardless of whether an exception was thrown or caught, and regardless of whether the catch block itself threw.
finally is the correct place for cleanup operations that must happen whether or not the operation succeeded: closing a file handle, releasing a database connection, or releasing a lock.
$handle = null;
try {
$handle = fopen('data.csv', 'r');
// process file
} catch (Throwable $e) {
logError($e);
} finally {
if ($handle !== null) {
fclose($handle);
}
}
Note the null check on $handle in the finally block. If fopen itself fails, $handle will be false or null, and calling fclose on it would produce another error. Checking before closing is the defensive pattern.
Multiple catch blocks
Multiple catch blocks handle different exception types from the same try block. PHP matches them in order from top to bottom and executes the first matching block.
try {
processRequest($input);
} catch (InvalidArgumentException $e) {
echo "Invalid input: " . $e->getMessage();
} catch (RuntimeException $e) {
echo "Runtime error: " . $e->getMessage();
} catch (Throwable $e) {
echo "Unexpected error: " . $e->getMessage();
}
Always order catch blocks from most specific to most general. If Throwable is listed first, it will match everything and the specific blocks below it will never execute.
PHP 8.0 introduced union catches, which handle multiple exception types in a single block when they should be handled the same way:
} catch (InvalidArgumentException | RuntimeException $e) {
echo "Input or runtime error: " . $e->getMessage();
}
Custom Exception Classes
Custom exception classes improve code clarity by making failure conditions explicit and catchable by specific type.
class PaymentFailedException extends RuntimeException {}
class InsufficientFundsException extends PaymentFailedException {}
Extending RuntimeException rather than the base Exception class is a common convention for exceptions representing runtime failures in application logic. Both work, but a consistent hierarchy makes it easier to catch groups of related exceptions.
Usage:
function processPayment(float $amount): void {
if ($amount <= 0) {
throw new InvalidArgumentException("Amount must be greater than zero.");
}
if (!hasSufficientFunds($amount)) {
throw new InsufficientFundsException("Insufficient funds for this transaction.");
}
}
try {
processPayment(0);
} catch (InsufficientFundsException $e) {
// handle low balance specifically
notifyUser($e->getMessage());
} catch (PaymentFailedException $e) {
// handle any other payment failure
logError($e);
}
Because InsufficientFundsException extends PaymentFailedException, catching PaymentFailedException would also catch InsufficientFundsException. Listing the more specific type first handles it separately while allowing the parent catch to handle everything else in the hierarchy.
Best Practices
Catch specific exceptions before general ones. The order of catch blocks determines which handler runs. Starting broad and getting specific is a common mistake that swallows exceptions with the wrong handler.
Log errors with context, not just messages. $e->getMessage() is rarely enough. Log the exception class, the stack trace, and relevant request context so the log entry is actionable.
} catch (Throwable $e) {
error_log(sprintf(
"[%s] %s in %s on line %d\n%s",
get_class($e),
$e->getMessage(),
$e->getFile(),
$e->getLine(),
$e->getTraceAsString()
));
}
Never expose raw exception messages to users in production. Stack traces, file paths, and database error messages can reveal information useful to an attacker. Show users a generic message and log the detail internally.
Use finally for resource cleanup. File handles, database connections, and locks should be released in finally blocks to guarantee cleanup even when exceptions are thrown.
Rethrow when appropriate. If a catch block cannot fully handle an exception, rethrowing it (or wrapping it in a more contextual exception) passes the failure up the call stack to a handler with more context.
try {
$result = callExternalApi();
} catch (NetworkException $e) {
throw new ServiceUnavailableException("Payment service unreachable.", 0, $e);
}
Wrapping the original exception as the $previous argument preserves the full error chain for logging while presenting a more meaningful exception type to the caller.
Real-World Example: File Upload Validation
class UploadException extends RuntimeException {}
class FileTooLargeException extends UploadException {}
function validateUpload(array $file): void {
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new UploadException("Upload failed with error code: " . $file['error']);
}
$maxSize = 2 * 1024 * 1024; // 2 MB
if ($file['size'] > $maxSize) {
throw new FileTooLargeException("File exceeds the 2 MB size limit.");
}
}
try {
validateUpload($_FILES['avatar']);
echo "Upload accepted.";
} catch (FileTooLargeException $e) {
echo "The file is too large. Please upload a file under 2 MB.";
logError($e);
} catch (UploadException $e) {
echo "The upload could not be completed. Please try again.";
logError($e);
}
This example illustrates several of the patterns described above: a custom exception hierarchy, specific catch blocks ordered from most to least specific, user-facing messages that do not expose internal details, and a separate logging call that records the full exception.
Key Takeaways
Throwableis the common interface for bothExceptionandErrorin PHP 7 and above. CatchingThrowablehandles either type.Exceptionrepresents application-level failures;Errorrepresents PHP engine-level problems. Catch the appropriate type for the failure being handled.finallyruns regardless of whether an exception was thrown or caught. Use it for resource cleanup.Catch blocks are matched in order. List specific exception types before general ones.
Custom exception classes make failure conditions explicit and catchable by type. Extending a common base class allows related exceptions to be caught as a group.
Never expose raw exception messages to users in production. Log the detail internally and return a generic message externally.
Wrap caught exceptions with
$previouswhen rethrowing to preserve the full error chain for debugging.
Conclusion
Structured error handling in PHP is not defensive coding for its own sake. It produces applications that fail in predictable, recoverable ways, give operators enough information to diagnose problems, and give users enough information to understand what happened without exposing anything they should not see.
The patterns here, specific catch blocks, custom exception hierarchies, finally for cleanup, and careful logging, compose well. A codebase that applies them consistently is significantly easier to debug and maintain than one where error handling is an afterthought.
Building an exception hierarchy for a specific domain and want a second opinion on the structure? Share it in the comments.




