Naming Your Domain's Failures: When to Create an Exception Class
throw new RuntimeException('out of stock') works — until three call sites need to react differently to "out of stock" and none of them can ask the exception what went wrong without parsing the message string. A custom exception exists the moment a failure is a category your code must act on:
// src/Exception/OutOfStockException.php
namespace App\Exception;
final class OutOfStockException extends \RuntimeException
{
}
// usage — the type IS the diagnosis
try {
$order = $checkout->submit($cart);
} catch (OutOfStockException $e) {
return $this->offerBackorder($cart); // act on the category, not the sentence
}
The class body can be empty, and that is the honest minimum: the value is the type name, which callers can catch, log with severity, and map to responses without string matching. Extending RuntimeException (for failures in the flow of execution) or LogicException (for programming mistakes) rather than raw Exception keeps your exceptions inside the standard taxonomy — static analysis tools and generic catch (\RuntimeException $e) handlers in frameworks already treat those base classes correctly.
The test for whether to create one: does any caller need to do something different? If every handler would log and 500 identically, a RuntimeException with a good message is enough — exception-class inflation is real, and an App\Exception\* class per throw site makes the hierarchy noise instead of signal. Create the class when at least one of these holds: callers branch on it (backorder vs. permanent cancellation), operators alert on it (payment failure vs. validation failure), or it crosses a boundary as a contract (PaymentGateway::charge() documents throws GatewayException).
Name failures as domain events, past tense, no Error suffix — OutOfStockException, PaymentDeclinedException, InvoiceNotFoundException. Avoid the two naming traps: AppException-style names that say nothing, and names tied to implementation (PdoQueryFailedException) when the concept is really persistence (StorageUnavailableException). Keep the file count sane by grouping them in one src/Exception/ namespace directory — autoloading finds them like any class. And keep each class final by default: nobody downstream should subtype your failure vocabulary, and a final leaf makes the catch contract stable. The empty class is only the start, though — the failures worth a class are usually the ones worth carrying data about, which is the next step.
Where do the classes live, and who owns them? The convention across PHP packages is a dedicated src/Exception directory mirroring the domain: checkout exceptions under App\Checkout\Exception, billing under App\Billing\Exception. Autoloading treats them like any class, so no registry or map is needed — the namespace is the taxonomy. Scope them to the layer that raises them: a repository throws storage-flavored exceptions its caller might handle; the controller translates those into HTTP-flavored responses without inventing a second tree for the transport layer. Watch for the inflation failure mode in review — a new class for every throw site produces a taxonomy no one can hold in their head, and catch blocks that list six sibling leaves where one family type would do. The pruning heuristic: after a quarter, merge any exception type that is always caught by the same handler as its sibling. If they share a fate, they share a name.
The throwing and catching mechanics — type-matched catch ordering, rethrow, chaining — are assumed here from Throwing and Catching Exceptions; what this topic adds is the vocabulary those mechanics operate on.
Carrying Structured Context: Entity Ids, Codes, and Previous
A message is for humans reading a log; properties are for code handling the failure. Promote the context the handler will actually need into the exception:
namespace App\Exception;
final class InvoiceNotFoundException extends \RuntimeException
{
public function __construct(
public readonly string $invoiceId,
?\Throwable $previous = null,
) {
parent::__construct(
"Invoice {$invoiceId} not found",
404, // int code — inherited, keep it meaningful
$previous, // cause chain — inherited, never skip it
);
}
}
// the throw site tells the whole story once
try {
$invoice = $this->repository->find($id);
} catch (\NotFoundException $e) {
throw new InvoiceNotFoundException($id, $e); // domain type + cause + data
}
// the handler gets typed data, not a string to parse
} catch (InvoiceNotFoundException $e) {
$this->metrics->increment('invoice.missing', ['id' => $e->invoiceId]);
return response(404, "No invoice {$e->invoiceId}");
}
Three inherited capabilities ride along for free once you forward them through parent::__construct(). The message stays a human sentence — build it from the context in the constructor so it can never disagree with the properties. The code is an int slot that is yours to define (an HTTP status, an error catalog number); declare your scheme once and stick to it. The previous is the cause chain — passing it through is mandatory discipline, because dropping it severs exactly the diagnostic trail custom exceptions exist to improve.
Rules of restraint keep the pattern healthy. Context properties should be readonly — an exception is a report of a past event, not a live object to update; mutating one mid-flight is how contradictory traces appear. Keep them small and serializable: identifiers, offending values, expected-vs-actual — not the entire $row array or an open file handle, both of which bloat logs and can leak secrets. And do not put recovery decisions on the exception ($e->shouldRetry() invites domain logic into error classes); the type plus the data is enough for the layer that decides.
Two PHP-native features pair naturally with typed context. First, match on a value you carry (match ($e->reason) { self::EXPIRED => ..., } when reason is an enum — note enums from Backed Enums for Fixed Values are ideal for fixed reason sets). Second, the message composition itself: parent::__construct(sprintf(...)) in the constructor means every getMessage() in every log line is consistent with the structured fields — a guarantee you lose the moment anyone hand-writes a message at the throw site. One boundary to remember: $e->getPrevious() chain-walking is how reporters extract the original engine failure (ValueError from a library, say) beneath your domain wrapper — so forwarding previous is not ceremony, it is the difference between a 2-line incident report and a 30-minute archaeology dig. With context solved, the last design question is how many exception classes a domain should have — and how they relate.
Serialization interacts with context properties in one practical way: exceptions cross boundaries in logs and queue failure reports, and PHP serializes only the properties you declared — so small readonly scalars travel cleanly, while a context property holding a PDO connection makes serialize() throw or, worse, risks dumping credentials into a failure report. The cheap audit is to serialize a representative exception inside a test and assert what the payload contains. And since PHP 8.1, enums as context fields give reports a fixed vocabulary for free — a Reason enum on your exception produces stable log values that dashboards can group on, instead of free-form strings that drift per throw site.
Designing a Catchable Hierarchy: One Base, Precise Leaves
As a domain grows, so do its failure types — and callers need a middle option: "catch anything this subsystem throws" without listing twelve leaves. The standard shape is a shallow hierarchy anchored on one base exception:
namespace App\Checkout;
abstract class CheckoutException extends \RuntimeException
{
}
final class OutOfStockException extends CheckoutException
{
public function __construct(public readonly string $sku, ?\Throwable $previous = null)
{
parent::__construct("Out of stock: {$sku}", 0, $previous);
}
}
final class PaymentDeclinedException extends CheckoutException
{
public function __construct(public readonly string $reason, ?\Throwable $previous = null)
{
parent::__construct("Payment declined: {$reason}", 0, $previous);
}
}
Making the base abstract states "never throw me directly — only my leaves", while still giving every catcher a concrete type to name. Because catch matching is instanceof, one clause catches the family and specific clauses catch the leaves:
try {
$order = $checkout->submit($cart);
} catch (PaymentDeclinedException $e) {
return $this->retryWithBackupCard($e); // specific: knows $e->reason
} catch (CheckoutException $e) {
return $this->abandonCart($e); // family-wide fallback
}
Order still applies — the specific clause above the family one — and the hierarchy depth should stay at one: base → leaves. Deep chains (AppException → DomainException → CheckoutException → ...) multiply the decision every handler makes and rarely buy matching precision; when you feel the need for a third level, it usually means two subsystems want two base exceptions instead.
A hierarchy also sets the library boundary. Code inside your namespace throws CheckoutException leaves; third-party exceptions never leak past your layer — you translate at the seam (catch (\GuzzleException $e) { throw new UpstreamUnavailableException($e->getMessage(), 0, $e); }), exactly as the previous chapter's chaining pattern prescribes. That gives callers one vocabulary regardless of which HTTP client or ORM is underneath, and swapping the library touches one file instead of every catch block in the app. Document the contract: PHPDoc on the method (@throws PaymentDeclinedException when the gateway rejects) is how callers learn what to catch without reading the implementation — there is no throws keyword in PHP, so the annotation plus the base exception is the public API.
Finally, resist two gravitational pulls. Do not catch-and-wrap everything at every layer — the same failure wrapped three times produces a trace eleven frames of Caused by deep; translate once, at the boundary where vocabulary changes. And do not use exceptions for control flow (looping on NotFoundException to mean "absent"), which inverts the design: absence-with-a-default is tryFrom-style returns, failure-to-act is exceptions. With vocabulary, context, and hierarchy settled, the remaining failure surface in PHP is everything that is not an exception — the notices and warnings that need Registering Custom Error Handlers.
The catch-side view completes the design: handlers should catch the narrowest type they can act on, and the hierarchy exists to make each level of that decision nameable. A controller catching CheckoutException covers the family with one clause and maps it to a 4xx; a payment-specific branch catches PaymentDeclinedException to add a retry affordance; an outermost kernel catches Throwable to log and 500. That three-tier shape — precise, family, last resort — is the same ladder from the basics topic, now made ergonomic by the tree you designed. Test it like code: a unit test per translation seam asserting the domain type and the chained previous, plus one integration test proving the kernel's last-resort clause actually catches what your subtree throws.
Quiz
1. What is the minimum viable body of a custom exception class, and where does its value come from?
2. What should a custom exception's constructor do with the $previous argument?
3. Why should a domain's exception hierarchy stay shallow (base → leaves)?
4. Your service calls a Guzzle HTTP client whose exceptions leak into your callers' catch blocks. What is the correct fix?
Flashcards
Question
When does a failure deserve its own exception class?
Click to reveal answer
Answer
When at least one caller must act differently on it, operators alert on it, or it crosses a boundary as a documented contract. Otherwise a RuntimeException with a good message suffices — class-per-throw-site is noise. Extend RuntimeException (execution failures) or LogicException (bugs), name past-tense domain events, and keep leaves final.
Question
How do you carry structured context in an exception, and what are the limits?
Click to reveal answer
Answer
Promote small readonly properties (ids, offending values, expected-vs-actual) in the constructor and build the message from them via parent::__construct($msg, $code, $previous) so message and data can't disagree. No big objects, open handles, or secrets; no recovery logic on the exception — the type plus data is the handler's input.
Question
The three inherited slots you should forward through parent::__construct
Click to reveal answer
Answer
Message (human sentence, composed from context), code (int — define a scheme like HTTP status or error-catalog number), previous (the cause chain — never omit, or traces lose the original failure). All three are constructor parameters of the base Exception class.
Question
Shape of a catchable exception hierarchy
Click to reveal answer
Answer
One abstract base per subsystem (abstract = never thrown directly) with final leaves under it. Catch matching is instanceof, so handlers choose family-wide (catch CheckoutException) or precise (catch PaymentDeclinedException) — specific clause above family clause. Keep depth at one level.
Question
Where should third-party exceptions be translated, and what should PHPDoc document?
Click to reveal answer
Answer
Once, at the boundary where vocabulary changes: catch the library type and throw your domain exception with the original as $previous — never leak upstream types, never re-wrap at every layer. Document each public method's failure contract with @throws annotations, since PHP has no throws keyword in signatures.
Revision Notes
Key Takeaways
- 1. An exception class earns its existence when callers branch, operators alert, or the failure is a boundary contract — the type name is the API, so an empty final class is often complete
- 2. Structured context beats message parsing: readonly promoted properties, message composed in the constructor, code given a defined scheme
- 3. Always forward $previous — the chain is what lets a two-line domain message still lead reporters to the original engine failure
- 4. One abstract base per subsystem, final leaves, depth one: instanceof gives family-wide and precise catches from the same tree
- 5. Translate third-party exceptions once at the boundary and document the failure contract with @throws, since signatures cannot
Interview Tips
- • "When would you create a custom exception?" — when a handler must distinguish the failure: name the branching, alerting, and contract tests; show restraint by saying generic RuntimeException covers the rest
- • "What belongs on an exception object?" — small readonly context (ids, offending values), meaningful code, forwarded previous; explicitly reject big objects and retry-decision methods
- • "How do callers catch my whole subsystem's failures?" — abstract base exception + instanceof matching: `catch (AppException $e)` family-wide, leaves for precision, specific clause ordered above family
- • "A library's exceptions are leaking through my API — fix?" — translate once at the boundary into domain exceptions with the original chained as $previous; one vocabulary, one file to swap the library, no re-wrapping at every layer
Cheat Sheet
Custom Exception Hierarchies
When: caller must branch / operators alert / it is a documented contract. Otherwise throw new RuntimeException('good message') is enough.
namespace App\Checkout;
abstract class CheckoutException extends \RuntimeException {}
final class OutOfStockException extends CheckoutException
{
public function __construct(public readonly string $sku, ?\Throwable $previous = null)
{
parent::__construct("Out of stock: {$sku}", 0, $previous);
}
}
try {
$order = $checkout->submit($cart);
} catch (PaymentDeclinedException $e) { /* precise: $e->reason */ }
catch (CheckoutException $e) { /* family fallback */ }
Context rules: readonly properties for ids/values; message composed in constructor (can't drift from data); forward $previous always; no secrets, handles, or decision logic on the exception.
Inherited slots: parent::__construct(string $message, int $code, ?Throwable $previous) — code is yours to scheme (HTTP status, catalog number); previous powers getPrevious() chain-walking in reporters.
Hierarchy: abstract base (one per subsystem) → final leaves; depth one. Translate library exceptions ONCE at the vocabulary boundary, chaining the original. Document with @throws — PHP has no throws keyword.