Exceptions #

Exceptions are the mechanism that lets code signal that something unexpected has happened — and move the responsibility of handling it to a caller that understands the context better. Without exceptions, every function would have to return error codes and every caller would have to check return values, creating code full of repeated checks that obscure the main logic. PHP has two error handling hierarchies: Exception for conditions that can be recovered and Error for internal PHP failures that usually can’t be recovered. Knowing the difference, when to throw an exception vs return null, how to design a meaningful custom exception hierarchy, and how finally works as resource cleanup — these are what separate good error handling from code that merely doesn’t crash.

The PHP Throwable Hierarchy #

Since PHP 7, everything that can be thrown implements the Throwable interface. There are two main branches:

flowchart TD
    T[Throwable\ninterface] --> E[Exception]
    T --> Err[Error]

    E --> RE[RuntimeException]
    E --> LA[LogicException]
    E --> OE[OverflowException]
    E --> UE[UnexpectedValueException]
    E --> IO[InvalidArgumentException]
    E --> BO[BadMethodCallException]

    Err --> TE[TypeError]
    Err --> PE[ParseError]
    Err --> AE[ArithmeticError]
    Err --> VE[ValueError]

    RE --> OOR[OutOfRangeException]
    RE --> OOB[OutOfBoundsException]
    RE --> UOE[UnderflowException]

    style T fill:#fef9c3,stroke:#ca8a04
    style E fill:#dcfce7,stroke:#16a34a
    style Err fill:#fee2e2,stroke:#dc2626

Exception and its subclasses are used for conditions the application can know about and handle — failed validation, missing resources, denied permissions. Error and its subclasses represent internal PHP failures like TypeError (wrong argument types), ParseError (bad file syntax), or ArithmeticError (integer division by zero). You’ll almost never need to catch (Error $e) except for top-level logging.


Basic Syntax: try, catch, finally #

<?php
try {
    // Code that might throw an exception goes here
    $result = divide(10, 0);
    echo "Result: $result"; // Never reached if divide() throws
} catch (\InvalidArgumentException $e) {
    // Catch a specific exception type — higher priority
    echo "Invalid argument: " . $e->getMessage();
} catch (\RuntimeException $e) {
    // Catch a broader type
    echo "Runtime error: " . $e->getMessage();
} catch (\Exception $e) {
    // Catch any Exception not caught above
    echo "Unexpected error: " . $e->getMessage();
} finally {
    // Always runs — whether or not an exception occurred
    echo "\nProcess finished.";
}

How the Exception Flow Works #

sequenceDiagram
    participant Caller
    participant try_block as try block
    participant catch_block as catch block
    participant finally_block as finally block

    Caller->>try_block: Execute code
    alt No exception
        try_block-->>finally_block: Continue to finally
        finally_block-->>Caller: Normal return
    else Exception thrown
        try_block->>catch_block: Exception forwarded
        catch_block-->>finally_block: After catch finishes
        finally_block-->>Caller: Return after handling
    else Exception not caught
        try_block->>finally_block: finally still runs
        finally_block->>Caller: Exception propagates up
    end

finally — Always Executed #

finally runs under all conditions: when the code succeeds, when an exception is caught, even when an exception isn’t caught and is propagating upward. This makes it the right place for resource cleanup:

<?php
function readFile(string $path): string
{
    $handle = fopen($path, 'r');

    if ($handle === false) {
        throw new \RuntimeException("Failed to open file: $path");
    }

    try {
        $content = fread($handle, filesize($path));

        if ($content === false) {
            throw new \RuntimeException("Failed to read file: $path");
        }

        return $content;

    } finally {
        // Always closes the file handle — even with an exception or return above
        fclose($handle);
        // No return needed here — the return value from try is still returned
    }
}

// The file handle is ALWAYS closed, no matter what happens
try {
    $content = readFile('/etc/hosts');
    echo strlen($content) . " bytes read";
} catch (\RuntimeException $e) {
    echo "Error: " . $e->getMessage();
}
A return inside a finally block will replace the return value from the try or catch block. This is unintuitive behavior and can hide the value that was meant to be returned. Use finally only for side effects (closing connections, deleting temp files, logging) — never return from finally.

Exception Object Properties #

Every Exception object carries useful information for debugging:

<?php
try {
    throw new \RuntimeException("Database connection failed", 500);
} catch (\RuntimeException $e) {
    echo $e->getMessage();   // "Database connection failed"
    echo $e->getCode();      // 500 — optional error code
    echo $e->getFile();      // "/var/www/app/Database.php"
    echo $e->getLine();      // 42 — the line where it was thrown
    echo $e->getTraceAsString(); // Full stack trace as a string

    // Stack trace as an array (easier to process)
    $trace = $e->getTrace();
    foreach ($trace as $frame) {
        echo "{$frame['file']}:{$frame['line']}{$frame['function']}()\n";
    }
}

