PHP Custom Exceptions
When something goes wrong in a PHP script — a bank account can’t cover a withdrawal, a config file is missing, an API request references a user that doesn’t exist — a generic error message isn’t enough. A custom exception is a class you write that extends PHP’s built-in Exception (or one of its subclasses) so each kind of failure in your application gets its own precisely named type. Instead of catching a vague Exception and guessing what happened from a string, you catch InsufficientFundsException or NotFoundException directly, with structured data already attached to it.
Overview: How Custom Exceptions Work
PHP models errors as objects. Every exception in PHP implements the built-in Throwable interface, which is split into two branches: Exception (for expected, recoverable runtime conditions your own code raises) and Error (for engine-level problems like calling an undefined method or dividing by zero, which PHP itself throws). Only objects that implement Throwable can be used with the throw keyword or caught in a catch block — if you try to throw a plain class instance, PHP raises a TypeError at the moment of the throw.
A custom exception is simply a class declared with extends Exception (or extends Throwable-implementing class such as InvalidArgumentException, RuntimeException, or LogicException). Because it inherits from Exception, it automatically gets a constructor accepting a message, a numeric code, and a "previous" exception, plus methods like getMessage(), getCode(), getFile(), getLine(), getTrace(), getTraceAsString(), and getPrevious(). You rarely need to reimplement any of this — you extend the class purely to give it a distinct name, and optionally add your own properties (like an order ID, an HTTP status code, or a list of validation errors) and methods to read them back out.
When throw executes, PHP immediately stops normal execution and starts unwinding the call stack, looking for the nearest enclosing try block whose catch clause matches the exception’s class (or one of its parent classes/interfaces). If no match is found in the current function, PHP pops back to the caller and checks there, and so on, all the way up to the global scope. If nothing ever catches it, PHP triggers a fatal error and, if set, calls the handler registered with set_exception_handler(). This is why exception type matters so much: catch blocks match by class hierarchy, so a well-designed set of custom exceptions lets calling code catch exactly the failures it knows how to handle and let everything else propagate.
Syntax
class MyException extends Exception
{
// optional: extra properties
public function __construct(string $message, private readonly mixed $context = null)
{
parent::__construct($message); // always forward to the parent!
}
public function getContext(): mixed
{
return $this->context;
}
}
throw new MyException("Something specific went wrong", $someData);
extends Exception— makes the class throwable and gives itgetMessage(),getCode(),getPrevious(), etc. for free.__construct()— optional override; only needed when you want extra parameters or defaults. It must callparent::__construct()to store the message/code/previous exception.parent::__construct(string $message = "", int $code = 0, ?Throwable $previous = null)— the baseExceptionconstructor signature you are forwarding to.- Extra properties — any data specific to the failure (an ID, a list of errors, a limit that was exceeded) that callers can retrieve through your own getter methods.
throw new MyException(...)— creates the object and immediately unwinds the stack looking for a matchingcatch.
Examples
Example 1: A basic custom exception
<?php
class InvalidAgeException extends Exception
{
}
function setAge(int $age): void
{
if ($age < 0 || $age > 150) {
throw new InvalidAgeException("Age must be between 0 and 150, got {$age}.");
}
echo "Age set to {$age}.\n";
}
try {
setAge(200);
} catch (InvalidAgeException $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Code: " . $e->getCode() . "\n";
}
Output:
Error: Age must be between 0 and 150, got 200.
Code: 0
Here InvalidAgeException adds nothing beyond a name — and that alone is already useful, because the catch (InvalidAgeException $e) block only matches this specific failure, not any other Exception that might be thrown elsewhere in setAge(). The message and code come straight from the inherited Exception constructor since we never overrode it.
Example 2: Attaching extra context
<?php
class InsufficientFundsException extends Exception
{
public function __construct(
string $message,
private readonly float $balance,
private readonly float $requested,
) {
parent::__construct($message);
}
public function getBalance(): float
{
return $this->balance;
}
public function getShortfall(): float
{
return $this->requested - $this->balance;
}
}
class BankAccount
{
private float $balance;
public function __construct(float $startingBalance)
{
$this->balance = $startingBalance;
}
public function withdraw(float $amount): void
{
if ($amount > $this->balance) {
throw new InsufficientFundsException(
"Cannot withdraw \${$amount}, balance is only \${$this->balance}.",
$this->balance,
$amount,
);
}
$this->balance -= $amount;
}
}
$account = new BankAccount(100.00);
try {
$account->withdraw(250.00);
} catch (InsufficientFundsException $e) {
echo $e->getMessage() . "\n";
echo "You are short by \$" . number_format($e->getShortfall(), 2) . "\n";
}
Output:
Cannot withdraw $250, balance is only $100.
You are short by $150.00
This is where custom exceptions really pay off: $balance and $requested are captured as constructor-promoted, readonly properties, and exposed through getBalance() and getShortfall(). The catching code doesn’t have to parse the message string to figure out how much money was missing — it just calls a method and gets a typed value back.
Example 3: An exception hierarchy with an abstract base
<?php
abstract class ApiException extends Exception
{
abstract public function getHttpStatusCode(): int;
}
class NotFoundException extends ApiException
{
public function getHttpStatusCode(): int
{
return 404;
}
}
class ValidationException extends ApiException
{
public function __construct(string $message, private readonly array $errors = [])
{
parent::__construct($message);
}
public function getHttpStatusCode(): int
{
return 422;
}
public function getErrors(): array
{
return $this->errors;
}
}
function findUser(int $id): array
{
$users = [1 => ['name' => 'Ana'], 2 => ['name' => 'Beto']];
if (!isset($users[$id])) {
throw new NotFoundException("User #{$id} was not found.");
}
return $users[$id];
}
function processRequest(int $id): void
{
try {
$user = findUser($id);
echo "Found user: {$user['name']}\n";
} catch (ApiException $e) {
echo "API error ({$e->getHttpStatusCode()}): {$e->getMessage()}\n";
} finally {
echo "Request finished.\n";
}
}
processRequest(1);
processRequest(99);
Output:
Found user: Ana
Request finished.
API error (404): User #99 was not found.
Request finished.
Both NotFoundException and ValidationException extend the abstract ApiException, which forces every subclass to implement getHttpStatusCode(). The single catch (ApiException $e) block in processRequest() catches any exception in that family, and can still call getHttpStatusCode() on it because the abstract base guarantees the method exists. The finally block runs every time, whether an exception was thrown or not — useful for cleanup like closing a database connection.
Under the Hood: What Happens Step by Step
- 1. Class resolution. When PHP compiles
class InsufficientFundsException extends Exception, it links your class into the existing hierarchy. It now inherits every property and method ofException(like the private$messageand$codeproperties) unless you override them. - 2. Object creation.
new InsufficientFundsException(...)allocates the object and runs your constructor. If you defined your own__construct(), PHP runs only that one — the parent constructor does not run automatically. That’s why you must explicitly callparent::__construct($message). - 3. The
throwstatement. PHP checks that the object implementsThrowable(guaranteed here since it extendsException), then begins unwinding the call stack, exiting each function/block on the way, running anyfinallyblocks it passes through. - 4. Matching a
catch. PHP compares the exception’s actual class against eachcatchtype in order, usinginstanceofsemantics — so acatch (ApiException $e)matches a thrownNotFoundExceptionbecause of theextendsrelationship. - 5. Handling and continuing. Once caught, execution resumes normally after the
try/catch/finallyblock — the stack does not continue unwinding past the block that caught it.
Common Mistakes
Mistake 1: Forgetting to call parent::__construct()
If you override the constructor to accept extra parameters but never forward the message to the parent, getMessage() silently returns an empty string.
<?php
class OrderException extends Exception
{
public function __construct(string $message, private readonly int $orderId)
{
// Oops: parent::__construct($message) was never called
}
public function getOrderId(): int
{
return $this->orderId;
}
}
try {
throw new OrderException("Order could not be processed.", 42);
} catch (OrderException $e) {
echo "Message: '" . $e->getMessage() . "'\n";
echo "Order ID: " . $e->getOrderId() . "\n";
}
Output:
Message: ''
Order ID: 42
The order ID still works because it’s your own property, but the message is lost because the parent constructor, which stores it, never ran. Fix it by always forwarding to parent::__construct():
<?php
class OrderException extends Exception
{
public function __construct(string $message, private readonly int $orderId)
{
parent::__construct($message);
}
public function getOrderId(): int
{
return $this->orderId;
}
}
try {
throw new OrderException("Order could not be processed.", 42);
} catch (OrderException $e) {
echo "Message: '" . $e->getMessage() . "'\n";
echo "Order ID: " . $e->getOrderId() . "\n";
}
Output:
Message: 'Order could not be processed.'
Order ID: 42
Mistake 2: Losing the original exception when wrapping it
It’s common to catch a low-level exception and rethrow a higher-level, more meaningful one. If you forget to pass the original as the "previous" exception, you lose the real root cause — which makes debugging production issues much harder.
<?php
class RepositoryException extends Exception
{
}
function loadConfig(string $path): array
{
if (!file_exists($path)) {
throw new RuntimeException("Config file missing: {$path}");
}
return [];
}
function bootstrap(): void
{
try {
loadConfig('/etc/app/config.php');
} catch (RuntimeException $e) {
// Oops: the original exception is discarded
throw new RepositoryException("Failed to bootstrap the application.");
}
}
try {
bootstrap();
} catch (RepositoryException $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Previous: " . ($e->getPrevious()?->getMessage() ?? 'none') . "\n";
}
Output:
Error: Failed to bootstrap the application.
Previous: none
Pass the caught exception as the third constructor argument to preserve the chain:
<?php
class RepositoryException extends Exception
{
}
function loadConfig(string $path): array
{
if (!file_exists($path)) {
throw new RuntimeException("Config file missing: {$path}");
}
return [];
}
function bootstrap(): void
{
try {
loadConfig('/etc/app/config.php');
} catch (RuntimeException $e) {
throw new RepositoryException("Failed to bootstrap the application.", 0, $e);
}
}
try {
bootstrap();
} catch (RepositoryException $e) {
echo "Error: " . $e->getMessage() . "\n";
echo "Previous: " . ($e->getPrevious()?->getMessage() ?? 'none') . "\n";
}
Output:
Error: Failed to bootstrap the application.
Previous: Config file missing: /etc/app/config.php
The nullsafe operator ?-> is used here so that getPrevious() can safely be chained even when it returns null.
Best Practices
- Name exception classes after the condition, not the mechanism —
InsufficientFundsException, notBankError. - Always call
parent::__construct()when you override the constructor, even if you add other parameters. - Extend the most specific built-in base that fits (
InvalidArgumentException,OutOfRangeException,RuntimeException) instead of rawExceptionwhen your case matches one of SPL’s predefined exception types. - Group related exceptions under a common abstract or interface base (like
ApiException) so callers can catch a whole family with onecatchclause when they don’t need to distinguish further. - Preserve the original exception with the
$previousparameter whenever you wrap or translate an exception, so stack traces and root causes are never lost. - Keep exception classes free of side effects — don’t log, send emails, or touch the database inside the constructor; do that in the code that catches the exception.
- Only catch exceptions you can actually do something about; let everything else propagate up to a top-level handler or
set_exception_handler(). - Attach machine-readable data (IDs, codes, arrays of errors) as typed properties with getters, not by cramming everything into the message string.
Practice Exercises
- Exercise 1: Create a
PasswordTooWeakExceptionclass extendingExceptionthat stores the minimum required length and the length that was actually provided. Write avalidatePassword(string $password, int $minLength)function that throws it when the password is too short, and atry/catchblock that prints a message like "Password must be at least 8 characters, got 5." - Exercise 2: Build a small exception hierarchy for a file-upload feature: an abstract
UploadException, and two concrete subclassesFileTooLargeExceptionandUnsupportedFileTypeException, each exposing its own relevant getter (max size, or a list of allowed types). Write one function that throws each kind conditionally, and onecatch (UploadException $e)block that handles both. - Exercise 3: Simulate a two-layer application: a
fetchFromDatabase()function that throws a plainRuntimeException, and agetUserProfile()function that catches it and rethrows a customProfileLoadException, correctly passing the original exception as the previous one. Print both the outer message and the previous exception’s message to confirm the chain is intact.
Summary
- A custom exception is a class that
extends Exception(or anotherThrowable-implementing class), giving your code a precise, named type for each kind of failure. - Only objects implementing
Throwablecan be thrown or caught; PHP raises aTypeErrorif you try to throw anything else. - If you override the constructor, you must call
parent::__construct()yourself — it does not run automatically. - Extra properties and getter methods let callers retrieve structured context instead of parsing an error string.
catchblocks match by class hierarchy, so grouping related exceptions under a shared abstract base or interface lets calling code catch broadly or narrowly as needed.- Always pass the original exception as the
$previousargument when wrapping one exception in another, to preserve the full chain of causes for debugging. finallyblocks always run, whether an exception was thrown, caught, or not thrown at all — use them for cleanup.
