Exceptions are part of your domain language
A generic InvalidArgumentException tells you nothing about the business. It says an argument was wrong, but not which rule was broken or in what context. By the time you read it in a log, you are reverse-engineering intent from a stack trace.
The problem
When you throw InvalidArgumentException or RuntimeException from inside an entity, you flatten two very different kinds of failure into one. A book that cannot be borrowed because it is already out on loan is a business rule. A null where an object was expected is a programming bug. Both surface as the same generic type, so the layer above cannot tell them apart without inspecting the message string.
That string-matching is where things rot. Someone writes if (str_contains($e->getMessage(), 'not available')) in a controller, and the day the message wording changes, the branch silently stops firing. The error message was never meant to be an API, but you turned it into one.
The mechanism
In Phexium, business failures are named after the rule they enforce, and the entity that owns the rule is the one that throws. The Book entity decides whether it can be borrowed, so it is the Book entity that raises the exception:
namespace AppDemo\Library\Domain;
use AppDemo\Library\Domain\Exception\BookNotAvailableException;
final class Book
{
public function markAsBorrowed(): void
{
if (!$this->status->canBeBorrowed()) {
throw BookNotAvailableException::forBorrowing($this->id);
}
$this->status = BookStatus::Borrowed;
}
}
The exception itself carries no logic. It uses named constructors so the call site reads as a sentence, and so the same failure can be raised from more than one situation without overloading a constructor:
namespace AppDemo\Library\Domain\Exception;
use AppDemo\Shared\Domain\Exception\DomainException;
use Phexium\Domain\Id\IdInterface;
final class BookNotAvailableException extends DomainException
{
public static function forBorrowing(IdInterface $bookId): self
{
return new self(sprintf('Book with ID %s is not available for borrowing', $bookId->getValue()));
}
public static function forReturning(IdInterface $bookId): self
{
return new self(sprintf('Book with ID %s is not borrowed and cannot be returned', $bookId->getValue()));
}
}
Every domain exception extends a shared base that carries a context array, not just a message:
namespace Phexium\Domain\Exception;
abstract class AbstractDomainException extends \DomainException implements DomainExceptionInterface
{
public function __construct(
string $message = '',
private readonly array $context = [],
int $code = 0,
?\Throwable $previous = null,
) {
parent::__construct($message, $code, $previous);
}
public function getContext(): array
{
return $this->context;
}
public function toArray(): array
{
return [
'message' => $this->getMessage(),
'context' => $this->getContext(),
'code' => $this->getCode(),
'file' => $this->getFile(),
'line' => $this->getLine(),
];
}
}
Because the type carries meaning, the presentation layer can catch exactly what it expects and route it as a user-facing error, leaving real bugs to propagate:
try {
$this->commandBus->dispatch($command);
return $this->redirectTo($response, '/loans/my');
} catch (BookNotAvailableException|InvalidArgumentException $e) {
$errors['book_id'] = $e->getMessage();
}
The same pattern shows up wherever a rule has context worth keeping. LoanNotOwnedByUserException::forLoanAndUser($loanId, $userId) names the violation and pins both identifiers to it, so the log line answers "which loan, which user" without a debugger.
Trade-offs
This costs you classes. One named exception per business rule that can fail means more files, and on a small project that overhead can outweigh the benefit. If a failure is genuinely a programming error rather than a domain rule, a generic exception is the honest choice. Naming everything would just bury the distinction you were trying to make.
There is also discipline involved. A named exception is only useful if the entity, not the handler, owns the rule that raises it. Move the check up into the application layer and you are back to an anemic domain with the exceptions in the wrong place.
What this enables
You can catch by type instead of parsing strings, so error handling stops breaking when wording changes. The stack trace names the failed rule before you open a single file, which shortens the path from log line to cause. And because each exception transports a context array, logging and presentation work from structured data rather than a sentence someone has to decode. The exception class becomes part of the vocabulary the codebase already speaks.