Exception Chaining — Preserving the Original Cause #

When catching a low-level exception and throwing a high-level one, always include the original exception as the previous exception so the stack trace isn’t lost:

<?php
class OrderException extends \RuntimeException {}

function saveOrder(array $data): int
{
    try {
        $stmt = $pdo->prepare("INSERT INTO orders ...");
        $stmt->execute($data);
        return (int) $pdo->lastInsertId();

    } catch (\PDOException $e) {
        // ANTI-PATTERN: the original exception is lost, debugging gets hard
        throw new OrderException("Failed to save order");

        // CORRECT: include $e as the previous exception (third argument)
        throw new OrderException("Failed to save order", 0, $e);
    }
}

try {
    saveOrder($data);
} catch (OrderException $e) {
    echo $e->getMessage();                // "Failed to save order"
    echo $e->getPrevious()->getMessage(); // "SQLSTATE[...]: ..." — the original detail
}

Creating Meaningful Custom Exceptions #

PHP’s built-in exceptions (RuntimeException, InvalidArgumentException, etc.) are often enough for simple code. But for larger applications, specific custom exceptions make error handling much easier and error messages far more informative.

Domain Exception Hierarchy #

Design an exception hierarchy that mirrors your application’s domain:

<?php
// Base exception for the whole application domain
class AppException extends \RuntimeException {}

// Domain-specific exceptions — extend AppException
class NotFoundException extends AppException
{
    public function __construct(string $resource, int|string $id)
    {
        parent::__construct(
            "$resource with ID '$id' not found",
            404
        );
    }
}

class ValidationException extends AppException
{
    private array $errors;

    public function __construct(array $errors)
    {
        $this->errors = $errors;
        parent::__construct(
            "Validation failed: " . implode(', ', array_keys($errors)),
            422
        );
    }

    public function getErrors(): array
    {
        return $this->errors;
    }

    public function getFirstError(): string
    {
        return reset($this->errors) ?: '';
    }
}

class AuthorizationException extends AppException
{
    public function __construct(string $action, string $resource = '')
    {
        $message = $resource
            ? "Not allowed to '$action' on '$resource'"
            : "Not allowed to '$action'";

        parent::__construct($message, 403);
    }
}

class DomainException extends AppException {}

// More specific sub-domain exceptions
class InsufficientStockException extends DomainException
{
    public function __construct(
        private string $productName,
        private int    $requested,
        private int    $available,
    ) {
        parent::__construct(
            "Insufficient stock for '$productName': $requested requested, $available available",
            409
        );
    }

    public function getProductName(): string { return $this->productName; }
    public function getRequested(): int       { return $this->requested; }
    public function getAvailable(): int       { return $this->available; }
}

// Usage
function reduceStock(int $productId, int $quantity): void
{
    $product = findProduct($productId);

    if ($product === null) {
        throw new NotFoundException('Product', $productId);
    }

    if ($quantity > $product['stock']) {
        throw new InsufficientStockException(
            $product['name'],
            $quantity,
            $product['stock']
        );
    }

    // reduce stock...
}

Catching the Exception Hierarchy in Layers #

With a well-designed hierarchy, you can catch at the right level:

<?php
try {
    reduceStock(42, 100);

} catch (InsufficientStockException $e) {
    // The most specific level — can access details
    echo "Insufficient stock: " . $e->getProductName();
    echo "  Requested: " . $e->getRequested();
    echo "  Available: " . $e->getAvailable();
    // Show a form to change the quantity

} catch (NotFoundException $e) {
    // Broader — only knows the resource doesn't exist
    http_response_code(404);
    echo $e->getMessage();

} catch (AppException $e) {
    // Catch all domain exceptions — generic response
    http_response_code($e->getCode() ?: 500);
    echo "Error: " . $e->getMessage();

} catch (\Throwable $e) {
    // The broadest — catches everything including Errors (TypeError, etc.)
    // Only for logging — don't expose details to the user
    error_log($e->getMessage() . "\n" . $e->getTraceAsString());
    http_response_code(500);
    echo "An internal error occurred";
}

Multi-Catch — Catching Several Types at Once #

Since PHP 8.0, several exception types can be caught in a single catch block with the | operator:

<?php
try {
    $result = processInput($input);

} catch (ValidationException | \InvalidArgumentException $e) {
    // Catch both with the same handling
    http_response_code(422);
    echo "Invalid input: " . $e->getMessage();

} catch (NotFoundException | \OutOfBoundsException $e) {
    http_response_code(404);
    echo "Data not found";

} catch (\PDOException | \RuntimeException $e) {
    // Database or runtime error — log and show a generic message
    error_log($e->getTraceAsString());
    http_response_code(500);
    echo "A server error occurred";
}

