phphd / exceptional-validation
Match exceptions with the properties that caused them
Package info
github.com/phphd/exceptional-validation
Type:symfony-bundle
pkg:composer/phphd/exceptional-validation
Requires
- php: >=8.1
- phphd/exception-toolkit: ^1.0
- webmozart/assert: ^1.11
Requires (Dev)
- nyholm/symfony-bundle-test: ^3.0
- phpat/phpat: ^0.11.5
- phphd/coding-standard: ~0.6.1
- phpstan/phpstan: ^2.1.17
- phpstan/phpstan-phpunit: ^2.0.6
- phpunit/phpunit: ^10.5.46
- psalm/plugin-phpunit: ^0.19.5
- 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/uid: ^6.0 || ^7.0
- symfony/validator: ^6.0 || ^7.0
- symfony/var-dumper: ^6.0 || ^7.0
- tomasvotruba/type-coverage: ^2.0.2
- vimeo/psalm: ^6.11.0
Suggests
- amphp/amp: Install AMP package to capture multiple exceptions at once
- symfony/messenger: Install Messenger component to use exceptional validation middleware
- symfony/validator: Install Validator component to use constraint violations mapper
Conflicts
- rector/rector: <2.0 || >= 3.0
- 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
- symfony/validator: <6.0 || >=8.0
Replaces
- phphd/exceptional-validation-bundle: *
README
💼 Match the Exceptions to the Object's Properties
A library that matches exceptions against object properties.
No longer do you need any peripheral validation for your objects
that doesn't really dig down into the domain.
Instead, build full-fledged declarative feature-rich domain validation with full use of exceptions
and let this library to relate these exceptions to their originator fields.
Eventually, you can return a normal validation failed response.
A Validation Library? 🤔
It's not a validation library. Not ever intended to be.
It doesn't provide validation rules, constraints, or validators.
It is exception handling library, specifically featured with usefulties for validation.
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.
Why Exceptional Validation? ✨
Ordinarily, validation flows through two different layers:
- HTTP/form level;
- domain layer.
It leads to duplication and potential inconsistencies of validation rules.
Traditional Validation 🕯️
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.
Exceptional Validation 💡
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 a 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.
Throwing exceptions like EmailValidationFailedException and matching them with the particular form fields as
$email,
you maintain a single source of truth for the domain validation logic.
Domain enforces its invariants via exceptions in value objects and services,
while this library ensures that these validation failures will properly match form fields
and appear correct in your API responses or forms.
Key takeways
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.
Installation 📥
-
Install via composer:
composer require phphd/exceptional-validation
-
Enable bundles in the
bundles.php:PhPhD\ExceptionalMatcher\Bundle\PhdExceptionalMatcherBundle::class => ['all' => true], PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],
Note:
PhdExceptionToolkitBundleis a required dependency
that provides exception unwrapping needful for this library.
Get Started 🎯
Mark a message with #[Try_] attribute.
It's used by matcher to include this object for processing.
Define #[Catch_] matching rules for your properties.
These declaratively describe what properties what exceptions correlate with:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] class RegisterUserCommand { #[Catch_(LoginAlreadyTakenException::class, message: 'auth.login.already_taken')] public string $login; #[Catch_(WeakPasswordException::class, message: 'auth.password.weak')] public string $password; }
For example, we say that login property is related to LoginAlreadyTakenException,
while password - to WeakPasswordException.
Matching takes place when the matcher is used:
use PhPhD\ExceptionalMatcher\ExceptionMatcher; /** @var ExceptionMatcher<ConstraintViolationListInterface> $matcher */ $matcher = $container->get(ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>'); $command = new RegisterUserCommand($login, $password); try { $this->service->register($command); } catch (DomainException $exception) { $violationList = $matcher->match($exception, $command); return new JsonResponse($violationList, 422); }
Each exception, when matched, results in a ConstraintViolation object,
which contains a property path, and a message translation.
You can serialize this violation list into a json-response or render a form with it.
Note that the default messages translation domain is
validators,
inherited fromvalidator.translation_domainparameter.You can change it by setting
phd_exceptional_matcher.translation_domainparameter.
How is this different from a standard validation? ⚖️
Conceptually.
If you're wondering why we wouldn't use "normal" validation asserts right in the command,
I'll say to you that this is not always best / convenient.
For example, let's take the same RegisterUserCommand as used before.
A comparison of the approaches would look something like this:
+#[Try_] class RegisterUserCommand { - #[AppAssert\UniqueLogin] + #[Catch_(LoginAlreadyTakenException::class, message: 'auth.login.already_taken')] public string $login; - #[Assert\PasswordStrength(minScore: 2)] + #[Catch_(WeakPasswordException::class, message: '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 relate 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); }
Direct Usage 🔌
It's possible to use features of this library without necessarily depending on the frameworks. See Standalone Usage section.
If you're using Symfony, you can check what exception matchers are available using this command:
bin/console debug:container ExceptionMatcher
This should provide you with a list, similar to this:
[0] PhPhD\ExceptionalMatcher\ExceptionMatcher<PhPhD\ExceptionalMatcher\Exception\MatchedExceptionList>
[1] PhPhD\ExceptionalMatcher\ExceptionMatcher<Symfony\Component\Validator\ConstraintViolationListInterface>
These matchers format the Exception to their respective format, specified as a generic parameter.
Format could be ConstraintViolationList, or MatchedExceptionList, or anything else dumped by the command.
Therefore, you can inject the wanted service into your own code:
use PhPhD\ExceptionalMatcher\ExceptionMatcher; use Symfony\Component\Validator\ConstraintViolationListInterface; class SignDocumentActivity { public function __construct( /** @var ExceptionMatcher<ConstraintViolationListInterface> */ #[Autowire(service: ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>')] private ExceptionMatcher $exceptionMatcher, ) { } public function sign(SignCommand $command): string { try { return $command->businessLogic($this); } catch (DomainException $e) { throw $this->failure($e, $command); } } private function failure(Throwable $e, SignCommand $command): Throwable { /** @var ?ConstraintViolationListInterface $violationList */ $violationList = $this->exceptionMatcher->match($e, $command); if (null === $violationList) { return $e; } return new ApplicationFailure('Validation Failed', $this->encode($violationList), previous: $e); } }
In this example, we use ExceptionMatcher to relate the exception to some property of the $command,
which produces ConstraintViolationListInterface that can be used however you want to.
Usage with Command Bus 📇
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_transaction
Once you have done this, the middleware will take care of exception capturing, matching, 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 created ConstraintViolationList with all your messages and property paths.
How it works ⚙️
Primarily, it works as a Command Bus middleware that intercepts exceptions and performs their matching to object's properties by an exception matcher, eventually formatting matched exceptions as standard SF Validator violations.
Besides that,
ExceptionMatcheris also available for direct use w/o any middleware.
You can reference it asExceptionMatcher<ConstraintViolationListInterface>service.
This diagram represents the concept:
Standalone Usage 🔧
If you are not using a Symfony framework, you still have a great opportunity of taking advantage of this library.
You can create a Service Container (symfony/dependency-injection is required)
and use it to get necessary services:
use PhPhD\ExceptionalMatcher\Bundle\DependencyInjection\PhdExceptionalMatcherExtension; $container = (new PhdExceptionalMatcherExtension())->getContainer([ 'kernel.environment' => 'prod', 'kernel.build_dir' => __DIR__.'/var/cache', ]); $container->compile(); /** @var ExceptionMatcher<ConstraintViolationListInterface> $matcher */ $matcher = $container->get(ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>');
Herein, you create a Container, compile it, and use to get ExceptionMatcher.
Features 💎
#[Try_] and #[Catch_] attributes allow implementing very flexible matching rules.
It's highly recommended to see the examples below to know the power of these solutions.
Match Conditions 🖇️
Exception Class Condition
A bare minimum condition.
Matches the exception by its class name using instanceof check,
acting similarly to catch operation.
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] class SubmitOrderCommand { #[Catch_(OrderSubmissionPeriodClosedException::class)] public string $id; }
Origin Source Condition
Filters the exception by its origin place,
specifying whence it was to be raised from (class name and method name).
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use Symfony\Component\Uid\Uuid; #[Try_] class ConfirmParcelDeliveryCommand { #[Catch_(\InvalidArgumentException::class, from: [Uuid::class, 'fromString'])] public string $uid; }
In this example InvalidArgumentException is a generic one, possibly originating from multiple places.
If you want to catch only those that belong to Uuid class, specify from: clause with class and method name.
Therefore, Exception Matcher will analyse the exception trace
and check whether the exception was originated from that origin from: place.
When-Closure Condition
#[Catch_] attribute allows to specify if: argument with a callback function to be used to determine
whether particular instance of the exception should be matched with a given property or not.
This is particularly useful when the same exception could be originated from multiple places:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] class TransferMoneyCommand { #[Catch_(CardBlockedException::class, if: [self::class, 'isWithdrawalCard'])] public int $withdrawFromCardId; #[Catch_(CardBlockedException::class, if: [self::class, 'isDepositCard'])] public int $depositToCardId; public function isWithdrawalCard(CardBlockedException $exception): bool { return $this->withdrawFromCardId === $exception->getCardId(); } public function isDepositCard(CardBlockedException $exception): bool { return $this->depositToCardId === $exception->getCardId(); } }
In this example, once we've matched CardBlockedException by class, custom closure is called.
If isWithdrawalCardBlocked() callback returns true, the exception is matched for withdrawalCardId property.
Otherwise, we analyse depositCardId, and if isDepositCardBlocked() callback returns true,
then the exception is matched for this property.
If neither of them returned true, then exception is re-thrown upper in the stack.
Uid Condition
You can match Symfony's InvalidArgumentException from the Uid component
using InvalidUidExceptionMatchCondition:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use Symfony\Component\Uid\Exception\InvalidArgumentException as InvalidUidException; use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Uid\uid_value; #[Try_] class ApproveVerificationCommand { #[Catch_(InvalidUidException::class, match: uid_value)] public string $id; }
This condition compares exception's invalidValue with the property value.
If they are equal, the exception is matched for this property, otherwise other properties are analysed (if any).
Only string property values are allowed for this condition.
This condition is registered only when
symfony/uidis installed and exposesSymfony\Component\Uid\Exception\InvalidArgumentException::$invalidValue.
ValueException Condition
Since in most cases matching conditions come down to the simple value comparison, it's easier to make the exception
implement ValueException interface and specify match: ExceptionValueMatchCondition::class instead of
implementing if: closure every time.
This way it's possible to avoid much of the boilerplate code, keeping it clean:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\exception_value; #[Try_] class TransferMoneyCommand { #[Catch_(CardBlockedException::class, match: exception_value)] public int $withdrawalCardId; #[Catch_(CardBlockedException::class, match: exception_value)] public int $depositCardId; }
In this example CardBlockedException could be matched either with withdrawalCardId or with depositCardId,
depending on the cardId value from the exception.
And CardBlockedException itself must implement ValueException interface:
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\ValueException; class CardBlockedException 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 one is very similar to ValueException condition
with the difference that it integrates Symfony's native ValidationFailedException.
Specify validated_value match condition to compare property's value against exception's validated value:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use Symfony\Component\Validator\Exception\ValidationFailedException; use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Validator\validated_value; use const PhPhD\ExceptionalMatcher\Validator\Formatter\Validator\validator_violations; #[Try_] class RegisterUserCommand { #[Catch_(ValidationFailedException::class, from: Password::class, match: validated_value, format: validator_violations)] public string $password; }
Violation Formatters 🎨
There are two main built-in violation formatters you can use: DefaultExceptionViolationFormatter and
ViolationListExceptionFormatter.
If needed, create a custom violation formatter as described below.
Main
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.
Constraint Violation List Formatter
ViolationListExceptionFormatter allows formatting the exceptions
that contain a ConstraintViolationList from the validator.
Such exceptions should implement ViolationListException interface:
use PhPhD\ExceptionalMatcher\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 format: for the #[Catch_] attribute:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use const PhPhD\ExceptionalMatcher\Validator\Formatter\ViolationList\included_violations; #[Try_] class IssueCreditCardCommand { #[Catch_(CardNumberValidationFailedException::class, format: included_violations)] private string $cardNumber; }
Thus, once cardNumber property gets a hold of CardNumberValidationFailedException,
formatter makes sure that a proper representation of this exception in a ConstraintViolation form is created for this property.
If
#[Catch_]attribute specified a message,
it would've been ignored in favour ofConstraintViolationListmessages.
Besides that, it's also possible to use
validator_violationsformatter,
which can format Symfony's nativeValidationFailedException.
Custom Violation Formatters 🎨🖌️
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\ExceptionalMatcher\Exception\MatchedException; use PhPhD\ExceptionalMatcher\Validator\Formatter\ExceptionViolationFormatter; use Symfony\Component\Validator\ConstraintViolationInterface; /** @implements ExceptionViolationFormatter<LoginAlreadyTakenException> */ final class LoginAlreadyTakenViolationFormatter 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); /** @var LoginAlreadyTakenException $exception */ $exception = $matchedException->getException(); $violation = new ConstraintViolation( $violation->getMessage(), $violation->getMessageTemplate(), ['loginHolder' => $exception->getLoginHolder()], // ... ); return [$violation]; } }
Then, register it as a service:
services: App\Auth\User\Support\Validation\LoginAlreadyTakenViolationFormatter: autoconfigure: true
In 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 #[Catch_] attribute:
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; #[Try_] final class RegisterUserCommand { #[Catch_(LoginAlreadyTakenException::class, format: LoginAlreadyTakenViolationFormatter::class)] private string $login; #[Catch_(WeakPasswordException::class, format: WeakPasswordViolationFormatter::class)] private string $password; }
In this example, LoginAlreadyTakenViolationFormatter formats constraint violation for LoginAlreadyTakenException,
while WeakPasswordViolationFormatter formats WeakPasswordException.
In-depth analysis
The approach described is done away with.
#[Try_] attribute works side-by-side with Symfony Validator's #[Valid] attribute.
Once you define #[Valid] on an object/iterable property,
the matcher will pick it up for a nested analysis,
providing a respective property path for the created violations.
use PhPhD\ExceptionalMatcher\Rule\Object\Try_; use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_; use Symfony\Component\Validator\Constraints as Assert; #[Try_] class CreateOrderCommand { /** @var OrderItemDto[] */ #[Assert\Valid] public array $items; } #[Try_] class OrderItemDto { public int $productId; #[Catch_(InsufficientStockException::class, if: [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.
Matching multiple exceptions
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.
Upgrading 👻
The basic upgrade can be performed by Rector using
ExceptionalMatcherSetList
which 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:
use PhPhD\ExceptionalMatcher\Upgrade\ExceptionalMatcherSetList; return RectorConfig::configure() ->withPaths([ __DIR__ . '/src']) ->withImportNames(removeUnusedImports: true) // Upgrading from your version (e.g. 1.4) to the latest version ->withSets(ExceptionalMatcherSetList::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 breaking changes and additional instructions.