Base classes, marker interfaces and layered architecture in PHP 8.4
Teams that keep relying on generic exceptions, or on an unplanned pile of one-off classes, quickly lose track of business errors versus technical errors as a PHP project grows. This article shows how custom exception hierarchies emerge from abstract base classes, marker interfaces and typed context data, and how they hold up across domain, application and infrastructure boundaries.
Table of Contents
- 1. Why a flat exception structure fails as projects grow
- 2. Business vs. technical errors: the core dividing line for exception hierarchies
- 3. Designing an abstract base class for your own exception hierarchy
- 4. Marker interfaces: categorizing exceptions without forcing inheritance
- 5. Structured context data with constructor property promotion
- 6. Layered architecture: separating domain, application and infrastructure exceptions
- 7. Naming conventions and granularity: when a new exception class makes sense
- 8. Exception translation at layer boundaries: turning technical into business errors
- 9. Working with global error handling and logging at the system boundary
- 10. Summary
- 11. FAQ
1. Why a flat exception structure fails as projects grow
In the first weeks of a project, almost any error handling works: a handful of SPL classes, maybe a single custom exception class for everything that can go wrong, and one central catch (Throwable $e) at the edge of the application. That works fine as long as the number of failure cases is small and the reaction to them is always the same. As a project grows, though, so does the number of places that need to react differently to failures, and that is exactly where a flat structure starts to fail.
The typical symptom is a caller that catches with catch (Exception $e) and then decides what actually happened by inspecting $e->getMessage() or using str_contains(). A user needs an understandable message, a developer needs a stack trace, monitoring needs a stable category for alerting, and all three needs end up hanging off the same fragile string. Without a well thought out custom exception structure, every change to an error message becomes a silent break somewhere completely different in the code.
Custom exception hierarchies do not solve this problem by adding more classes for their own sake, but through a deliberate structure: classes that say something about the nature of the error, not just its text. The following sections build up exactly this structure step by step, from separating business and technical errors through to a full layered architecture in a large system.
2. Business vs. technical errors: the core dividing line for exception hierarchies
The most important decision when designing your own exception hierarchies happens before the first class is written: is a given error a business error or a technical error? A business error is part of the normal flow of the application, stock is insufficient, a discount code has expired, a customer number does not exist. These errors are expected, are usually reasonable to explain to an end user, and are, as a rule, not a sign of a bug.
A technical error, by contrast, sits outside the business logic entirely: a database connection drops, an external payment provider stops responding, a configuration file is missing. These errors are almost always unexpected, cannot be meaningfully explained to an end user, and are worth alerting operations about. If exception hierarchies do not reflect this dividing line, business and technical errors end up in the same catch block and the same log level, which means either harmless business cases flood monitoring, or real outages get lost in the noise.
This distinction is the thread running through the rest of the architecture: it determines which HTTP status code an API returns, which log level is used, whether a retry makes sense, and whether a human needs to be paged at all. Every further design decision in this article, from the base class to global error handling, builds directly on this business/technical split.
3. Designing an abstract base class for your own exception hierarchy
The first building block of custom exception hierarchies is an abstract base class per bounded context, not a single global base class for the entire application. A base class per context, for example OrderDomainException for everything related to orders, gives callers one meaningful catch point for that context, without lumping every exception in the application together.
What matters more than the inheritance itself is what the base class enforces. An abstract method like errorCode() forces every concrete exception to declare a stable, machine readable identifier that is independent of the mutable message text. A context array that is private but accessible through a getter, set up in the constructor, gives every exception in the hierarchy a place for structured additional data from day one, instead of every class inventing its own solution.
<?php
declare(strict_types=1);
namespace App\Domain\Exception;
/**
* Abstract base class for all exceptions raised by the order domain.
* Every custom exception in this bounded context extends this class,
* which gives us a single catch point and a place for shared behavior.
*/
abstract class OrderDomainException extends \RuntimeException
{
/**
* @param string $message Human readable message (not shown to end users)
* @param array<string, mixed> $context Structured data for logging and debugging
* @param \Throwable|null $previous Original exception, if this wraps a lower layer error
*/
public function __construct(
string $message,
private readonly array $context = [],
?\Throwable $previous = null,
) {
parent::__construct($message, 0, $previous);
}
/**
* Returns structured context data attached to this exception.
*
* @return array<string, mixed>
*/
public function getContext(): array
{
return $this->context;
}
/**
* Every concrete exception must declare a stable, machine readable
* error code that frontends and logs can rely on, independent of message text.
*/
abstract public function errorCode(): string;
}
This base class is deliberately kept lean: it extends RuntimeException, because most business and technical runtime errors fall into that category, and it passes the $previous exception straight through to the parent class unchanged. Every concrete exception in the hierarchy only has to take care of two things: a meaningful message and a stable errorCode, everything else, including context handling, is already solved.
4. Marker interfaces: categorizing exceptions without forcing inheritance
Inheritance alone is not enough to categorize exceptions along multiple, overlapping criteria. An exception can be both a business error and retryable at the same time, or technical and retryable, and a single class hierarchy with simple inheritance cannot cleanly express such combinations. This is exactly where marker interfaces come in: empty or near-empty interfaces such as BusinessException, TechnicalException and RetryableException that categorize an exception in addition to its class hierarchy.
The decisive advantage of marker interfaces over deeper inheritance: a concrete exception class can declare implements BusinessException or implements TechnicalException, RetryableException without ever changing the underlying class hierarchy. Callers then no longer catch a concrete class, they catch a capability, catch (RetryableException $e) works regardless of whether the concrete exception comes from the order, payment or shipping domain.
<?php
declare(strict_types=1);
namespace App\Domain\Exception;
/**
* Marker interface for all business/domain errors: expected, user-facing,
* and part of normal application flow (e.g. validation, business rule violations).
*/
interface BusinessException extends \Throwable
{
}
/**
* Marker interface for technical/infrastructure errors: unexpected,
* not meant to be shown to end users, always worth alerting on.
*/
interface TechnicalException extends \Throwable
{
}
/**
* Marker interface for errors where a retry might succeed
* (e.g. transient network or lock issues).
*/
interface RetryableException extends \Throwable
{
}
/**
* Raised when a referenced product no longer exists. Business error:
* expected, user-facing, no bug, no alert needed.
*/
final class ProductNotFoundException extends OrderDomainException implements BusinessException
{
public function errorCode(): string
{
return 'ORDER_PRODUCT_NOT_FOUND';
}
}
/**
* Raised when the payment gateway times out. Technical error, but
* retrying the same request is likely to succeed.
*/
final class PaymentGatewayTimeoutException extends OrderDomainException implements TechnicalException, RetryableException
{
public function errorCode(): string
{
return 'ORDER_PAYMENT_GATEWAY_TIMEOUT';
}
}
// Catching by capability, independent of class hierarchy position
function handle(\Throwable $e, LoggerInterface $logger): void
{
match (true) {
$e instanceof RetryableException => retryLater($e),
$e instanceof BusinessException => $logger->info($e->getMessage()),
$e instanceof TechnicalException => $logger->error($e->getMessage()),
default => throw $e,
};
}
In practice this means: a global error handler can use a single match (true) over instanceof checks against marker interfaces to decide how to handle an exception, without ever knowing the concrete class. New concrete exceptions added later only need to implement the right interfaces, the existing error handling code does not change.
5. Structured context data with constructor property promotion
A generic context array, as offered by the base class, is a good start, but ultimately just another associative array with the usual weaknesses: no type checking, no IDE autocompletion, no error when accessing a misspelled key. For exceptions that get inspected regularly, for example to build a user-facing message or to make a retry decision, a typed context object right in the constructor of the concrete exception pays off instead.
Constructor property promotion makes this possible without extra boilerplate: the context data is declared as readonly properties directly in the constructor and used both for the message and for the base class's generic context array at the same time. That gives every caller type-safe getters like getSku() or getRequestedQuantity(), while logging code can still fall back on the generic getContext() if it does not know the concrete class.
<?php
declare(strict_types=1);
namespace App\Domain\Exception;
/**
* Raised when a requested product variant is out of stock.
* Carries strongly typed context instead of a generic array,
* so callers and logging code get IDE autocompletion and type safety.
*/
final class InsufficientStockException extends OrderDomainException implements BusinessException
{
public function __construct(
private readonly string $sku,
private readonly int $requestedQuantity,
private readonly int $availableQuantity,
?\Throwable $previous = null,
) {
parent::__construct(
message: sprintf(
'Cannot fulfill order for SKU "%s": requested %d, only %d available.',
$sku,
$requestedQuantity,
$availableQuantity,
),
context: [
'sku' => $sku,
'requested_quantity' => $requestedQuantity,
'available_quantity' => $availableQuantity,
],
previous: $previous,
);
}
public function getSku(): string
{
return $this->sku;
}
public function getRequestedQuantity(): int
{
return $this->requestedQuantity;
}
public function getAvailableQuantity(): int
{
return $this->availableQuantity;
}
public function errorCode(): string
{
return 'ORDER_INSUFFICIENT_STOCK';
}
}
// Usage: type-safe access to context, no array key lookups
try {
$orderService->placeOrder($cart);
} catch (InsufficientStockException $e) {
$logger->warning($e->getMessage(), [
'sku' => $e->getSku(),
'missing' => $e->getRequestedQuantity() - $e->getAvailableQuantity(),
]);
}
An important side effect: because the context data consists of explicit, typed properties, it becomes immediately obvious if sensitive data such as payment details or passwords accidentally end up in an exception, the type makes visible what is actually being carried. With an unstructured array, a mistake like that often goes unnoticed until it surfaces in a log aggregator.
6. Layered architecture: separating domain, application and infrastructure exceptions
In larger applications with a separation between domain, application and infrastructure layers, exception hierarchies should follow that same layering. The infrastructure layer knows about database drivers, HTTP clients and file systems, and throws correspondingly specific exceptions such as DatabaseConnectionException. The domain layer only knows business concepts such as orders and stock levels and must not know anything about those technical details, its exceptions like OrderRepositoryUnavailableException express what went wrong from a business point of view, not how.
The application layer takes on the role of translator between both worlds: it orchestrates use cases, calls infrastructure code, and catches its technical exceptions in order to translate them into domain-specific exceptions. If the domain layer instead caught PDOException or a Guzzle-specific exception directly, it would be coupled to a concrete infrastructure implementation, and switching database drivers would then force changes deep inside business code.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Exception;
// Infrastructure layer: knows about the database, HTTP client, filesystem
final class DatabaseConnectionException extends \RuntimeException
{
}
namespace App\Domain\Exception;
// Domain layer: knows nothing about infrastructure, only business rules
// (constructor with context/errorCode omitted here for brevity, see section 3)
abstract class OrderDomainException extends \RuntimeException
{
}
final class OrderRepositoryUnavailableException extends OrderDomainException
{
}
namespace App\Application\Order;
// Application layer: orchestrates use cases, translates between layers
use App\Domain\Exception\OrderRepositoryUnavailableException;
use App\Infrastructure\Exception\DatabaseConnectionException;
use App\Infrastructure\Repository\OrderRepository;
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orderRepository,
) {
}
public function handle(PlaceOrderCommand $command): void
{
try {
$this->orderRepository->save($command->toOrder());
} catch (DatabaseConnectionException $e) {
// Translate the infrastructure detail into a domain-level exception;
// callers above this point must not need to know about PDO or SQL.
throw new OrderRepositoryUnavailableException(
'Order could not be persisted due to a repository failure.',
previous: $e,
);
}
}
}
This separation of layers pays off above all when testing: domain and application tests do not need to simulate real infrastructure exceptions, they can work exclusively with the clearly named exceptions of their own layer. That makes tests more resilient to changes in the underlying infrastructure, and keeps each layer's error classes internally consistent.
7. Naming conventions and granularity: when a new exception class makes sense
The name of an exception class is not a minor detail: InsufficientStockException immediately tells you what happened, while GeneralOrderException or even OrderProblemException stays uninformative and forces the caller back into searching getMessage(). A proven convention: the name describes the business situation, not the technical implementation, and consistently ends in Exception, so it is instantly recognizable in code what you are dealing with.
For granularity, a simple guiding question helps: does the caller need to react differently to this failure case than to an existing one? If yes, that justifies a new class. If the difference is only in the value of a variable, for example which SKU is affected, an existing class with the right constructor parameters is entirely sufficient. This rule prevents two opposite mistakes: a single god exception for everything, and an unmanageable explosion of near-identical classes.
In practice it also pays to keep exception names in the same vocabulary as the domain's ubiquitous language. If domain experts talk about a "cancellation block", the exception should be called CancellationBlockedException, not something like OrderStateException, that way code and domain language stay aligned across the entire exception hierarchy, which makes reviews and onboarding new developers noticeably easier.
8. Exception translation at layer boundaries: turning technical into business errors
Exception translation at layer boundaries means: a low-level technical exception is caught and replaced with a higher-level, business-meaningful exception, while the original exception is preserved as $previous. This pattern is not the exception, it is the rule everywhere domain or application code calls infrastructure that can throw errors which have no meaning in the current context.
The payoff is decoupling: no caller in the domain layer ever needs to know or import \PDOException or a Guzzle-specific exception. If the team switches from PDO to a different database layer, only the translation inside the repository changes, all domain and application exceptions stay the same, and so does every existing catch block.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Repository;
use App\Domain\Exception\BusinessException;
use App\Domain\Exception\CustomerNotFoundException;
use App\Domain\Exception\OrderDomainException;
use App\Domain\Exception\OrderRepositoryUnavailableException;
final readonly class PdoOrderRepository
{
public function __construct(private \PDO $connection)
{
}
/**
* Loads a customer's order history. Translates low-level PDO failures
* into domain exceptions so callers never need to catch \PDOException.
*
* @return array<int, array<string, mixed>>
*/
public function findOrdersByCustomerId(string $customerId): array
{
try {
$statement = $this->connection->prepare(
'SELECT * FROM orders WHERE customer_id = :customer_id',
);
$statement->execute(['customer_id' => $customerId]);
$rows = $statement->fetchAll(\PDO::FETCH_ASSOC);
} catch (\PDOException $e) {
// Connection lost, deadlock, syntax error: technical details
// the domain layer should never have to know about.
throw new OrderRepositoryUnavailableException(
sprintf('Could not load orders for customer "%s".', $customerId),
previous: $e,
);
}
if ($rows === []) {
// CustomerNotFoundException is a BusinessException, defined
// analogous to InsufficientStockException in an earlier example.
throw new CustomerNotFoundException($customerId);
}
return $rows;
}
}
// Global handler at the system boundary (e.g. a PSR-15 middleware)
set_exception_handler(function (\Throwable $e) use ($logger): void {
if ($e instanceof BusinessException) {
$logger->info('Business exception surfaced at the boundary', [
'code' => $e instanceof OrderDomainException ? $e->errorCode() : 'unknown',
]);
http_response_code(422);
} else {
$logger->critical('Unhandled technical exception', ['exception' => $e]);
http_response_code(500);
}
});
What matters is to never swallow the original exception: the $previous parameter keeps the full stack trace of the technical cause for logging and debugging, while the new exception's message is deliberately phrased at the business level. That way developers get the full technical depth while debugging, while users or API consumers only ever see the business-relevant, translated error message.
9. Working with global error handling and logging at the system boundary
At the edge of the system, whether that is a PSR-15 middleware stack, a framework kernel, or a plain set_exception_handler(), every exception hierarchy in the application ultimately converges. This is exactly where the groundwork from the previous sections pays off: instead of handling dozens of concrete classes one by one, the global handler only checks against the small number of marker interfaces and base classes to determine log level, HTTP status code and alerting behavior.
A well thought out global error handler consistently uses the structured context data from section 5: instead of a plain text message, the SKU, customer number or order number end up as structured fields in the log entry and can be tied together across multiple log lines with a correlation ID. The following table compares the four most common approaches to custom exception hierarchies in terms of maintainability, granularity and catch effort.
| Approach | Maintainability | Granularity | Catch effort |
|---|---|---|---|
| One exception for everything | low, obscures causes | none | minimal, but uninformative |
| One class per use case | explodes as project grows | very high | high, many catch blocks |
| Base class + marker interfaces | high | deliberately tunable | low, catch by interface |
| Layer-specific hierarchies | high in large systems | clearly scoped per layer | medium, translation needed at boundaries |
| Built-in SPL exceptions only | low | no business separation | low, but no context |
For production systems, a combination is almost always advisable: a base class per bounded context, marker interfaces for business/technical status and retryability, and, at larger layer boundaries, an additional translation between infrastructure and domain exceptions. This combination keeps the number of classes manageable without giving up granularity or type safety, and turns your own error class architecture into a tool that grows with the project instead of slowing it down.
10. Summary
Custom exception hierarchies pay off once a project outgrows a handful of failure cases and different consumers, users, developers, monitoring, need different information from the same error. The core dividing line between business and technical errors decides log level, HTTP status and alerting, and should be the very first design decision of any custom exception hierarchy, before the first concrete class is even written.
An abstract base class per bounded context, marker interfaces for cross-cutting categories like retryable, typed context data via constructor property promotion, and a deliberate translation at layer boundaries together add up to a structure that ends neither in a single god exception nor in hundreds of near-duplicates. Teams that apply these building blocks consistently from the start save themselves a costly error handling refactor once the project hits its first real growth spurt.
Custom Exception Hierarchies: The Key Takeaways
Business vs. Technical
The core dividing line of every exception hierarchy: determines log level, HTTP status, and whether a retry makes sense.
Base class per context
An abstract base class per bounded context bundles context handling and enforces a stable errorCode().
Marker interfaces
BusinessException, TechnicalException, RetryableException categorize across the class hierarchy, without deep inheritance.
Layers & translation
Domain exceptions know nothing about infrastructure details; application code translates technical into business errors at the boundary.
11. FAQ: Custom Exception Hierarchies
1What is the difference from PHP's SPL exceptions?
2When does a custom hierarchy pay off, and when is it overkill?
3Exception or RuntimeException as the base?
4Advantage of marker interfaces over inheritance?
5How much context data should an exception carry?
6How do you translate technical into business errors?
7Does every layer need its own hierarchy?
8How does this affect global error handling?
9Is one exception per use case a good idea?
10Can BusinessException and TechnicalException apply at once?
Mironsoft
PHP architecture, domain-driven design and maintainable error handling
Is your PHP project outgrowing its own error handling?
We review existing codebases, identify fragile catch blocks, and design custom exceptions with clear base classes, marker interfaces and a clean layered architecture that stays maintainable years into production.
Architecture review
Analyzing existing error classes and uncovering business/technical blending
Refactoring
Introducing base classes, marker interfaces and typed context data step by step
Layered architecture
Cleanly separating and translating domain, application and infrastructure exceptions