Skip to content
beginner Phase 6 · Error Handling and Exceptions

Throwing and Catching Exceptions

Interrupt normal flow and handle errors with try/catch blocks.

40m
0 problems
Topic Progress 0%

throw: Where Execution Stops and the Stack Unwinds

A payment function that cannot charge must not limp on with a fake result. throw is the interrupt:

function charge(Card $card, float $amount): Receipt
{
    if ($amount <= 0) {
        throw new \InvalidArgumentException(
            'Charge amount must be positive, got ' . $amount,
            422,                      // optional int code
        );
    }

    $gateway = new Gateway();
    $result  = $gateway->submit($card, $amount);   // never reached when the throw fires

    return new Receipt($result->id);
}

At the throw, three things happen in order. First, PHP builds the exception object — an instance of the given class, carrying the message, an integer code, and the file/line of the throw recorded automatically. Second, the current function immediately stops: no further statement runs, no finally-less cleanup happens yet. Third, control jumps outward through the call stack looking for a try block whose catch matches the thrown type; each frame the search crosses is torn down without completing. If no matching catch exists anywhere, the request ends: PHP prints an Uncaught InvalidArgumentException: Charge amount must be positive fatal with the file, line, and stack trace — which is exactly what you saw before learning exceptions, now localized to the throw instead of to whatever garbage the invalid data would have corrupted later.

Every exception is an object of Throwable's hierarchy, and the top split matters for what you catch:

Root Typical members Origin
Exception InvalidArgumentException, RuntimeException, LogicException conditions your code detects and decides to raise
Error TypeError, ValueError, DivisionByError, OutOfMemoryError engine-level violations — wrong types, bad arguments to built-ins
both implement Throwable — the interface try/catch actually understands

PHP 7 drew this line deliberately: recoverable, business-detected failures are Exceptions your API can promise; Errors signal bugs that should crash loudly, because continuing after a TypeError usually spreads the corruption. The practical consequence: catch Exception (or a subclass) in application code, and think hard before catching Error — most of the time a TypeError in your own code is a defect to fix, not a condition to handle.

One constructor detail to internalize while we are here: new RuntimeException('msg', 0, $previous) accepts the previous exception as its third argument, which is how a lower-level failure gets chained to a higher-level story — how to use that chain, where to place a catch, and how not to swallow the object are covered later in this topic. The other half of the mechanism — the try block and its ordered catch clauses — comes first.

Choosing what to throw is half the craft. Construct the message for the engineer who will read it at 3 a.m.: what failed, on what input, and — when relevant — the identifier to search for. The integer code slot is optional and underused: reserve it for stable, documented values (your error catalog, or the HTTP status the caller should map to) rather than incrementing counters nobody parses. And throw only types your callers can reasonably catch: InvalidArgumentException for bad arguments, RuntimeException for environmental failures mid-flow, LogicException for invariant violations that are bugs. PHP's own SPL catalog covers most situations — UnexpectedValueException, DomainException, OverflowException — so a new class is justified only when the failure is genuinely your domain's vocabulary, which is the next topic's territory.

The strict __get from Magic Methods like __get and __toString already reached for this phase's tool when it threw a RuntimeException for unknown names; this topic covers the whole mechanism those interceptors borrowed.

Catching the Right Type: specific First, Throwable Last

A throw without a nearby try ends the request; try/catch is what converts an interruption into a decision:

try {
    $receipt = charge($card, $amount);
} catch (\InvalidArgumentException $e) {
    // caller bug or bad input — a 400, not a crash
    return response(400, $e->getMessage());
} catch (\GatewayException $e) {
    // infrastructure failed — a 502 and an alert
    report($e);
    return response(502, 'payment unavailable');
}

The selection rule is type-based and first-match-wins: PHP walks the catch clauses top to bottom and enters the first whose type matches via instanceof semantics — the thrown class, or any of its parents. Ordering therefore runs specific-to-general. Put a broad catch (\Exception $e) first and it swallows every later clause: the GatewayException handler below becomes dead code, unreachable, with no warning from the engine:

// WRONG ORDER — the second catch never runs
catch (\Exception $e)           { /* catches everything Exception-derived */ }
catch (\GatewayException $e)    { /* dead code */ }

PHP 8.0 condensed the ladder with multi-type catch:

try {
    $rows = $pdo->query($sql)->fetchAll();
} catch (\PDOException|\RuntimeException $e) {
    // either type, one handler body — $e is always the actual thrown object
}