Re-throwing — Throwing an Exception Again #

Sometimes you need to catch an exception to do something (logging, cleanup) but still want it to propagate upward:

<?php
function executeTransaction(callable $operation): mixed
{
    $pdo->beginTransaction();

    try {
        $result = $operation($pdo);
        $pdo->commit();
        return $result;

    } catch (\Throwable $e) {
        // Roll back before the exception is re-thrown
        $pdo->rollBack();

        // Log technical details (not shown to the user)
        error_log("Transaction failed: " . $e->getMessage());
        error_log($e->getTraceAsString());

        // Re-throw — let the caller decide how to handle it
        throw $e;
        // Or wrap it in a more descriptive exception:
        // throw new TransactionException("Transaction failed", 0, $e);
    }
}

// The caller gets the original exception (or its wrapper)
try {
    $orderId = executeTransaction(function(\PDO $db) use ($data) {
        $id = saveOrder($db, $data);
        reduceStock($db, $data['product_id'], $data['qty']);
        return $id;
    });
} catch (InsufficientStockException $e) {
    echo "Out of stock: " . $e->getProductName();
} catch (\PDOException $e) {
    echo "Database error";
}

throw as an Expression (PHP 8.0+) #

Since PHP 8.0, throw is an expression — it can be used inside ternaries, null coalescing, and arrow functions:

<?php
// throw in null coalescing
$name = $_GET['name'] ?? throw new \InvalidArgumentException("The 'name' parameter is required");

// throw in a ternary
$age = is_numeric($_GET['age'] ?? '')
    ? (int) $_GET['age']
    : throw new \InvalidArgumentException("Age must be a number");

// throw in an arrow function
$parse = fn(string $json) => json_decode($json, true)
    ?? throw new \ValueError("Invalid JSON: $json");

// throw inside match
$status = match($code) {
    200 => 'ok',
    404 => 'not_found',
    default => throw new \UnexpectedValueException("Unknown HTTP code: $code"),
};

// throw as a short-circuit guard
function findUserOrFail(int $id): array
{
    return findUser($id)
        ?? throw new NotFoundException('User', $id);
}

Global Exception Handler #

For exceptions not caught by any try/catch block, PHP provides a global handler mechanism. This is important for logging and showing user-friendly error pages:

<?php
// set_exception_handler — for uncaught Exceptions
set_exception_handler(function (\Throwable $e): void {
    // Log technical details
    $message = sprintf(
        "[%s] %s at %s:%d\nStack trace:\n%s",
        get_class($e),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString()
    );
    error_log($message);

    // Appropriate response based on context
    if (php_sapi_name() === 'cli') {
        fwrite(STDERR, "Fatal: " . $e->getMessage() . "\n");
        exit(1);
    }

    // For web — show a clean error page
    $code = $e instanceof AppException ? $e->getCode() : 500;
    http_response_code($code ?: 500);

    if (getenv('APP_ENV') === 'development') {
        // In development — show details for debugging
        echo "<pre>" . htmlspecialchars($message) . "</pre>";
    } else {
        // In production — show a generic error page
        echo "Something went wrong. Please try again later.";
    }
});

// set_error_handler — convert traditional PHP errors to Exceptions
set_error_handler(function(int $errno, string $errstr, string $errfile, int $errline): bool {
    if (!(error_reporting() & $errno)) {
        return false; // This error is ignored by the error_reporting setting
    }
    throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});

Exceptions vs Error Codes — When to Use Which #

Not every abnormal situation should use exceptions. This guide helps you choose:

Use an Exception when:
  ✓ A genuinely exceptional condition — rare in the normal flow
  ✓ A failure the caller can't proceed without (file missing, DB down)
  ✓ A function contract is violated (invalid arguments, inconsistent state)
  ✓ A failure that needs to propagate several levels up

Don't use an Exception when:
  ✗ Conditions that happen frequently in the normal flow (user not logged in, empty form)
  ✗ Validation that's expected to fail (wrong input format)
  ✗ A "not found" search result is a valid case
  ✗ For flow control (like breaking out of a loop)
<?php
// ANTI-PATTERN: Exceptions for normal flow control
function findUserByEmail(string $email): array
{
    $user = queryDatabase($email);
    if (!$user) {
        throw new NotFoundException('User', $email); // a missing email is normal!
    }
    return $user;
}

// The caller is forced to use try/catch for normal flow:
try {
    $user = findUserByEmail($email);
    // user exists
} catch (NotFoundException $e) {
    // user doesn't exist — this is normal flow, not exceptional!
}

// CORRECT: return null for valid "not found"
function findUserByEmail(string $email): ?array
{
    return queryDatabase($email) ?: null;
}

// The caller uses a regular null check:
$user = findUserByEmail($email);
if ($user === null) {
    // user doesn't exist
}

