How I Model Business Rules with Domain Exceptions in Symfony—Safely

Using named domain exceptions to express rejected business rules, roll back commands, cross Symfony Messenger safely, produce RFC 9457 problem responses, and separate expected failures from real incidents.
Ever read a service that returns false, null, or an error string and wondered what exactly went wrong?
Was the user already subscribed? Were they covered by an organization? Was the product unavailable? Did PostgreSQL fail? Did the payment provider time out?
The caller cannot tell. The business language has collapsed into one technical failure.
While building a Symfony platform with several bounded contexts, I chose to model rejected business rules as named domain exceptions. A policy does not throw RuntimeException('Invalid operation'). It throws something such as UserAlreadySubscribed, WeakPassword, or EditorialTransitionNotAllowed.
Those exceptions travel through my application bus, trigger a database rollback, are logged according to their severity, and become safe, translated HTTP problem responses at the presentation boundary.
As a co-founder, this matters to me because exceptions are not only a coding detail. They define how product rules are communicated between domain experts, backend engineers, frontend engineers, support, and observability tools.
There is no such thing as a completely risk-free exception pipeline. An exception can leak sensitive data, be swallowed, generate duplicate alerts, leave partial state behind, or expose an unstable class name as a public contract.
The goal is therefore not “throw everywhere.” The goal is to make failure an intentional part of the model and control exactly how it crosses each boundary.
In this post, I’ll show the generic architecture I use and how you can reproduce it in a Symfony project.
A Rejection Is Part of the Domain Language
Consider this requirement:
A customer cannot start an individual subscription while already covered by an organization subscription.
The negative path is not an implementation accident. It is part of the rule.
Compare these two APIs:
public function subscribe(UserId $userId): bool;/** @throws UserAlreadyCoveredByOrganization */
public function ensureCanStartIndividualSubscription(
UserId $userId,
DateTimeImmutable $now,
): void;The second API preserves the reason. The handler can stop immediately, the transaction can roll back, and the outer layers can translate the same rejection without duplicating the rule.
The exception name becomes part of the ubiquitous language:
Not Every “No” Should Be an Exception
I use two policy styles deliberately.
A decision method returns a value when both outcomes are normal branches:
final readonly class SubscriptionPolicy
{
public function hasAccess(UserId $userId, DateTimeImmutable $now): bool
{
return $this->subscriptions->hasRunningSubscription($userId, $now)
|| $this->memberships->isCovered($userId, $now);
}
}This is useful for rendering a paywall, deciding which action to show, or selecting a workflow.
An assertion method throws when the caller has already chosen an operation and continuing would violate a business rule:
final readonly class SubscriptionPolicy
{
public function ensureCanStartIndividualSubscription(
UserId $userId,
DateTimeImmutable $now,
): void {
if ($this->subscriptions->hasRunningSubscription($userId, $now)) {
throw SubscriptionRuleViolated::userAlreadySubscribed();
}
if ($this->memberships->isCovered($userId, $now)) {
throw SubscriptionRuleViolated::userAlreadyCoveredByOrganization();
}
}
}My naming convention makes the difference visible:
-
can…(),has…(), andis…()answer a question. -
ensure…()either returns normally or raises a precise rejection.
Exceptions are a good fit for aborting a use case. They are a poor fit for ordinary branching inside a loop or for a question callers routinely ask.
Step 1: Define an Explicit User-Facing Failure Contract
I do not expose every DomainException to the user. Only exceptions implementing an explicit marker contract are eligible for presentation:
use Throwable;
interface UserFacingError extends Throwable
{
public function id(): string;
/** @return array<string, scalar|null> */
public function params(): array;
public function translationDomain(): string;
}The contract carries three stable values:
-
idis a machine-readable error and translation identifier; -
paramscontains scalar placeholders for the translated message; -
translationDomainselects the message catalogue.
The PHP exception class is an internal implementation detail. The ID is the stable semantic contract.
I then provide a base class for expected business rejections:
use DomainException;
abstract class UserFacingDomainException extends DomainException implements UserFacingError
{
/** @param array<string, scalar|null> $params */
protected function __construct(
string $internalMessage,
private readonly string $id,
private readonly array $params = [],
private readonly string $translationDomain = 'messages',
) {
parent::__construct($internalMessage);
}
public function id(): string
{
return $this->id;
}
public function params(): array
{
return $this->params;
}
public function translationDomain(): string
{
return $this->translationDomain;
}
}Notice what is not in the contract: an HTTP status code.
The domain knows that a subscription rule was violated. It does not know whether the caller is HTTP, CLI, a message worker, or a test. Mapping the rejection to 409 Conflict belongs to Presentation.
Step 2: Model One Exception per Meaningful Failure Family
Avoid one universal BusinessException whose message changes everywhere. It provides no useful type to catch or test.
At the opposite extreme, one class for every sentence can create hundreds of tiny files. I often group closely related failures under one domain concept and use named constructors:
final class WeakPassword extends UserFacingDomainException
{
public static function tooShort(int $minimumLength): self
{
return new self(
sprintf(
'Password must contain at least %d characters.',
$minimumLength,
),
'identity.exceptions.weak_password.too_short',
['%minimum_length%' => $minimumLength],
);
}
public static function missingUppercase(): self
{
return new self(
'Password must contain at least one uppercase letter.',
'identity.exceptions.weak_password.missing_uppercase',
);
}
public static function missingNumber(): self
{
return new self(
'Password must contain at least one number.',
'identity.exceptions.weak_password.missing_number',
);
}
}Named constructors are preferable to this:
throw new WeakPassword('missing_uppercase', []);They make valid failure variants discoverable, keep identifiers centralized, and prevent callers from constructing half-valid exceptions.
For state transitions, the exception can preserve relevant domain values:
final class AccountTransitionNotAllowed extends UserFacingDomainException
{
public static function lockFrom(AccountStatus $current): self
{
return new self(
sprintf('Cannot lock an account from status "%s".', $current->value),
'identity.exceptions.account_status.lock_from',
[
'%current_status%' => $current->value,
'%expected_status%' => AccountStatus::Active->value,
],
);
}
}The internal message helps engineers and tests. The public message comes from a controlled translation catalogue.
Step 3: Throw from the Object That Owns the Rule
An aggregate owns invariants about its own state:
final class User
{
public function lock(DateTimeImmutable $now): void
{
if ($this->status === AccountStatus::Locked) {
return; // This operation is intentionally idempotent.
}
if ($this->status !== AccountStatus::Active) {
throw AccountTransitionNotAllowed::lockFrom($this->status);
}
$this->status = AccountStatus::Locked;
$this->updatedAt = $now;
$this->recordThat(new UserLocked($this->id, $now));
}
}A policy owns a rule that needs information outside one aggregate:
final readonly class UserUniquenessPolicy
{
public function __construct(
private UserRepository $users,
private EmailValidator $emailValidator,
) {}
public function ensureEmailIsAvailable(EmailAddress $email): void
{
if (! $this->emailValidator->isLegitimate($email)) {
throw DisposableEmailNotAllowed::fromEmail($email);
}
if ($this->users->findByEmail($email) instanceof User) {
throw new EmailAlreadyUsed();
}
}
}Do not move this into the controller because HTTP happens to need an error response. The rule must also hold for console commands, imports, background messages, and tests.
The exception starts where the business fact is known, then travels outward without losing its meaning.
Step 4: Let the Application Handler Orchestrate
The command handler should not translate the exception into HTTP or replace it with a generic failure:
#[AsMessageHandler(bus: 'command.bus')]
final readonly class StartSubscriptionHandler
{
public function __construct(
private SubscriptionPolicy $policy,
private SubscriptionRepository $subscriptions,
private Clock $clock,
) {}
public function __invoke(StartSubscription $command): void
{
$now = $this->clock->now();
$this->policy->ensureCanStartIndividualSubscription(
$command->userId,
$now,
);
$subscription = Subscription::start(
SubscriptionId::new(),
$command->userId,
$now,
);
$this->subscriptions->save($subscription);
}
}No try/catch is required for ordinary propagation. If the policy rejects the command, the rest of the handler does not run.
That gives the application an important ordering rule: validate everything that can be validated before mutating state or calling an external system.
Step 5: Make the Command Transactional by Default
An exception is only safe if partial persistence is controlled.
My command bus runs ordinary handlers inside Doctrine’s transaction middleware:
framework:
messenger:
buses:
command.bus:
middleware:
- App\Shared\Infrastructure\Bus\CorrelationMiddleware
- App\Shared\Infrastructure\Bus\TransactionMiddlewareConceptually, the middleware does this:
$entityManager->wrapInTransaction(function () use ($next, $envelope): void {
$next->handle($envelope);
});If a domain exception escapes, Doctrine rolls back changes made during that command. The exception continues upward after rollback.
This protects database state. It cannot undo an email already sent, a file already uploaded, or a payment already captured. Keep irreversible I/O outside the database transaction or use an outbox, idempotency keys, and compensating actions.
Commands that intentionally opt out of the default transaction must document how they handle partial failure. “Non-transactional” is not a performance flag; it transfers consistency responsibility to the use case.
Step 6: Unwrap Symfony Messenger without Losing the Original Exception
Symfony Messenger wraps handler failures in HandlerFailedException. If that wrapper reaches Presentation, my domain type becomes harder to recognize and tests become coupled to Messenger.
My bus adapter unwraps nested wrappers and rethrows the original object:
final class SymfonyCommandBus implements CommandBus
{
use HandleTrait {
HandleTrait::handle as messengerHandle;
}
public function handle(object $command): mixed
{
try {
return $this->messengerHandle($command);
} catch (HandlerFailedException $failure) {
$throwable = $this->unwrap($failure);
$this->failureLogger->command($command, $throwable);
throw $throwable;
}
}
private function unwrap(HandlerFailedException $failure): Throwable
{
$throwable = $failure;
while (
$throwable instanceof HandlerFailedException
&& $throwable->getPrevious() instanceof Throwable
) {
$throwable = $throwable->getPrevious();
}
return $throwable;
}
}The same adapter pattern is used for synchronous queries.
Rethrowing the original exception matters. Creating a new one loses object identity, may truncate the causal chain, and can defeat once-only logging.
Step 7: Separate Expected Rejections from Incidents
An already-used email is useful business information. A database connection failure is an operational incident. Both are exceptions, but they should not trigger the same alert.
I classify severity centrally:
final readonly class FailureLogger
{
public function __construct(
private LoggerInterface $logger,
private LoggedExceptionRegistry $registry,
) {}
public function command(object $command, Throwable $throwable): void
{
if ($this->registry->contains($throwable)) {
return;
}
$this->registry->mark($throwable);
$context = [
'messageClass' => $command::class,
'exception' => $throwable,
'exceptionClass' => $throwable::class,
];
if ($throwable instanceof DomainException) {
$this->logger->warning(
'Command handling rejected by an expected application rule',
$context,
);
return;
}
$this->logger->critical(
'Command handling failed unexpectedly',
$context,
);
}
}Expected does not mean invisible. A spike in subscription conflicts or rejected editorial transitions can reveal product friction or abusive behavior. I keep the record, but it does not page the on-call engineer like an infrastructure failure.
The log contains the exception object for stack traces and its class for structured searching. Keep additional context small and business-relevant. Never log passwords, access tokens, payment credentials, uploaded private content, or raw personal data merely because they were present in the command.
Step 8: Prevent Duplicate Logs with Object Identity
A nested command may cross multiple bus adapters. A handler may add useful context, log, and rethrow. Symfony may log the final kernel exception again.
Without coordination, one failure produces several alerts.
I track exception objects already logged during the request:
final class LoggedExceptionRegistry
{
/** @var WeakMap<Throwable, true> */
private WeakMap $exceptions;
public function __construct()
{
$this->exceptions = new WeakMap();
}
public function contains(Throwable $throwable): bool
{
return isset($this->exceptions[$throwable]);
}
public function mark(Throwable $throwable): void
{
$this->exceptions[$throwable] = true;
}
}WeakMap is useful here because it keys by object identity without keeping exception objects alive unnecessarily.
A Monolog processor also marks any exception already present in a log record and normalizes severity:
if ($exception instanceof UserFacingError || $exception instanceof DomainException) {
return $record->with(level: Level::Warning);
}
return $record->with(level: Level::Critical);This creates one consistent rule whether the exception was first logged by a handler, a bus adapter, or the framework.
Step 9: Translate Only Explicitly Safe Exceptions to HTTP
The controller remains thin:
#[Route('/subscriptions', methods: ['POST'])]
final class StartSubscriptionController extends AbstractController
{
#[Serialize(code: Response::HTTP_CREATED)]
public function __invoke(#[MapRequestPayload] StartSubscriptionInput $input): array
{
$this->handleCommand(new StartSubscription(
userId: $this->currentUserId(),
productId: new ProductId($input->productId),
));
return [];
}
}A single kernel exception listener owns the public translation:
#[AsEventListener(KernelEvents::EXCEPTION)]
final readonly class UserFacingErrorListener
{
public function __construct(
private TranslatorInterface $translator,
) {}
public function __invoke(ExceptionEvent $event): void
{
if (! $event->isMainRequest()) {
return;
}
$throwable = $this->unwrap($event->getThrowable());
if (! $throwable instanceof UserFacingError) {
return;
}
$status = $this->statusCode($throwable);
$detail = $this->translator->trans(
$throwable->id(),
$throwable->params(),
$throwable->translationDomain(),
);
$event->setResponse(new JsonResponse([
'type' => $this->problemType($throwable->id()),
'title' => Response::$statusTexts[$status] ?? 'Error',
'status' => $status,
'detail' => $detail,
'instance' => $event->getRequest()->getRequestUri(),
], $status, [
'Content-Type' => 'application/problem+json',
]));
}
}This is the main disclosure boundary.
If the exception does not implement UserFacingError, the listener does nothing. Symfony’s production error handler returns a generic 500 response while observability retains the internal stack trace.
Never serialize $throwable->getTrace(), $throwable->getFile(), database messages, previous exceptions, or arbitrary exception properties into the response.
Step 10: Map Domain Families to HTTP Semantics
The domain exception should not carry HTTP status. The listener maps semantic families:
private function statusCode(UserFacingError $error): int
{
return match (true) {
$error instanceof HumanVerificationFailed => 403,
$error instanceof EntityNotFound => 404,
$error instanceof InvalidInput => 422,
default => 409,
};
}My generic mapping is:
-
404 Not Foundwhen a requested domain entity does not exist; -
422 Unprocessable Contentwhen the input is structurally valid but unacceptable; -
403 Forbiddenfor a failed security gate where revealing more is safe; -
409 Conflictfor a business operation that conflicts with current domain state; -
500 Internal Server Errorfor unexpected technical failures.
You may choose a different mapping. Consistency matters more than pretending HTTP status codes can express the whole domain. The stable problem type and detail carry the more precise meaning.
For authorization, be careful with 403 versus 404. Returning 404 can avoid confirming that a resource exists when the caller is not allowed to know.
Step 11: Translate Public Messages instead of Exposing Internal Ones
The exception ID maps to a translation catalogue:
identity.exceptions.email_already_used: >-
This email address is not available.
identity.exceptions.weak_password.too_short: >-
The password must contain at least %minimum_length% characters.
access.exceptions.user_already_covered_by_organization: >-
Your organization subscription already covers this account.The API response is an RFC 9457-style problem document:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json{
"type": "https://api.example.test/problems/access/user-already-covered-by-organization",
"title": "Conflict",
"status": 409,
"detail": "Your organization subscription already covers this account.",
"instance": "/api/subscriptions"
}Translation IDs provide several advantages:
-
public wording can change without modifying domain behavior;
-
internal diagnostic messages can remain precise;
-
multiple locales share one semantic error;
-
and the frontend receives a consistent problem shape.
Parameters must be allow-listed scalar values. Do not pass an entire entity, request payload, provider response, or exception into translation parameters.
If a translation is missing, use a curated generic public fallback. Falling back to getMessage() is safe only when the UserFacingError contract guarantees that every implementing exception contains a reviewed, non-sensitive message.
Step 12: Consume the Problem Safely on the Frontend
The frontend validates the problem document like any other unknown network data:
const apiProblemSchema = z.looseObject({
type: z.string().optional(),
title: z.string().optional(),
status: z.number().optional(),
detail: z.string().optional(),
instance: z.string().optional(),
violations: z
.array(
z.object({
propertyPath: z.string(),
title: z.string(),
}),
)
.optional(),
});
export function getErrorMessage(
error: unknown,
fallback = "Unable to complete the request.",
) {
if (!isAxiosError(error)) {
return fallback;
}
const result = apiProblemSchema.safeParse(error.response?.data);
return result.success
? result.data.detail ?? fallback
: fallback;
}A mutation can then display the safe backend message:
useMutation({
mutationFn: startSubscription,
onError(error) {
toast.error(getErrorMessage(error));
},
});Do not branch product behavior by matching translated sentences. If the frontend must react differently—open an upgrade screen, redirect to login, highlight a field—expose a stable machine-readable problem type or error ID.
Step 13: Catch Only When You Can Change the Outcome
Most domain exceptions should bubble. Catching is appropriate when the current layer can do something meaningful.
Make an Operation Idempotent
Deleting a missing recent search can be considered successful:
try {
$recentSearch = $this->recentSearches->get($command->id);
$this->recentSearches->remove($recentSearch);
} catch (RecentSearchNotFound) {
// The requested final state already exists.
}This is a product decision, not a blanket rule that not-found exceptions should be ignored.
Translate an Infrastructure Failure
An adapter can preserve the cause while returning an application-level failure:
try {
$profile = $provider->fetchProfile($authorizationCode);
} catch (ProviderSdkException $previous) {
throw FederatedProviderAuthenticationFailed::fromProvider($previous);
}The application no longer depends on the vendor SDK type, and logs still contain the causal chain.
Return a Protocol-Specific Result
An OAuth callback may need to redirect instead of returning JSON. Its handler can convert known failures into a small allow-listed error code while logging unexpected failures internally.
Add Context and Rethrow
If a handler knows identifiers that will materially improve diagnosis, it may log them and rethrow the same object:
} catch (Throwable $throwable) {
$this->logger->critical('Unable to update policy document', [
'documentId' => (string) $command->documentId,
'exception' => $throwable,
'exceptionClass' => $throwable::class,
]);
throw $throwable;
}The once-only registry prevents the bus from logging it again.
Do not catch merely to write throw $exception;, return false, or replace a precise domain exception with SomethingWentWrong.
When catching technical failures, catch Throwable, not only Exception, if the intention is cleanup, logging, or boundary translation for all PHP failures. Always rethrow unless the catch block deliberately owns recovery.
Step 14: Keep Security-Sensitive Rejections Deliberately Vague
Not every true reason should reach the caller.
During authentication, these internal states may all produce the same public response:
-
the email does not exist;
-
the password is incorrect;
-
the account is archived;
-
the account is locked;
-
or the federated identity is not connected.
Returning the exact exception would create an account-enumeration channel.
The domain can still model precise failures for auditing and internal decisions, while the authentication boundary maps them to one public message:
{
"type": "https://api.example.test/problems/authentication-failed",
"title": "Unauthorized",
"status": 401,
"detail": "Invalid credentials."
}Safe exception handling means deciding which distinctions belong to the user and which belong only to logs.
Apply the same thinking to ownership checks, password reset, invitations, payments, document visibility, and rate limits.
Step 15: Remember Concurrency and External Side Effects
A uniqueness policy usually performs a check before creation:
if ($this->users->findByEmail($email) instanceof User) {
throw new EmailAlreadyUsed();
}Two concurrent requests can both pass that check. The database must still enforce a unique constraint:
CREATE UNIQUE INDEX uniq_users_email ON users (email);The policy expresses intent and gives the normal conflict a good name. The constraint closes the race.
If consistent UX under that race matters, catch only the known unique-constraint violation at the persistence or application boundary and translate it to EmailAlreadyUsed. Do not convert every database exception into a domain conflict.
Likewise, a database rollback cannot retract an external payment or email. Use the appropriate reliability pattern:
-
transactional outbox for events and email dispatch;
-
idempotency keys for payment or provider requests;
-
explicit transaction runners around the state-changing unit;
-
compensation when an external side effect cannot be delayed;
-
and retry policies based on failure type.
For asynchronous Messenger handlers, an exception no longer becomes an HTTP response. Decide whether each failure is retryable, unrecoverable, or should move to a failure transport. Retrying an expected domain rejection three times rarely helps.
Step 16: Test Rejections as First-Class Behavior
Exception architecture needs tests at several levels.
Unit-Test the Policy Vocabulary
#[DataProvider('weakPasswords')]
public function testItRejectsWeakPasswords(
string $password,
string $expectedErrorId,
): void {
try {
$this->policy->ensureSatisfiedBy(new PlainPassword($password));
} catch (WeakPassword $error) {
self::assertSame($expectedErrorId, $error->id());
return;
}
self::fail('Weak password was accepted.');
}Test the exact variant, ID, and parameters when they are part of the contract.
Test through the Application Use Case
Scenario: Rejecting a registration with an email already used
Given a registered user exists with email "jane@example.test"
When another user registers with email "jane@example.test"
Then registration should be rejected because the email is already used
And no second account should be registeredThis proves the handler actually invokes the policy and that the transaction leaves no partial state.
Test the Bus Adapter
Verify that a wrapped handler failure is unwrapped, logged once, and rethrown as the same exception object.
Test the HTTP Boundary
For each semantic family, assert status, content type, stable problem type, translated detail, and absence of stack traces or private fields.
Also test an unexpected RuntimeException and confirm the production response is generic while the internal log is critical.
Test Observability Classification
Expected domain rejection should be a warning. Unexpected infrastructure failure should be critical. An already logged exception should not create another record.
Common Exception-Modeling Mistakes
Throwing Generic Exceptions
throw new RuntimeException('Invalid operation') discards the business reason and makes safe translation impossible.
Putting HTTP Status Codes in the Domain
The domain expresses business semantics. Presentation decides HTTP semantics.
Exposing Every Domain Exception
Some failures reveal account existence, ownership, internal workflow state, or security policy. Require an explicit user-facing marker and review its message and parameters.
Catching in Every Handler
Catch only for recovery, translation, compensation, protocol conversion, cleanup, or genuinely useful context. Otherwise, let the failure bubble.
Logging Expected Rejections as Critical
This creates alert fatigue and hides real incidents. Keep expected rejection telemetry, but classify it separately.
Logging the Same Exception at Every Layer
Log once with the best available context. Preserve the same object while rethrowing and use request-scoped deduplication when several boundaries may observe it.
Relying on a Policy Check for Uniqueness
Check-then-insert races exist. Back uniqueness with a database constraint.
Returning Translated Messages as Machine Codes
Wording changes. Use a stable problem type or error ID for frontend decisions.
Retrying Every Exception
Technical timeouts may be retryable. A weak password or forbidden state transition is not.
Swallowing Throwable
If the catch block does not deliberately recover, rethrow. Silent failure is usually riskier than propagation.
Wrapping Up
Domain exceptions work well when they are treated as a designed failure model, not as an escape hatch.
-
Policies return booleans for normal decisions and throw from
ensure…()methods when a chosen operation violates a rule. -
Aggregates throw named exceptions for invalid state transitions.
-
A user-facing marker explicitly controls which failures may leave the backend.
-
Stable error IDs and scalar parameters separate business meaning from public wording.
-
Application handlers orchestrate and normally let precise failures bubble.
-
Transaction middleware rolls back database changes when a command fails.
-
Bus adapters unwrap Messenger failures and preserve the original exception.
-
Expected rejections are logged as warnings; unexpected failures are critical.
-
A
WeakMapregistry prevents the same exception from being logged repeatedly. -
One Symfony listener translates safe exceptions into consistent RFC 9457 problem responses.
-
Unexpected exceptions remain private and become generic
500responses. -
Security-sensitive workflows intentionally collapse internal reasons into safer public messages.
-
Database constraints, idempotency, outboxes, and retry policies handle risks exceptions alone cannot solve.
The most important rule is simple: let the domain name the failure, but let each outer boundary decide what it is allowed to do with that knowledge.
That gives me expressive policies, atomic commands, useful logs, predictable frontend errors, and a much smaller chance of leaking the wrong detail to the wrong audience.
Happy coding!