Use it when several sibling types share one fate ("both mean retry later"); keep separate clauses when their responses differ. You may also catch by an interface: anything implementing, say, LoggableFailure matches a catch (\LoggableFailure $e) clause, because matching is plain instanceof.

Two boundary tools complete the basics. catch (\Throwable $e) catches both roots — exceptions and engine errors — and is the right choice at the outermost edge of a process (an HTTP kernel, a queue worker loop) where nothing may escape, provided you rethrow or abort after logging rather than pretend success. And PHP 8.0's non-capturing catch — catch (\RateLimitedException) with no variable — declares "this type is handled but I do not need the object", which reads as intentional suppression to reviewers. A try with zero catch clauses but a finally is legal too: it means "no recovery here, only guaranteed cleanup", which is the subject of Cleanup with finally Blocks. What remains at this level is the discipline around the object itself — rethrowing it unchanged, preserving its cause chain, and the review flag for any catch whose body is empty or returns a fake success.

Nesting deserves one worked shape, because deeply nested try blocks are a readability tax. Flatten them when the layers share a handler: one try around the whole operation with typed catches usually beats a try inside a try inside a try, each re-catching to translate. When nesting is unavoidable, remember that a catch may itself throw — the inner throw unwinds through the outer try like any other, so an outer catch sees both the inner failure and anything the inner handler raised while handling it. And one boundary myth to discard: catch matching spans the entire call stack, not just your function — a callee's throw will find your handler, which is precisely why handler placement is a design decision rather than a reflex. PHP 8's catch-without-variable form pairs well with this: catch (RateLimitedException) followed by a retry states 'handled and retried, the object itself is irrelevant', which a reviewer can distinguish from a swallow at a glance.

Rethrow, Chain, and the Swallowed-Exception Anti-Pattern

The worst handler ever written is this one:

try {
    $receipt = charge($card, $amount);
} catch (\Exception $e) {
    // ignore
}
return $receipt;   // undefined here — or worse, an old value from an earlier loop

A swallowed exception removes the failure and its evidence. The call site believes the charge succeeded, $receipt is stale or undefined, and the log never records why. The two legitimate alternatives to "handle it here" are rethrow and deliberate degradation — and they look different on purpose.

Rethrow preserves the failure for a handler that knows what to do, and there is exactly one correct form that keeps the original object — its file, line, code, and cause chain intact:

try {
    validate($payload);
} catch (\InvalidArgumentException $e) {
    log()->warning('invalid payload', ['exception' => $e]);   // annotate on the way past
    throw $e;                                                 // same object, same trace
}

// WRONG: new exception loses the original's file/line/trace
throw new \RuntimeException('validation failed');             // previous arg omitted!

The wrong form is a common accident, and its cure is chaining: pass the old exception as the constructor's third argument so both stories survive.

throw new \RuntimeException(
    'checkout failed for order 512',
    0,
    $e,                      // previous — visible in the trace as "Caused by"
);

A trace printed from that chain shows RuntimeException: checkout failed for order 512 with Caused by: InvalidArgumentException: Charge amount must be positive underneath — the domain-level story at the top, the precise cause below, recoverable with getPrevious() when a reporter wants to log both.

Where should a catch live? At the layer that can act on the failure. A storage layer catches PDOException to translate it into its own exception type; a controller catches to map failures onto HTTP status codes; a queue worker catches to decide retry-versus-dead-letter. Each of those is handling. A try placed "just in case" around code whose failure the surrounding layer cannot interpret is not handling — it is deferral with extra steps, and rethrow is the honest move. One structural rule for teams: a catch block body should do one of three things — recover (return a fallback), translate (throw a domain exception with $e chained), or terminate (log and abort/rethrow). Anything else, especially empty bodies and fabricated successes, should fail code review on sight. Once you can classify handlers by those three verbs, the next topic gives your domain the exception vocabulary to translate into: Custom Exception Hierarchies.

The chaining pattern has a reporting consequence worth stating: when you translate, keep the top message specific to the layer that threw it — 'checkout failed for order 512' — while the previous object preserves the low-level detail. Reporters walk getPrevious() in a loop, so a translation chain of two or three links is not noise if each link adds context: the gateway client said connection refused, the payment service said upstream unavailable, the checkout said payment unavailable. What reviewers should reject on sight is the opposite pattern — rethrowing the same failure at four layers with nothing added, which pads every trace without adding a fact. The one-line test for a rethrow: if your handler adds no data and takes no action, delete the try block entirely and let the next layer handle it.

Quiz

