Modeling business rules with domain exceptions in Symfony
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.
Why reading the system clock inside business logic creates nondeterministic behavior, and how an explicit Clock port makes expiry, validity, scheduling, persistence, and time-sensitive tests predictable.
How to build a durable payment workflow around FlexPay using Orders, Payment Transactions, signed webhooks, idempotent fulfillment, and a migration path from a legacy billing model.
A practical testing architecture for combining fast domain tests, executable Behat application specifications, in-memory ports, deterministic time, real Symfony boundaries, and focused infrastructure tests.
Read
Need a software engineer?
Send the goal, stack, and timeline. I'll reply with a next step.
A service that returns false, null, or a generic error string hides why an operation failed.
PHP
if(!$subscriptionService->subscribe($user)){returnnewJsonResponse(['error'=>'Unable to subscribe'],400);}
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 not to throw exceptions everywhere. It is to model expected failures and control how they cross each boundary.
The examples implement that failure model in a generic Symfony project.
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:
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.
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:
PHP
finalclassWeakPasswordextendsUserFacingDomainException{publicstaticfunctiontooShort(int$minimumLength):self{returnnewself(sprintf('Password must contain at least %d characters.',$minimumLength,),'identity.exceptions.weak_password.too_short',['%minimum_length%'=>$minimumLength],);}publicstaticfunctionmissingUppercase():self{returnnewself('Password must contain at least one uppercase letter.','identity.exceptions.weak_password.missing_uppercase',);}publicstaticfunctionmissingNumber():self{returnnewself('Password must contain at least one number.','identity.exceptions.weak_password.missing_number',);}}
Named constructors are preferable to this:
PHP
thrownewWeakPassword('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:
PHP
finalclassAccountTransitionNotAllowedextendsUserFacingDomainException{publicstaticfunctionlockFrom(AccountStatus$current):self{returnnewself(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.
finalclassUser{publicfunctionlock(DateTimeImmutable$now):void{if($this->status===AccountStatus::Locked){return;// This operation is intentionally idempotent.}if($this->status!==AccountStatus::Active){throwAccountTransitionNotAllowed::lockFrom($this->status);}$this->status=AccountStatus::Locked;$this->updatedAt=$now;$this->recordThat(newUserLocked($this->id,$now));}}
A policy owns a rule that needs information outside one aggregate:
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.
If a domain exception escapes, Doctrine rolls back changes made during that command. The exception continues upward after rollback.
Mermaid
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.
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:
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:
PHP
finalreadonlyclassFailureLogger{publicfunction__construct(privateLoggerInterface$logger,privateLoggedExceptionRegistry$registry,){}publicfunctioncommand(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($throwableinstanceofDomainException){$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.
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:
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.
404 Not Found when a requested domain entity does not exist;
422 Unprocessable Content when the input is structurally valid but unacceptable;
403 Forbidden for a failed security gate where revealing more is safe;
409 Conflict for a business operation that conflicts with current domain state;
500 Internal Server Error for 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.
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:
{"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.
Do not branch product behavior by matching translated sentences. If the frontend must open an upgrade screen, redirect to login, or highlight a field, expose a stable machine-readable problem type or error ID.
Deleting a missing recent search can be considered successful:
PHP
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.
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.
If a handler knows identifiers that will materially improve diagnosis, it may log them and rethrow the same object:
PHP
}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.
Two concurrent requests can both pass that check. The database must still enforce a unique constraint:
SQL
CREATEUNIQUEINDEX 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.
#[DataProvider('weakPasswords')]publicfunctiontestItRejectsWeakPasswords(string$password,string$expectedErrorId,):void{try{$this->policy->ensureSatisfiedBy(newPlainPassword($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.
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 registered
This proves the handler actually invokes the policy and that the transaction leaves no partial state.
Expected domain rejection should be a warning. Unexpected infrastructure failure should be critical. An already logged exception should not create another record.
Some failures reveal account existence, ownership, internal workflow state, or security policy. Require an explicit user-facing marker and review its message and parameters.
Log once with the best available context. Preserve the same object while rethrowing and use request-scoped deduplication when several boundaries may observe it.
The domain names each failure. The transaction layer decides rollback behavior, observability decides severity, and the HTTP boundary decides which details are safe to publish. That separation keeps policies precise without exposing internal exceptions as a public API.