Use when creating domain exception classes, structuring exception hierarchies for a module, or adding application-layer exceptions for use case errors.
Every module has a two-level exception tree rooted at {Entity}Exception. All exception classes are empty-bodied — naming alone conveys intent. Each module owns its own tree; never extend another module's exception base.
Exception (PHP built-in)
└── {Entity}Exception # Module root — always
├── {Entity}NotFound # Resource lookup failure — always
├── {Entity}OwnershipError # Authorization violation — when needed
├── Invalid{Concept}Exception # Validation/invariant failure — when needed
├── {Entity}AlreadyExists # Uniqueness violation — when needed
└── {Entity}{Action}Error # Specific domain error — when needed
One per module, extends \Exception directly:
declare(strict_types=1);
namespace App\CustomerRelationshipManagement\Contacts\Domain\Exceptions;
use Exception;
class ContactException extends Exception {}
Rules:
{Entity}Exception matching the aggregate root\Exception — never another module's exception{Module}/Domain/Exceptions/All extend the module's base exception. All have empty bodies.
class ContactNotFound extends ContactException {}
Every module needs this. Thrown by finder use cases when the repository returns null.
class ContactOwnershipError extends ContactException {}
Thrown by use cases that enforce resource ownership (e.g., "you can only manage your own contacts").
class InvalidVisaSubclass extends VisaException {}
Thrown by value object constructors and entity methods when input violates domain invariants. Named Invalid{Concept}Exception or Invalid{Concept}.
class PackageAlreadyExists extends PackageException {}
Thrown when a uniqueness constraint is violated at the domain level.
class DocumentStoreError extends DocumentException {}
class AccountInvitationError extends AccountException {}
Named descriptively for the specific failure. Use Error or Exception suffix consistently within a module.
Use case errors that don't belong to the domain live in {Module}/Application/Exceptions/:
declare(strict_types=1);
namespace App\IdentityAndAccess\Users\Application\Exceptions;
use Exception;
class InvalidUserPassword extends Exception {}
Rules:
\Exception directly — they root their own small treepublic function __construct(string $abn)
{
$this->abn = str_replace(' ', '', $abn);
if (! $this->isValidFormat()) {
throw new InvalidABNException("Invalid ABN format {$this->abn}");
}
}
public function addContact(array $attributes): self
{
if ($this->contacts()->count() >= 10) {
throw new CompanyException('Maximum 10 contacts allowed.');
}
$this->contacts()->create($attributes);
return $this;
}
public function __invoke(string $id): Contact
{
return Contact::find($id)
?? throw new ContactNotFound(strtr('Contact {id} not found.', ['{id}' => $id]));
}
Use strtr() with token substitution for messages containing identifiers:
throw new AccountNotFound(strtr('Account {id} not found.', ['{id}' => $id]));
Keep messages business-oriented and descriptive
Always declare thrown exceptions in method docblocks:
/**
* @throws ContactNotFound
*/
| Mistake | Fix |
|---|---|
| Adding methods or properties to exceptions | Keep all exception classes empty-bodied |
| Extending another module's base exception | Each module owns its own {Entity}Exception tree |
Putting all exceptions under \Exception directly | Leaf exceptions extend the module's base, not \Exception |
| Domain validation errors in Application layer | Invalid{Concept} exceptions belong in Domain/Exceptions/ |
| Generic exception messages without identifiers | Use strtr() with tokens for actionable messages |
Missing @throws docblock declarations | Always declare thrown exceptions in PHPDoc |
{Entity}Exception extends \Exception directly?{Entity}NotFound extends the module's base exception?Application/Exceptions/ if needed?{Module}/Domain/Exceptions/?@throws declarations on methods that throw?