π§° Correlate Domain Exceptions with Object Properties
A library that captures validation exceptions and maps them to validated object properties.
No longer do you need custom validators in your object
nor any validation in application/ui layers.
Instead, declaratively relate domain exceptions with their relevant form fields
and yield validation failed response as you do normally.
It's not a validation library. Not ever intended to be.
It doesn't provide validation rules, constraints, or validators.
Instead, it provides exception handling functionality, specific to a validation task.
You can validate business logic with any third-party library (or even plain PHP),
while the library will be correlating these validation exceptions to the specific properties
whose invalid values caused them.
It's not a strict requirement to use Symfony Validator as a validation component,
though this library integrates it well.
Ordinarily, validation flows through two different layers:
- HTTP/form level;
- domain layer.
It leads to duplication and potential inconsistencies of validation rules.
The traditional validation uses an attribute-based approach,
which strips the domain layer from most business logic.
Besides that, any custom validation you'd normally implement in a service
must be wrapped in a custom validator attribute and moved away from the service.
It's all for the sake of being able to display a nice validation message on the form.
Thus, the domain services and model end up naked,
all business rules having been leaked elsewhere.
On the other hand, it's a common practice in DDD for domain objects to be responsible for their own validation rules.
Emailvalue object validates its own format and naturally throws an exception that represents validation failure.RegisterUserServicenormally verifies email is not yet taken and naturally throws an exception.
That is the kind of code that utterly expresses the model of the business,
which should not be stripped down.
Yet, with a domain-driven approach, it's not possible to use standard validation tools,
as these drain domain from all logic.
How then do we show contextual validation errors to the users?
It's a task of relating thrown exception with the property which value caused this exception.
To return a neat json-response with email as a property path and validation error description,
it's necessary to match EmailAlreadyTakenException with a $email property of the original RegisterUserCommand.
This is what Exceptional Validation was designed for.
Capturing exceptions like EmailValidationFailedException and mapping them to the particular form fields as $email,
you maintain a single source of truth for the domain validation logic.
Domain enforces its invariants through value objects and services,
while this library ensures that validation failures will properly appear in your forms and API responses.
Exceptional Validation:
- Eliminates duplicate validation across HTTP/application and domain layers;
- Keeps business rules where they belong β in the domain;
- Makes validation logic easily unit-testable;
- Reduces complexity of nested validation scenarios;
- Eliminates the need for validation groups and custom validators.
-
Install via composer:
composer require phphd/exceptional-validation
-
Enable bundles in the
bundles.php:PhPhD\ExceptionalValidation\Bundle\PhdExceptionalValidationBundle::class => ['all' => true], PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],
Note:
PhdExceptionToolkitBundleis a required dependency
that provides exception unwrapping needful for this library.
Mark a message with #[ExceptionalValidation] attribute.
It's used by mapper to include this object for processing.
Then, define #[Capture] exception mappings on your properties.
These declaratively describe what exceptions correlate to what properties:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
#[ExceptionalValidation]
class RegisterUserCommand
{
#[Capture(LoginAlreadyTakenException::class, 'auth.login.already_taken')]
public string $login;
#[Capture(WeakPasswordException::class, 'auth.password.weak')]
public string $password;
}Here, we say that LoginAlreadyTakenException is related to login property,
while WeakPasswordException is related to password property.
The actual mapping takes place when the mapper is used:
use PhPhD\ExceptionalValidation\Mapper\ExceptionMapper;
/** @var ExceptionMapper<ConstraintViolationListInterface> $mapper */
try {
$command = new RegisterUserCommand($login, $password);
$this->service->register($command);
} catch (DomainException $exception) {
$violationList = $mapper->map($command, $exception);
return new JsonResponse($violationList, 422);
}Each exception, when mapped, results in a ConstraintViolation object,
which contains a property path, and an exception message translation.
You can use it to render form with validation errors or serialize these into a json-response.
Note that the default messages translation domain is
validators,
being inherited fromvalidator.translation_domainparameter.You can change it by setting
phd_exceptional_validation.translation_domainparameter.
You might be wondering why we wouldn't just use simple validation asserts right in the command?
This is a logical question. A simple answer is that this's not always convenient / best.
For example, let's take the same RegisterUserCommand as used before.
A comparison of the approaches would look something like this:
+#[ExceptionalValidation]
class RegisterUserCommand
{
- #[App\Assert\UniqueLogin]
+ #[Capture(LoginAlreadyTakenException::class, 'auth.login.already_taken')]
public string $login;
- #[Assert\PasswordStrength(minScore: 2)]
+ #[Capture(WeakPasswordException::class, 'auth.password.weak')]
public string $password;
}The main difference between the two is that standard validation runs before your actual business logic.
This alone means that for every domain-specific rule like "login must be unique" it's necessary to create
a custom validation constraint and a validator that implements this business logic.
Thereby, the main problem with the standard approach is that domain leaks into validators.
That code, which you would've normally implemented in the service, you are obliged to wrap into the validator.
One more point is that oftentimes there are multiple actions that use the same validations.
For example, login uniqueness is validated both during registration and during profile update.
Even though a "login is unique" rule is conceptually obvious,
a validator approach is fraught with problems to check that a user's own login isn't taken into account when validating.
Exceptional validation doesn't force you to write business logic in any validators.
Instead, you can throw an instance of exception in whatever scenario you would like to,
and then the library will retroactively analyse it.
Another example is a password validation, which's used both during registration and during password reset.
Using the validation attributes results in duplicated asserts between the two,
while this business conceptually belongs to Password,
which most properly would be represented as a value object, used in both actions.
With exceptional validation you just write business logic in your domain and then retroactively map violations.
Retroactively β after your business logic has worked out.
Representation of the errors to the user is separate from the business logic concern which's managed by this library.
Finally, this approach gives a lot of flexibility,
removing the need for custom validators, validation groups, duplicate validation rules,
allowing you to keep the domain code in the domain objects,
resulting in a better design of the system.
Focus on the domain and let the library take care of the exception representation:
// RegisterUserService
if ($this->userRepository->loginExists($command->login)) {
throw new LoginAlreadyTakenException($command->login);
}If you are using Symfony Messenger as a Command Bus,
it's recommended to use this package
as Symfony Messenger Middleware.
If you are not using
Messengercomponent, you can still leverage features of this library,
as it provides a rigorously structured set of tools w/o depending on any particular implementation.
Installation of third-party dependencies is optional β they won't be installed unless you need it.
Add phd_exceptional_validation middleware to the list:
framework:
messenger:
buses:
command.bus:
middleware:
- validation
+ - phd_exceptional_validation
- doctrine_transactionOnce you have done this, the middleware will take care of capturing exceptions and re-throwing
ExceptionalValidationFailedException.
You can use it to catch and process it:
$command = new RegisterUserCommand($login, $password);
try {
$this->commandBus->dispatch($command);
} catch (ExceptionalValidationFailedException $exception) {
$violationList = $exception->getViolationList();
return $this->render('registrationForm.html.twig', ['errors' => $violationList]);
} This exception just wraps respectively mapped ConstraintViolationList with all your messages and property paths.
Primarily, it works as a Command Bus middleware that intercepts exceptions and uses exception mapper to perform their mapping to the relevant form properties, eventually formatting captured exceptions as standard SF Validator violations.
Besides that,
ExceptionMapperis also available for direct use w/o any middleware. You can reference it asExceptionMapper<ConstraintViolationListInterface>service.
This diagram represents the concept:
It's possible to use features of this bundle without necessarily depending on Command Bus middleware, nor on the Messenger component.
If you're using Symfony, you can check what exception mappers are available using this command:
bin/console debug:container ExceptionMapperThis should provide you with a list, similar to this:
[0] PhPhD\ExceptionalValidation\Mapper\ExceptionMapper<PhPhD\ExceptionalValidation\Rule\Exception\MatchedExceptionList>
[1] PhPhD\ExceptionalValidation\Mapper\ExceptionMapper<Symfony\Component\Validator\ConstraintViolationListInterface>
These mappers allow you to map the Exception to any available format, specified as a generic parameter.
It could be ConstraintViolationList, or a list of MatchedException, or anything else.
Therefore, you can inject the needed service into your own code:
use PhPhD\ExceptionalValidation\Mapper\ExceptionMapper;
use Symfony\Component\Validator\ConstraintViolationListInterface;
class SignDocumentActivity
{
public function __construct(
/** @var ExceptionMapper<ConstraintViolationListInterface> */
#[Autowire(service: ExceptionMapper::class.'<'.ConstraintViolationListInterface::class.'>')]
private ExceptionMapper $exceptionMapper,
) {
}
public function sign(SignCommand $command): string
{
try {
return $command->process();
} catch (DomainException $e) {
/** @var ConstraintViolationListInterface $violationList */
$violationList = $this->exceptionMapper->map($message, $e);
throw new ApplicationFailure(
'Validation Failed',
$this->encode($violationList),
previous: $e,
);
}
}
}In this example, we use ExceptionMapper to relate the caught exception to some property of the $message,
producing ConstraintViolationListInterface that can be used however you want to.
If you are not using Symfony framework, you can still take advantage of this library.
Create a Service Container (symfony/dependency-injection is required) with a DI Extension
and then use it to create necessary services:
use PhPhD\ExceptionalValidation\Bundle\DependencyInjection\PhdExceptionalValidationExtension;
$container = (new PhdExceptionalValidationExtension())->getContainer([
'kernel.environment' => 'prod',
'kernel.build_dir' => __DIR__.'/var/cache',
]);
$container->compile();
/** @var ExceptionMapper<ConstraintViolationListInterface> $mapper */
$mapper = $container->get(ExceptionMapper::class.'<'.ConstraintViolationListInterface::class.'>');Herein, you create a Container, compile it, and use it to retrieve ExceptionMapper.
#[ExceptionalValidation] and #[Capture] attributes allow you to implement very flexible mappings.
Here are the examples of how you can use them.
A minimum required condition.
Matches the exception by its class name using instanceof check,
making it similar to catch operation.
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
#[ExceptionalValidation]
class PublishMessageCommand
{
#[Capture(MessageNotFoundException::class)]
public string $messageId;
}Besides filtering by exception class,
it's possible to filter by the origin class and method name
whence the exception raised from.
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
use Symfony\Component\Uid\Uuid;
#[ExceptionalValidation]
class ConfirmPackageCommand
{
#[Capture(\InvalidArgumentException::class, from: [Uuid::class, 'fromString'])]
public string $uid;
}In this example InvalidArgumentException is a generic one, which can originate from multiple places.
To catch only those exceptions that belong to Uuid class, from: clause specifies class and method names.
Thus, Exception Mapper will analyse the exception trace
and check whether it was originated from the from: place.
#[Capture] attribute allows to specify when: argument with a callback function to be used to determine
whether particular instance of the exception should be captured for a given property or not.
This is particularly useful when the same exception could be originated from multiple places:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
#[ExceptionalValidation]
class TransferMoneyCommand
{
#[Capture(BlockedCardException::class, when: [self::class, 'isWithdrawalCardBlocked'])]
public int $withdrawalCardId;
#[Capture(BlockedCardException::class, when: [self::class, 'isDepositCardBlocked'])]
public int $depositCardId;
public function isWithdrawalCardBlocked(BlockedCardException $exception): bool
{
return $exception->getCardId() === $this->withdrawalCardId;
}
public function isDepositCardBlocked(BlockedCardException $exception): bool
{
return $exception->getCardId() === $this->depositCardId;
}
}In this example, once we've matched BlockedCardException by class, custom closure is called.
If isWithdrawalCardBlocked() callback returns true, then exception is captured for withdrawalCardId property.
Otherwise, we analyse depositCardId, and if isDepositCardBlocked() callback returns true,
then the exception is captured on this property.
If neither of them returned true, then exception is re-thrown upper in the stack.
Since in most cases capture conditions come down to the simple value comparison, it's easier to make the exception
implement ValueException interface and specify condition: ExceptionValueMatchCondition::class instead of
implementing when: closure every time.
This way it's possible to avoid much of the boilerplate code, keeping it clean:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
use PhPhD\ExceptionalValidation\Rule\Object\Property\Capture\Condition\Value\ExceptionValueMatchCondition;
#[ExceptionalValidation]
class TransferMoneyCommand
{
#[Capture(BlockedCardException::class, condition: ExceptionValueMatchCondition::class)]
public int $withdrawalCardId;
#[Capture(BlockedCardException::class, condition: ExceptionValueMatchCondition::class)]
public int $depositCardId;
}In this example BlockedCardException could be captured either to withdrawalCardId or depositCardId,
depending on the cardId value from the exception.
And BlockedCardException itself must implement ValueException interface:
use PhPhD\ExceptionalValidation\Rule\Object\Property\Capture\Condition\Value\ValueException;
class BlockedCardException extends DomainException implements ValueException
{
public function __construct(private Card $card)
{
parent::__construct('card.blocked');
}
public function getValue(): int
{
return $this->card->getId();
}
}This one is very similar to ValueException condition
with the difference that it integrates Symfony's native ValidationFailedException.
Specify ValidationFailedExceptionMatchCondition to correlate validation exception's value with a property value:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
use PhPhD\ExceptionalValidation\Rule\Object\Property\Capture\Condition\Validator\ValidationFailedExceptionMatchCondition;
use Symfony\Component\Validator\Exception\ValidationFailedException;
#[ExceptionalValidation]
class RegisterUserCommand
{
#[Capture(
exception: ValidationFailedException::class,
from: Password::class,
condition: ValidationFailedExceptionMatchCondition::class,
)]
public string $password;
}There are two main built-in violation formatters you can use: DefaultExceptionViolationFormatter and
ViolationListExceptionFormatter.
If needed, create a custom violation formatter as described below.
MainExceptionViolationFormatter is used by default if another formatter is not specified.
It provides a basic way of creating a ConstraintViolation with these parameters:
$root, $message, $propertyPath, $value.
ViolationListExceptionFormatter allows formatting the exceptions
that contain a ConstraintViolationList from the validator.
Such exceptions should implement ViolationListException interface:
use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\ViolationList\ViolationListException;
use Symfony\Component\Validator\ConstraintViolationListInterface;
final class CardNumberValidationFailedException extends \RuntimeException implements ViolationListException
{
public function __construct(
private readonly string $cardNumber,
private readonly ConstraintViolationListInterface $violationList,
) {
parent::__construct('Card Number Validation Failed');
}
public function getViolationList(): ConstraintViolationListInterface
{
return $this->violationList;
}
}Then, specify ViolationListExceptionFormatter as a formatter: for the #[Capture] attribute:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\ViolationList\ViolationListExceptionFormatter;
#[ExceptionalValidation]
class IssueCreditCardCommand
{
#[Capture(
exception: CardNumberValidationFailedException::class,
formatter: ViolationListExceptionFormatter::class,
)]
private string $cardNumber;
}Thus, CardNumberValidationFailedException is captured on a cardNumber property,
and formatter makes sure all its constraint violations are mapped for this property.
If
#[Capture]attribute specified a message,
it would've been ignored in favour ofConstraintViolationListmessages.
Besides that, it's also possible to use
ValidationFailedExceptionFormatter,
which can format Symfony's nativeValidationFailedException.
In some cases, you might want to customize the created violations.
For example, pass additional parameters to the message translation.
You can create custom violation formatter by implementing ExceptionViolationFormatter interface:
use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\ExceptionViolationFormatter;
use PhPhD\ExceptionalValidation\Rule\Exception\MatchedException;
use Symfony\Component\Validator\ConstraintViolationInterface;
/** @implements ExceptionViolationFormatter<LoginAlreadyTakenException|WeakPasswordException> */
final class RegistrationViolationsFormatter implements ExceptionViolationFormatter
{
public function __construct(
#[Autowire(service: ExceptionViolationFormatter::class.'<Throwable>')]
private ExceptionViolationFormatter $formatter,
) {
}
/** @return array{ConstraintViolationInterface} */
public function format(MatchedException $matchedException): ConstraintViolationInterface
{
// format violation with the default formatter
// and then adjust only the necessary parts
[$violation] = $this->formatter->format($matchedException);
$exception = $matchedException->getException();
if ($exception instanceof LoginAlreadyTakenException) {
$violation = new ConstraintViolation(
$violation->getMessage(),
$violation->getMessageTemplate(),
['loginHolder' => $exception->getLoginHolder()],
// ...
);
}
if ($exception instanceof WeakPasswordException) {
// ...
}
return [$violation];
}
}Then, register it as a service:
services:
App\Auth\User\Features\Registration\Validation\RegistrationViolationsFormatter:
autoconfigure: trueIn order for violation formatter to be recognized by the bundle,
its service must be tagged withMatchedExceptionFormatterclass-name tag.If you are using autoconfiguration, this will be done automatically by the service container,
owing to the fact thatMatchedExceptionFormatterinterface is implemented.
Finally, specify formatter in the #[Capture] attribute:
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
#[ExceptionalValidation]
final class RegisterUserCommand
{
#[Capture(LoginAlreadyTakenException::class, formatter: LoginAlreadyTakenViolationFormatter::class)]
private string $login;
#[Capture(WeakPasswordException::class, formatter: WeakPasswordViolationFormatter::class)]
private string $password;
}In this example, LoginAlreadyTakenViolationFormatter is used to format constraint violation for
LoginAlreadyTakenException,
and WeakPasswordViolationFormatter formats WeakPasswordException.
Though not recommended, you might use a single formatter for the two.
#[ExceptionalValidation] attribute works side-by-side with Symfony Validator's #[Valid] attribute.
Once you define #[Valid] on an object/iterable property,
the mapper will pick it up for the nested exception mapping analysis,
providing a respective property path for the created violations.
use PhPhD\ExceptionalValidation;
use PhPhD\ExceptionalValidation\Capture;
use Symfony\Component\Validator\Constraints as Assert;
#[ExceptionalValidation]
class CreateOrderCommand
{
/** @var OrderItemDto[] */
#[Assert\Valid]
public array $items;
}
#[ExceptionalValidation]
class OrderItemDto
{
public int $productId;
#[Capture(InsufficientStockException::class, when: [self::class, 'isStockExceptionForThisItem'])]
public string $quantity;
public function isStockExceptionForThisItem(InsufficientStockException $exception): bool
{
return $exception->getProductId() === $this->productId;
}
}In this example, every time exception is processed, it will also be matched with inner objects from items property,
until it finally arrives at items[*].quantity (* stands for the particular array item index) property, being matched
by InsufficientStockException class name, and custom closure condition that makes sure that it was this particular
OrderItemDto that caused the exception.
The resulting property path of the caught violation includes all intermediary items, starting from the root of the tree, proceeding down to the leaf item, where the exception was actually caught.
Typically, validation is expected to return all present violations at once (not just the first one) so they can be shown to the user.
Though due to the limitations of the sequential computation model, only one instruction can be executed at a time, and therefore, only one exception can be thrown at a time. This leads to a situation where validation ends up in only the first exception being thrown, while the rest are not even reached.
For example, if we consider user registration with RegisterUserCommand from the code above, we'd like to capture both
LoginAlreadyTakenException and WeakPasswordException at once, so that the user can fix all the form errors at once,
rather than sorting them out one by one.
This limitation can be overcome by implementing some concepts from an Interaction Calculus model in a sequential PHP environment. The key idea is to use a semi-parallel execution flow instead of a purely sequential.
In practice, if validation is split into multiple functions, each of which may throw an exception, the concept can be implemented by calling them one by one and collecting any exceptions as they raise. If there were any, they are wrapped into a composite exception that is eventually thrown.
Fortunately, you don't need to implement this manually, since amphp/amp library already provides a more efficient
solution than one you'd likely write yourself, using async Futures:
/**
* @var Login $login
* @var Password $password
*/
[$login, $password] = await([
// validate and create an instance of Login
async($this->createLogin(...), $service),
// validate and create an instance of Password
async($this->createPassword(...), $service),
]);In this example, createLogin() method could throw LoginAlreadyTakenException and createPassword() method could
throw WeakPasswordException.
By using async and awaitAnyN functions, we are leveraging semi-parallel execution flow instead of sequential, so
that both createLogin() and createPassword() methods are executed regardless of thrown exceptions.
If no exceptions were thrown, then $login and $password variables are populated with the respective return
values. But if there were indeed some exceptions then Amp\CompositeException will be thrown with all the wrapped
exceptions inside.
If you would like to use a custom composite exception, make sure to read about ExceptionUnwrapper
Since the library is capable of processing composite exceptions (with unwrappers for Amp and Messenger exceptions), all of our thrown exceptions will be processed, and the user will get the complete stack of validation errors at hand.
The basic upgrade can be performed by Rector using
ExceptionalValidationSetList
that comes with the library and contains automatic upgrade rules.
To upgrade a project to the latest version of exceptional-validation,
add the following configuration to your rector.php file:
return RectorConfig::configure()
->withPaths([ __DIR__ . '/src'])
->withImportNames(removeUnusedImports: true)
// Upgrading from your version (e.g. 1.4) to the latest version
->withSets(ExceptionalValidationSetList::fromVersion('1.4')->getSetList());Make sure to specify your current version of the library so that upgrade sets will be matched correctly.
You should also check UPGRADE.md for additional instructions and breaking changes.