PHPThrowing & Re-throwing

Throwing & Re-throwing

throw is how a piece of code signals "I cannot continue, and I'm handing the problem to whoever called me." Most of the time that means a plain throw new SomeException(...) statement, but PHP also lets throw appear as an expression rather than a statement, and it gives you a deliberate mechanism — exception chaining — for re-throwing a failure while attaching more context without losing the original cause.

throw as a statement

The ordinary form

PHP
<?php
function requireEven(int $n): int
{
    if ($n % 2 !== 0) {
        throw new \InvalidArgumentException("{$n} is not even");
    }

    return $n;
}
throw as an expression (PHP 8)

Before PHP 8, throw could only be used as a full statement, which meant it couldn't appear inside an arrow function body, a ternary, or a null-coalescing expression — those all expect a single expression, not a statement. PHP 8 made throw itself an expression, so it can now be used anywhere an expression is valid.

throw inside a ternary and an arrow function

PHP
<?php
function requireEven(int $n): int
{
    return $n % 2 === 0
        ? $n
        : throw new \InvalidArgumentException("{$n} is not even");
}

$double = fn (?int $n) => $n !== null
    ? $n * 2
    : throw new \InvalidArgumentException('n cannot be null');

echo requireEven(4), "\n";

try {
    echo $double(null);
} catch (\InvalidArgumentException $e) {
    echo 'Caught: ', $e->getMessage(), "\n";
}
4
Caught: n cannot be null

This is more than syntactic sugar — it means validation-and-fail logic can live in a single expression instead of forcing a function body to expand from a one-line arrow function into a full multi-line block just to fit an if/throw pair.

Re-throwing to add context

Sometimes the right response to a caught exception isn't to handle it, but to add information relevant to the current layer and let it keep propagating. Simply calling throw $e; inside a catch block re-throws the same exception instance unchanged, which is useful when a layer wants to run cleanup or logging before passing the failure upward.

Logging, then letting the same exception continue

PHP
<?php
function saveOrder(array $order): void
{
    try {
        writeToDatabase($order);
    } catch (\PDOException $e) {
        error_log('Order save failed: ' . $e->getMessage());
        throw $e; // Re-throw unchanged after logging.
    }
}
Exception chaining with the previous argument

More often, re-throwing means wrapping the low-level failure in a more meaningful, higher-level exception type — a \\PDOException about a broken connection isn't meaningful to code three layers up that only cares about "the order couldn't be saved." Every built-in exception constructor accepts an optional third argument, $previous, specifically for this: it links the new exception to the one that caused it, without discarding the original.

Wrapping a low-level exception in a domain-specific one

PHP
<?php
class OrderSaveFailedException extends \RuntimeException
{
}

function saveOrder(array $order): void
{
    try {
        writeToDatabase($order);
    } catch (\PDOException $e) {
        throw new OrderSaveFailedException(
            "Could not save order #{$order['id']}",
            previous: $e,
        );
    }
}

try {
    saveOrder(['id' => 42]);
} catch (OrderSaveFailedException $e) {
    echo 'Top-level message: ', $e->getMessage(), "\n";
    echo 'Caused by: ', $e->getPrevious()->getMessage(), "\n";
}
Top-level message: Could not save order #42
Caused by: Connection refused

getPrevious() returns the chained exception (or null if there wasn't one), and PHP's default uncaught-exception rendering walks the entire chain automatically, printing "Next exception" sections for each link. That means even without custom logging, a stack trace for OrderSaveFailedException still shows the original \\PDOException underneath it — nothing about the root cause is lost.

Nested try/catch and re-throwing a different type

Translating an exception type at a layer boundary

PHP
<?php
function fetchUserProfile(int $userId): array
{
    try {
        return queryDatabase("SELECT * FROM users WHERE id = {$userId}");
    } catch (\PDOException $e) {
        // Callers of fetchUserProfile shouldn't need to know or catch
        // PDOException -- that's a database-layer detail leaking out.
        throw new \RuntimeException('Could not load user profile', previous: $e);
    }
}
Re-throwing without chaining loses the original cause
`throw new RuntimeException('Save failed');` inside a `catch ($e)` block, without passing `$e` as the `previous` argument, discards the original exception's message and stack trace entirely. Whoever reads the log later sees only "Save failed" with no indication of *why* — always pass the caught exception as `previous` when wrapping it.
  • throw works as an expression since PHP 8, so it can appear in ternaries, arrow functions, and null-coalescing expressions.

  • throw $e; inside a catch block re-throws the same instance unchanged, useful for log-then-propagate.

  • Wrapping a low-level exception in a higher-level one hides implementation details from callers further up the stack.

  • Pass the caught exception as the previous constructor argument to preserve the original cause.

  • getPrevious() walks back to the wrapped exception; PHP's default trace output renders the whole chain automatically.

Note
Exception chaining is not PHP-specific in spirit -- it mirrors "caused by" chains found in most exception-based languages. The underlying idea is always the same: never let translating an exception's type destroy the diagnostic trail that led to it.
Tip
When a `catch` block is about to translate an exception into a different type, ask whether the caller genuinely needs the original hidden, or whether `throw $e;` (no translation) would actually be clearer. Wrapping is for crossing a real abstraction boundary — not a reflex applied to every `catch` block.