1. What happens immediately after a throw statement inside charge() with no matching catch in the call stack?

Question 1 options

2. What is the difference between Exception and Error in PHP 7+?

Question 2 options

3. In what order should catch clauses be written, and why?

Question 3 options

4. You log an exception in a catch block and must abort the operation without losing the original file, line, and trace. What do you write?

Question 4 options

Flashcards

Question

What are the three roots of PHP's throwable hierarchy, and which goes where?

Answer

Throwable is the interface try/catch understands. Under it: Exception — business-detectable failures your code raises (InvalidArgumentException, RuntimeException); Error — engine-level violations signaling bugs (TypeError, ValueError, OutOfMemoryError). Catch Exception in application code; think before catching Error.

Question

How does PHP choose a catch clause, and what ordering follows from it?

Answer

Clauses are tried top to bottom and the FIRST whose type matches via instanceof wins — including interface catches. Therefore order specific → general, with catch (Throwable) last if used at all; a broad clause above a narrow one silently makes the narrow one dead code.

Question

What do PHP 8.0's multi-type catch and non-capturing catch each give you?

Answer

catch (A|B $e) handles several sibling types in one clause with $e bound to the actual object; catch (A) with no variable declares the type handled without using it. Both reduce ceremony while keeping the type-based selection rules unchanged.

Question

The correct rethrow, and the accidental one

Answer

Correct: `throw $e;` — the same object, keeping message, code, file, line, and the previous-exception chain. Accidental: `throw new RuntimeException('failed')` without the third constructor argument — a new object that severs the original cause; use chaining instead (covered under custom exceptions).

Question

The three legal fates of a catch block's body

Answer

Recover (return a fallback), translate (throw a domain exception with $e as previous), or terminate (log and abort/rethrow). Empty bodies and fabricated successes are the swallowed-exception anti-pattern: the caller believes the operation succeeded while the evidence is deleted.

Revision Notes

Key Takeaways

  • 1. throw halts execution instantly and unwinds the stack looking for a matching catch; unmatched means an Uncaught fatal with the full trace
  • 2. Exception is for failures your code raises, Error for engine bugs — both are Throwable, but only the former belongs in application handlers by default
  • 3. Catch selection is first-match-wins, so the ladder runs specific-to-general; PHP 8 adds multi-type (A|B) and non-capturing catch clauses
  • 4. Rethrow with `throw $e;` to keep the original evidence; a new exception without the $previous argument severs the cause chain
  • 5. A catch body recovers, translates, or terminates — empty catches and fake successes swallow failures along with their evidence

Interview Tips

  • • "Exception vs Error in PHP?" — the 7.0 split: business-detectable failures versus engine bugs, both under Throwable; add that catching Error is a deliberate act reserved for process boundaries
  • • "How do you order catch blocks?" — specific to general because selection is first-match instanceof, not most-specific; show you know a broad early clause silently kills later ones
  • • "How do you rethrow without losing information?" — `throw $e;` preserves everything; if you must wrap, pass $e as the constructor's third argument so the trace shows 'Caused by'
  • • "What's wrong with catch (Exception $e) { /* ignore */ }?" — it is the swallowed-exception anti-pattern: the caller proceeds on false success and the evidence is deleted; the fix is recover, translate with chaining, or terminate with a log

Cheat Sheet

Throwing and Catching Exceptions

The throw mechanic: builds the exception object (message, code, file/line recorded) → stops the current function instantly → unwinds frames until a matching catch (instanceof semantics) is found → otherwise 'Uncaught ...' fatal with trace.

try {
    $receipt = charge($card, $amount);
} catch (\InvalidArgumentException $e) {
    return response(400, $e->getMessage());       // recover
} catch (\GatewayException $e) {
    report($e);
    throw new \PaymentUnavailable($e->getMessage(), 0, $e);   // translate + chain
}                                                // specific → general, always

Hierarchy:

Root Meaning Examples
Exception code-detected, business failures InvalidArgumentException, RuntimeException
Error engine violations = bugs TypeError, ValueError, DivisionByError
Throwable the catchable interface (both roots) use at process boundaries only

PHP 8 syntax: catch (A|B $e) multi-type; catch (A) non-capturing; catch by interface via instanceof.

Rethrow & chain:

throw $e;                                              // preserves file/line/trace/chain
throw new RuntimeException('order 512 failed', 0, $e); // 'Caused by' in the trace
$e->getPrevious();                                     // walk the chain programmatically

Catch lives at the layer that can act; body must recover, translate, or terminate — never nothing.

Continue Learning