phphd / exceptional-validation
Capture domain exceptions and map them to the corresponding properties that caused them
Installs: 1 791
Dependents: 0
Suggesters: 0
Security: 0
Stars: 2
Watchers: 0
Forks: 2
Open Issues: 3
Type:symfony-bundle
Requires
- php: >=8.1
- phphd/exception-toolkit: ^1.0
- symfony/validator: ^6.0 | ^7.0
- webmozart/assert: ^1.11
Requires (Dev)
- nyholm/symfony-bundle-test: ^3.0
- phpat/phpat: ^0.10.13
- phphd/coding-standard: ~0.5.3
- phpstan/phpstan: ^1.10
- phpstan/phpstan-phpunit: ^1.3
- phpunit/phpunit: ^10.5
- psalm/plugin-phpunit: ^0.18.4
- symfony/config: ^6.0 | ^7.0
- symfony/dependency-injection: ^6.2 | ^7.0
- symfony/http-kernel: ^6.0 | ^7.0
- symfony/messenger: ^6.4 | ^7.0
- symfony/var-dumper: ^6.0 | ^7.0
- tomasvotruba/type-coverage: ^0.3.1
- vimeo/psalm: ^5.13
Suggests
- amphp/amp: Install AMP package in order to capture multiple exceptions at once
- symfony/messenger: Install Messenger component to use exceptional validation middleware
Conflicts
- symfony/config: <6.0 || >=8.0
- symfony/dependency-injection: <6.2 || >=8.0
- symfony/http-kernel: <6.0 || >=8.0
- symfony/messenger: <6.4 || >=8.0
Replaces
- phphd/exceptional-validation-bundle: *
README
🧰 Transform Domain Exceptions Into Validation Errors
Exceptional Validation bridges your domain validation exceptions with the user interface by capturing business exceptions and converting them into ordered validation errors. You don't have to run duplicate validation in your application/ui layers, nor even create custom validators, since you can declaratively map the exceptions to their relevant form fields by the means of this library instead.
Another Validation Library? 🤔
No, it's not a validation library, and never intended to be. It doesn't provide any validation rules, validators, constraints whatsoever. Instead, it is more of exception handling library, that formats exceptions in the validator format.
Your domain validation logic could be implemented with any kind of third-party library, or even plain PHP, while Exceptional Validation will provide an easy way to accurately map validation exceptions to the particular properties they relate to.
Even though it's not a strict requirement, it's recommended to use Symfony Validator as the main validation tool, since this library integrates it quite well.
Why Exceptional Validation? ✨
Ordinarily validation flows through two different layers - one at the HTTP/form level and another within domain objects - leading to duplication and potential inconsistencies.
Traditional approach usually makes high use of attribute-based validation, what strips down domain layer from most business logic it must've implemented on its own. Also, we don't have any other way to get a nice message on the form, but to create custom validator for every special check we need. This way, domain model ends up naked, since all business rules have leaked elsewhere.
On the other hand, there's a common practice in DDD that domain objects should be responsible for their own validation
rules. Email
value object validates its own format by itself, and it naturally throws an exception that represents
validation failure. RegisterUserService
normally verifies that there's no duplicate user in the system, and naturally
throws an exception. That is the kind of code that consummately expresses the model of the business, and therefore it
should not be stripped down.
Yet, with this domain-driven approach, it's a good question how to make these validation errors get shown to the user?
In order for us to be able to return a neat Frontend response with email
as property path, it's necessary to match
EmailAlreadyTakenException
with $email
property of the original RegisterUserCommand
.
That's exactly what Exceptional Validation is intended to do.
By capturing exceptions like EmailValidationFailedException
and mapping them to their particular form fields as
$email
, you maintain a single source of truth for domain validation logic. Your domain enforces its invariants through
value objects and services, while this library ensures that any validation failures will appear properly in your forms
and API responses.
This approach:
- Eliminates duplicate validation code across HTTP/application and domain layers;
- Keeps business rules where they belong - in the domain;
- Makes validation logic easily unit-testable;
- Simplifies complex nested validation scenarios;
- Eliminates the need for validation groups.
How it works? ⚙️
Primarily it works as a Command Bus middleware that intercepts exceptions, uses exception mapper to map them to the relevant form properties, and then formats captured exceptions as standard SF Validator violations.
Besides that,
ExceptionMapper
is also available for direct use w/o any middleware. You can reference it as@phd_exceptional_validation.exception_mapper.validator
service.
Installation 📥
-
Install via composer
composer require phphd/exceptional-validation
-
Enable the bundles in the
bundles.php
PhPhD\ExceptionalValidation\Bundle\PhdExceptionalValidationBundle::class => ['all' => true], PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],
Note: The PhdExceptionToolkitBundle is a required dependency that provides exception unwrapping functionality used by this library.
Configuration 🔧
The recommended way to use this package is via Symfony Messenger Middleware.
To start off, you should add phd_exceptional_validation
middleware to the list:
framework:
messenger:
buses:
command.bus:
middleware:
- validation
+ - phd_exceptional_validation
- doctrine_transaction
Once you have done this, the middleware will take care of capturing exceptions and processing them.
If you are not using Messenger component, you can still leverage features of this package, since it gives you rigorously structured set of tools w/o depending on any particular implementation. Since
symfony/messenger
component is optional, it won't be installed automatically if you don't need it.
Quick Start 🎯
First off, mark your message with #[ExceptionalValidation]
attribute, as it is used by mapper to include the
object for processing.
Then you can define exceptions to the properties mapping using #[Capture]
attributes.
They declaratively describe what exceptions should match to what properties under what conditions.
Basic example looks like this:
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; }
In this example we say that whenever LoginAlreadyTakenException
is thrown, it will be matched with login
property,
resulting in created ConstraintViolation
object with login
as property path, and auth.login.already_taken
as a
message.
The same comes to WeakPasswordException
at password
property path as well.
Please note that by default messages translation domain is
validators
, since it is inherited fromvalidator.translation_domain
parameter. You can change it by settingphd_exceptional_validation.translation_domain
parameter.
Finally, when phd_exceptional_validation
middleware processes the exception, it throws
ExceptionalValidationFailedException
so that client code can catch it and process as needed:
$command = new RegisterUserCommand($login, $password); try { $this->commandBus->dispatch($command); } catch (ExceptionalValidationFailedException $exception) { $violationList = $exception->getViolationList(); return $this->render('registrationForm.html.twig', ['errors' => $violationList]); }
Exception object contains both message and respectively mapped ConstraintViolationList
.
This violation list can be used for example to render errors into html-form or to serialize them into a json-response.
How is it different from the standard validation? ⚖️
You might be wondering why would we not just use simple validation asserts right in the command?
Let's see it with the same RegisterUserCommand
example above.
Traditional validation approach for the same rules would look something like this:
use Symfony\Component\Validator\Constraints as Assert; use App\Validator\Constraints as AppAssert; class RegisterUserCommand { #[AppAssert\UniqueLogin] public string $login; #[Assert\PasswordStrength(minScore: 2)] 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 to implement this business logic. Thereby domain leaks into validators. That code, which you would've normally implemented in the service, you have to implement in the validator.
One more point is that oftentimes multiple actions duplicate subset of validations. For example, password reset
action normally validates password in the same way as registration action, usually resulting in validation asserts being
duplicated between the two, while this business logic should've belonged to Password
concept, properly represented as
value object, being used in both actions.
With exceptional validation, you just retroactively map violations dictated by the domain. Herewith business logic has already worked out, and all you have to do is just display its result to the end user. This gives a lot of flexibility removing the need for custom validators, validation groups, and allowing you to keep the domain code in the domain objects, resulting in overall improvement of the design of the system.
Thus, you focus on the domain and let the library take care of the exception presentation:
// RegisterUserService if ($this->userRepository->loginExists($command->login)) { throw new LoginAlreadyTakenException($command->login); }
Features 📖
#[ExceptionalValidation]
and #[Capture]
attributes allow you to implement very flexible mappings.
Here are examples of how you can use them.
Capture Conditions
Exception Class Condition
A minimum required condition. Matches the exception by its class name using instanceof
operator, making it
similar to catch
block.
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; #[ExceptionalValidation] class PublishMessageCommand { #[Capture(MessageNotFoundException::class)] public string $messageId; }
When-Closure Condition
#[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.
ValueException Condition
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 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(); } }
ValidationFailedException Condition
This is very similar to ValueException Condition with the difference that it integrates Symfony's native
ValidationFailedException
.
You can specify ValidationFailedExceptionValueMatchCondition
to match validation exception based on the value:
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; use PhPhD\ExceptionalValidation\Rule\Object\Property\Capture\Condition\Validator\ValidationFailedExceptionValueMatchCondition; use Symfony\Component\Validator\Exception\ValidationFailedException; #[ExceptionalValidation] class RegisterUserCommand { #[Capture(ValidationFailedException::class, from: Password::class, condition: ValidationFailedExceptionValueMatchCondition::class)] public string $password; }
Capturing for nested structures
#[ExceptionalValidation]
attribute works side-by-side with Symfony Validator's #[Valid]
attribute. Once you
define #[Valid]
on a property containing an object, or an array, the mapper will analyze it for the nested exception
mapping, and provide respective property path for the caught 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, when InsufficientStockException
is processed, it will also be matched with inner objects of items
property, until it finally gets matched to particular items[*].quantity
property, where *
stands for the index of
the particular OrderItemDto
instance, witch the exception was captured on. The resulting property path includes all
intermediary items, starting form the root of the tree, and proceeding down to the leaf of the tree, where the exception
was actually caught.
Capturing multiple exceptions
Typically, during the validation process, it is expected that all validation errors will be shown to the user and not just the first one.
Though, due to the limitations of the sequential computation model, only one instruction is executed at a time, and therefore only one exception can be thrown at the time. This leads to the situation where only the first exception is 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 user will be able to fix the form at once of
all errors, not doing it one by one.
This limitation can be overcome by implementing some of the concepts of interaction combinators model in sequential PHP environment. The key concept is to use semi-parallel execution flow instead of sequential.
Practically, this idea could be implemented in the code that splits validation into separate functions, each of which could possibly throw, calls them one by one, and finally if there were any exceptions, wrap them into some kind of "composite exception" that will be thrown.
Fortunately, you don't need to do this manually, since amphp/amp
library already implements this in a more efficient
way than you'd probably do, using async Futures:
/** * @var Login $login * @var Password $password */ [$login, $password] = awaitAnyN([ // validate and create instance of Login async($this->createLogin(...), $command->getLogin()), // validate and create instance of Password async($this->createPassword(...), $command->getPassword()), ]);
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 indeed were some exceptions, then Amp\CompositeException
will be thrown with all the wrapped
exceptions inside.
If you want to use custom composite exception, read about ExceptionUnwrapper
Since current library is capable of processing composite exceptions (there are unwrappers for Amp and Messenger exceptions), all our thrown exceptions will be processed and user will have the full stack of validation errors at hand.
Violation formatters
There are two built-in violation formatters that you can use - DefaultViolationFormatter
and ViolationListExceptionFormatter
. If needed, you can create your own custom violation formatter as described below.
Default
DefaultViolationFormatter
is used by default if other formatter is not specified.
It provides a very basic way to format violations, building ConstraintViolation
with these parameters: $message
,
$root
, $propertyPath
, $value
.
Constraint Violation List Formatter
ViolationListExceptionFormatter
is used to format violations for the exceptions that
implement ViolationListException
interface. It allows to easily capture the exception that has ConstraintViolationList
obtained from the validator.
You can also format Symfony's native
ValidationFailedException
withValidationFailedExceptionFormatter
.
The typical exception class implementing ViolationListException
interface would look like this:
use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\Item\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((string)$this->violationList); } public function getViolationList(): ConstraintViolationListInterface { return $this->violationList; } }
Then you can use ViolationListExceptionFormatter
on the #[Capture]
attribute of the property:
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\Item\ViolationList\ViolationListExceptionFormatter; #[ExceptionalValidation] class IssueCreditCardCommand { #[Capture( exception: CardNumberValidationFailedException::class, formatter: ViolationListExceptionFormatter::class, )] private string $cardNumber; }
In this example, CardNumberValidationFailedException
is captured on the cardNumber
property and all the constraint
violations from this exception are mapped to this property. If there's message specified on the #[Capture]
attribute,
it is ignored in favor of the messages from ConstraintViolationList
.
Custom violation formatters
In some cases, you might want to customize the violations, such as passing additional parameters to the message
translation. You can create your own violation formatter by implementing ExceptionViolationFormatter
interface:
use PhPhD\ExceptionalValidation\Mapper\Validator\Formatter\Item\ExceptionViolationFormatter; use PhPhD\ExceptionalValidation\Rule\Exception\CapturedException; use Symfony\Component\Validator\ConstraintViolationInterface; final class RegistrationViolationsFormatter implements ExceptionViolationFormatter { public function __construct( #[Autowire('@phd_exceptional_validation.violation_formatter.default')] private ExceptionViolationFormatter $defaultFormatter, ) { } /** @return array{ConstraintViolationInterface} */ public function format(CapturedException $capturedException): ConstraintViolationInterface { // format violation with the default formatter // and then adjust only necessary parts [$violation] = $this->defaultFormatter->format($capturedException); $exception = $capturedException->getException(); if ($exception instanceof LoginAlreadyTakenException) { $violation = new ConstraintViolation( $violation->getMessage(), $violation->getMessageTemplate(), ['loginHolder' => $exception->getLoginHolder()], // ... ); } if ($exception instanceof WeakPasswordException) { // ... } return [$violation]; } }
Then you should register your custom formatter as a service:
services: App\AuthBundle\ViolationFormatter\RegistrationViolationsFormatter: tags: [ 'exceptional_validation.violation_formatter' ]
In order for your custom violation formatter to be recognized by this bundle, its service must be tagged with
exceptional_validation.violation_formatter
tag. If you use autoconfiguration, this is done automatically by the service container owing to the fact thatExceptionViolationFormatter
interface is implemented.
Finally, your custom formatter should be specified in the #[Capture]
attribute:
use PhPhD\ExceptionalValidation; use PhPhD\ExceptionalValidation\Capture; #[ExceptionalValidation] final class RegisterUserCommand { #[Capture( LoginAlreadyTakenException::class, 'auth.login.already_taken', formatter: RegistrationViolationsFormatter::class, )] private string $login; #[Capture( WeakPasswordException::class, 'auth.password.weak', formatter: RegistrationViolationsFormatter::class, )] private string $password; }
In this example, RegistrationViolationsFormatter
is used to format constraint violations for
both LoginAlreadyTakenException
and WeakPasswordException
(though you are perfectly fine to use separate
formatters), enriching them with additional context.
Upgrading
Project comes with ExceptionalValidationSetList
class that containing rules for automatic upgrade.
To upgrade project to the latest version of exceptional-validation
,
you should add the following line to your rector.php
file:
return static function (RectorConfig $rectorConfig): void { // Upgrading from the version 1.4 to the latest version $rectorConfig->sets(ExceptionalValidationSetList::fromVersion('1.4')->getSetList()); }
Make sure to specify your current version of library so that upgrade sets will be matched correctly.