- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
You open a service class and find this:
public function transfer(string $from, string $to, int $cents): void
{
$account = $this->accounts->findById($from);
if ($account === null) {
throw new HttpException(404, 'Account not found');
}
if ($account->balanceCents < $cents) {
throw new HttpException(422, 'Insufficient funds');
}
// ...
}
It works in the controller. Then someone writes a CLI command that runs the same transfer from a CSV backfill. Now a 404 is being thrown inside a console job that has no request and no response. The operator running the import sees HttpException in a log line and has no idea what a status code is doing in a batch process.
The business rule "you cannot transfer from an account that does not exist" got tangled up with a transport detail "the wire protocol returns 404 for that." Those are two different facts. One belongs to the domain. The other belongs to the edge. This post separates them.
The domain throws meaning, not status codes
Start with a base exception that carries no transport knowledge at all. It lives in the domain layer and imports nothing from a framework.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
abstract class DomainException extends \RuntimeException
{
}
That is the whole base class. It extends \RuntimeException so existing catch (\Throwable) blocks still work, and it gives you a single type to catch when you want every domain failure in one net.
Now three subtypes, one per kind of failure that maps to a distinct HTTP family. Not one per error message — one per behaviour at the boundary.
<?php
declare(strict_types=1);
namespace App\Domain\Shared;
abstract class NotFound extends DomainException
{
}
abstract class Conflict extends DomainException
{
}
abstract class Validation extends DomainException
{
}
These are abstract too. You never throw new NotFound(...) directly. You throw a concrete exception that is a NotFound, named after the thing that was missing.
Concrete exceptions read like sentences
Each concrete exception lives next to the aggregate it belongs to. It names the entity and the identifier, and it builds its own message in a named constructor so call sites stay short.
<?php
declare(strict_types=1);
namespace App\Domain\Account;
use App\Domain\Shared\NotFound;
final class AccountNotFound extends NotFound
{
public static function withId(AccountId $id): self
{
return new self(
"Account {$id->value} does not exist."
);
}
}
<?php
declare(strict_types=1);
namespace App\Domain\Account;
use App\Domain\Shared\Validation;
final class InsufficientFunds extends Validation
{
public static function transfer(
AccountId $from,
int $requestedCents,
int $balanceCents,
): self {
return new self(sprintf(
'Account %s has %d but %d was requested.',
$from->value,
$balanceCents,
$requestedCents,
));
}
}
The transfer method from the opening now reads in domain terms. No status codes, no Request, no Response.
public function transfer(
AccountId $from,
AccountId $to,
int $cents,
): void {
$account = $this->accounts->findById($from);
if ($account === null) {
throw AccountNotFound::withId($from);
}
if ($account->balanceCents() < $cents) {
throw InsufficientFunds::transfer(
$from,
$cents,
$account->balanceCents(),
);
}
// ...
}
Run this from a controller, a CLI command, a queue worker, or a unit test. Every caller gets the same exception with the same meaning. None of them is forced to think in HTTP.
One mapper at the HTTP adapter
The translation from domain failure to status code happens once, at the edge, in the only layer that is allowed to know HTTP exists. Here it is for a Symfony app, registered as an exception listener.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http;
use App\Domain\Shared\Conflict;
use App\Domain\Shared\DomainException;
use App\Domain\Shared\NotFound;
use App\Domain\Shared\Validation;
use Symfony\Component\HttpFoundation\JsonResponse;
final class DomainExceptionMapper
{
public function toStatus(DomainException $e): int
{
return match (true) {
$e instanceof NotFound => 404,
$e instanceof Conflict => 409,
$e instanceof Validation => 422,
default => 500,
};
}
public function toResponse(DomainException $e): JsonResponse
{
$status = $this->toStatus($e);
$body = [
'error' => $this->slug($e),
'message' => $e->getMessage(),
];
return new JsonResponse($body, $status);
}
private function slug(DomainException $e): string
{
$short = (new \ReflectionClass($e))->getShortName();
return strtolower(preg_replace(
'/(?<!^)[A-Z]/',
'_$0',
$short,
));
}
}
The match (true) checks the subtype, not the concrete class. Add a new AccountClosed extends Conflict next year and the mapper already handles it: it is a Conflict, so it returns 409. You wrote zero new mapper code. The default => 500 arm catches any domain exception that does not fit a known family, which is itself a signal that you forgot to give that failure a category.
The slug method turns AccountNotFound into account_not_found for a stable machine-readable error code in the JSON body, while the message stays human. Clients switch on error; humans read message.
Wiring it into the framework
In Symfony, an event subscriber catches the exception before it becomes a generic 500.
<?php
declare(strict_types=1);
namespace App\Infrastructure\Http;
use App\Domain\Shared\DomainException;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
final class DomainExceptionSubscriber implements EventSubscriberInterface
{
public function __construct(
private readonly DomainExceptionMapper $mapper,
) {}
public static function getSubscribedEvents(): array
{
return [KernelEvents::EXCEPTION => 'onException'];
}
public function onException(ExceptionEvent $event): void
{
$e = $event->getThrowable();
if (!$e instanceof DomainException) {
return;
}
$event->setResponse($this->mapper->toResponse($e));
}
}
A non-domain exception falls through untouched, so a real bug still surfaces as a 500 and gets logged the way your framework already logs it. Only failures the domain raised on purpose get translated.
Laravel does the same job in App\Exceptions\Handler::render:
public function render($request, \Throwable $e)
{
if ($e instanceof DomainException) {
return $this->mapper->toResponse($e);
}
return parent::render($request, $e);
}
Same mapper, different framework hook. The mapper is the one piece of HTTP knowledge; the framework just decides where to call it.
Why three subtypes is the right number to start with
The temptation is to model every status code. Resist it until a real case forces the addition. Three families cover the bulk of what domain logic produces:
-
NotFound→ 404. The thing you asked for is not there. -
Conflict→ 409. The thing is there, but the state forbids the operation. A closed account, a double-submitted order, an already-used token. -
Validation→ 422. The input or the resulting state breaks a rule. Negative amounts, insufficient funds, a quantity over the limit.
When you genuinely need another, add a sibling. A rate-limit rule that belongs to the domain (not the infrastructure) becomes TooManyAttempts extends DomainException, and you add one arm to the match. Authorization failures usually do not belong here, because "who is allowed to do this" is often an edge concern handled by middleware before the use case runs. Keep the hierarchy to failures the domain itself decides.
What you get for the price
The price is small: one abstract base, three abstract subtypes, one mapper, one framework hook. What it buys:
The domain stays portable. The same transfer method runs under HTTP, in a console command, inside a queue consumer, and in a test harness, and it throws the same exceptions in every context. No transport type leaks into the layer that holds your business rules.
The status-code policy lives in one file. Want every conflict to return 409 with a Retry-After header? You edit the mapper. You do not grep the codebase for throw new HttpException(409.
New failures categorise themselves. A concrete exception extends NotFound, Conflict, or Validation, and the mapper handles it without a change. The type hierarchy carries the routing, so you stop maintaining a giant switch that pairs every exception class with a number.
And tests read in domain language. A unit test asserts $this->expectException(AccountNotFound::class) against the use case, with no HTTP kernel booted. A separate, smaller adapter test confirms that an AccountNotFound comes back as a 404. Two concerns, two test layers, no overlap.
The next time you reach for throw new HttpException(404, ...) inside a service, stop and ask what the domain is actually saying. It is saying something was not found. Let it say that, and let the edge decide what 404 means.
If this was useful
This exception hierarchy is one slice of the larger argument in Decoupled PHP: that the framework should sit at the edge of your application as an adapter, never in the middle as the protagonist. The book walks the same separation through ports, use cases, persistence, and the migration path for Laravel and Symfony codebases that have grown framework roots over the years.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.

Top comments (0)