// Exceptions remain appropriate for: clearly invalid arguments
function findUserById(int $id): ?array
{
    if ($id <= 0) {
        throw new \InvalidArgumentException("ID must be positive, received: $id");
    }
    return queryDatabase($id) ?: null;
}

The Result Type Pattern — An Exception Alternative #

For operations that can fail in predictable ways, the Result type pattern (borrowed from functional programming) can be more expressive than exceptions:

<?php
class Result
{
    private function __construct(
        private readonly bool   $success,
        private readonly mixed  $value,
        private readonly string $error = '',
    ) {}

    public static function ok(mixed $value): static
    {
        return new static(true, $value);
    }

    public static function fail(string $error): static
    {
        return new static(false, null, $error);
    }

    public function succeeded(): bool  { return $this->success; }
    public function failed(): bool     { return !$this->success; }
    public function value(): mixed     { return $this->value; }
    public function error(): string    { return $this->error; }
}

// An operation returning a Result
function validateEmail(string $email): Result
{
    if (empty($email)) {
        return Result::fail("Email must not be empty");
    }
    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        return Result::fail("Invalid email format: $email");
    }
    return Result::ok($email);
}

function registerUser(string $name, string $email): Result
{
    $validation = validateEmail($email);
    if ($validation->failed()) {
        return Result::fail($validation->error());
    }

    // Continue processing...
    return Result::ok(['id' => 1, 'name' => $name, 'email' => $email]);
}

// The caller doesn't need try/catch
$result = registerUser("Budi", "budi-not-valid");
if ($result->failed()) {
    echo "Failed: " . $result->error();
} else {
    $user = $result->value();
    echo "Success: user #{$user['id']} created";
}

Common Exception Anti-Patterns #

<?php
// ✗ Anti-pattern 1: catch everything and ignore it
try {
    complexProcess();
} catch (\Exception $e) {
    // Silently ignoring errors — very dangerous!
    // Hidden bugs with no trace left to debug
}

// ✓ At minimum, log before continuing
try {
    complexProcess();
} catch (\Exception $e) {
    error_log("Process failed: " . $e->getMessage());
    // Continue with a default value or re-throw
}

// ✗ Anti-pattern 2: uninformative error messages
throw new \Exception("Error");           // which error?
throw new \Exception("Something happened"); // what happened?

// ✓ Specific, actionable messages
throw new \RuntimeException(
    "Failed to send email to '{$email}': SMTP server not responding (30s timeout)"
);

// ✗ Anti-pattern 3: catching too broadly in the wrong place
function calculateTotal(array $items): float
{
    try {
        return array_sum(array_column($items, 'price'));
    } catch (\Exception $e) {
        return 0; // Hides errors — this function needs no try/catch at all!
    }
}

// ✓ Let exceptions propagate — catch in the right place (the caller)
function calculateTotal(array $items): float
{
    return array_sum(array_column($items, 'price'));
}

// ✗ Anti-pattern 4: using Exceptions for ordinary user input validation
function processLoginForm(string $email, string $password): array
{
    if (empty($email)) throw new \Exception("Email empty");
    if (empty($password)) throw new \Exception("Password empty");
    // ...
}

// ✓ Regular validation doesn't need Exceptions — return an errors array
function processLoginForm(string $email, string $password): array
{
    $errors = [];
    if (empty($email))    $errors['email']    = "Email must not be empty";
    if (empty($password)) $errors['password'] = "Password must not be empty";

    if (!empty($errors)) {
        return ['success' => false, 'errors' => $errors];
    }

    // process login...
    return ['success' => true, 'user' => $user];
}

Summary #

  • PHP’s hierarchy: Exception for conditions the application can recover; Error for internal PHP failures (TypeError, ParseError). Catch \Throwable only in a global handler for logging.
  • finally always runs — whether or not an exception occurred. Use it for resource cleanup (closing files, rolling back transactions) but don’t return from finally because it will replace the try block’s return value.
  • Exception chaining — when re-throwing, include the original exception as the third argument new MyException("...", 0, $e). Use getPrevious() to access the original cause.
  • Informative custom exceptions carry domain-specific data (getErrors(), getProductName(), getRequested()) so callers can respond appropriately without parsing message strings.
  • Multi-catch catch (TypeA | TypeB $e) lets you handle several exception types with the same code without duplicating catch blocks.
  • throw as an expression (PHP 8.0+) allows throwing in null coalescing ??, ternaries, match, and arrow functions — making guard conditions more concise.
  • Exceptions for exceptional conditions — not for normal flow like “not found” or “form validation failed”. For those cases, return null, an errors array, or use the Result pattern.
  • Global handlers via set_exception_handler() and set_error_handler() ensure all uncaught exceptions are logged and produce an appropriate response, rather than PHP’s default error page exposing sensitive information.

← Previous: Traits   Next: Arrays →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact