In PHP, how do you wrap a failed payment-provider call in your own domain exception without losing the original cause?
answer
- the constructor's third argument
- ?Throwable $previous = null
- getPrevious() walks back to the root
- catch narrowly, throw your own type
- marker interface extends Throwable
basics
~20 sCatch the provider client's exception narrowly and throw your own exception class, passing the caught object as the third constructor argument, $previous. Callers depend only on your type, while getPrevious() still returns the provider's original failure for logs and debugging.
solid answer
~40 sCatch only what the provider client throws, then throw a domain exception such as `PaymentFailed`, passing the caught object as `$previous` — the third parameter of `Exception::__construct(string $message = "", int $code = 0, ?Throwable $previous = null)`, or `previous: $e` as a named argument. Callers now catch one type that speaks the domain's language instead of the SDK's, so changing providers does not ripple through the codebase. Nothing is lost: `getPrevious()` returns the original, and casting the exception to a string prints the whole chain, root cause first, with each wrapper introduced by `Next`. A common hierarchy is a marker interface (`interface PaymentException extends \Throwable`) implemented by concrete classes that extend a built-in such as `RuntimeException`, because a user class cannot implement `Throwable` directly.
code
php · 25 lines<?php
declare(strict_types=1);
namespace App\Billing;
interface PaymentException extends \Throwable {}
final class PaymentFailed extends \RuntimeException implements PaymentException
{
public static function forOrder(string $orderId, \Throwable $cause): self
{
return new self("Charge for order {$orderId} failed", previous: $cause);
}
}
final class Checkout
{
public function __construct(private ProviderClient $client) {}
public function pay(string $orderId, int $amountCents): string
{
try {
return $this->client->charge($orderId, $amountCents);
} catch (ProviderTimeout | ProviderRejected $e) {
throw PaymentFailed::forOrder($orderId, $e);
}
}
}go deeper
Recall that the third constructor argument, $previous, links a new exception to the one that caused it, and that getPrevious() reads it back.
Explain why you wrap at a boundary, how to build a small hierarchy with a marker interface extending Throwable, and why a class cannot implement Throwable itself.
Show where wrapping belongs in a service — once, at the adapter boundary — and how chained exceptions reach the logs without leaking provider secrets or doubling layers.
Weigh a shared exception vocabulary across teams against per-module types, and decide which failures callers are expected to handle versus only observe.
## Why wrap at all A checkout service that calls an external payment provider through that provider's client library receives the library's exceptions: timeouts, rejected cards, malformed responses. If those types leak upwards, every controller, job and test that touches payments has to know the vendor's class names. **Wrapping** means catching the low-level exception at the boundary and throwing an exception that belongs to your own domain, such as `PaymentFailed`. The benefits: - callers catch **one stable type** instead of a vendor's set; - the message and properties can carry **domain context** (the order id, the amount) that the provider knows nothing about; - replacing the provider changes one adapter class, not every `catch` in the application. Wrapping is only safe if the original failure survives, because it holds the detail an engineer needs at 3 a.m. That is what `$previous` is for. ## The $previous constructor parameter The built-in `Exception` (and `Error`) constructor is: ```php public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null) ``` Passing the caught object as the third argument links the two. With named arguments you can skip `$code`: `new PaymentFailed('Charge failed', previous: $e)`. There is **no setter** — `Exception` has no method to attach a cause after construction, so the link has to be made when the new exception is created. Two details trip people up: - `$code` is typed `int`. Some built-in exceptions carry a non-integer code — a `PDOException` holds an SQLSTATE string such as `'HY000'` — so forwarding `$e->getCode()` blindly can throw a `TypeError` under `strict_types`. Choose your own code or leave it at `0`. - Do not put secrets from the provider's response into your message. The message tends to end up in logs and sometimes in responses. ## Reading the chain back `getPrevious()` returns the linked `?Throwable`, and the linked exception can have its own `previous`, so a chain can be several levels deep. A logger walks it with a loop until `getPrevious()` returns `null`. Casting an exception to a string (which is what an uncaught-exception report or `echo $e` does) prints the **whole chain**: the innermost cause first, then each wrapper after a line starting with `Next`. So a stack trace in the log still leads straight to the provider's timeout. For structured logs, record each level separately rather than only the outer message: - the outer class and message, which say which business operation failed; - each `getPrevious()` level's class, message, file and line, which say what actually broke; - the domain context you added — order id, amount — which the provider's exception never had. Because `getPrevious()` is declared `final` on `Exception`, a subclass cannot change how the chain is read; it only decides what it passes in at construction. ## Designing the hierarchy | Piece | Example | Purpose | |---|---|---| | Marker interface | `interface PaymentException extends \Throwable {}` | One type callers can catch for "anything payment-related" | | Concrete class | `final class PaymentFailed extends \RuntimeException implements PaymentException` | A specific, catchable failure with a real parent | | Named constructor | `PaymentFailed::forOrder($orderId, $e)` | Builds a consistent message and always passes the cause | A user class **cannot implement `Throwable` directly**: PHP stops with a fatal error telling you to extend `Exception` or `Error` instead. An *interface* may extend `Throwable`, though, which is why the marker-interface pattern works — the interface makes the intent explicit and the class supplies the real base. Keep the hierarchy shallow. Two or three meaningful types, split by what a caller would *do* differently (retry later, ask for another card, give up), beat a class per provider error code. ## Mistakes to avoid 1. **Dropping the cause** — `throw new PaymentFailed('Charge failed');` inside the `catch` loses the provider's trace for good. 2. **Wrapping too broadly** — catching `\Throwable` around the provider call also wraps your own `TypeError` bugs as "payment failed". 3. **Wrapping twice at every layer** — each layer adding its own wrapper produces a five-deep chain that says the same thing five times; wrap once, at the boundary where the vocabulary changes. 4. **Copying the cause's message into the wrapper** — link it instead; the chain already carries it.
- Why not pass $e->getCode() from the provider's exception as the wrapper's $code?The `$code` parameter of `Exception::__construct` is typed `int`, but `getCode()` is untyped and some built-in exceptions hold a string there — `PDOException` stores an SQLSTATE such as `'HY000'`. Forwarding it can throw a `TypeError`, especially under `strict_types`. The cause is reachable through `getPrevious()` anyway, so pick a code that means something in your domain or leave it at 0.
- Can a PHP interface extend Throwable, even though a class cannot implement it directly?Yes. The rule that stops a class from implementing `Throwable` applies only to classes, which must extend `Exception` or `Error`. An interface such as `interface PaymentException extends \Throwable {}` is allowed, and any class implementing it must still extend one of the two built-ins. That makes the marker interface catchable and type-hintable as a throwable.
- In what order does echo $wrapper print a two-level exception chain?Root cause first. `Exception::__toString()` renders the innermost previous exception with its trace, then each outer exception after a blank line and the word `Next`, ending with the wrapper you actually caught. Reading a log from the top therefore starts at the original provider failure.
saying these in an interview costs you the question
- You can attach the cause later with a setPrevious() call
- Wrapping loses the original stack trace, so log it before rethrowing
- A custom exception class can simply implement Throwable
- Every layer should wrap the exception in its own type again
- Catch Throwable around the provider call so nothing escapes unwrapped