https://github.com/phphd/exceptional-matcher
Match exceptions to the properties that originated them
https://github.com/phphd/exceptional-matcher
Last synced: about 1 month ago
JSON representation
Match exceptions to the properties that originated them
- Host: GitHub
- URL: https://github.com/phphd/exceptional-matcher
- Owner: phphd
- License: mit
- Created: 2023-12-30T18:27:26.000Z (over 2 years ago)
- Default Branch: main
- Last Pushed: 2026-05-08T12:03:24.000Z (about 2 months ago)
- Last Synced: 2026-05-08T12:30:03.306Z (about 2 months ago)
- Language: PHP
- Homepage:
- Size: 1.37 MB
- Stars: 3
- Watchers: 0
- Forks: 2
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# Exceptional Matcher 🏹
💼 Match the Exceptions to the Object's Properties
[](https://github.com/phphd/exceptional-matcher/actions?query=branch%3Amain)
[](https://codecov.io/gh/phphd/exceptional-matcher)
[](https://github.com/phphd/exceptional-matcher/blob/main/phpstan.dist.neon)
[](https://shepherd.dev/github/phphd/exceptional-matcher)
[(\.\d{1})%3F\d*\%25%3C%2Ftd%3E&replace=%241%25&flags=is&style=flat&logo=data%3Aimage%2Fpng%3Bbase64%2CiVBORw0KGgoAAAANSUhEUgAAACwAAAAsCAMAAAApWqozAAABblBMVEUAAAAAAAAAAAAAAAABAQEBAQELCwsAAAAAAAAAAAAGBgYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD%2F%2F%2F8AAAD9%2Ff0FBQX39%2Ff8%2FPwQEBAUFBT09PQgICAYGBjq6uo6Ojrj4%2BPd3d2%2Fv7%2B0tLSmpqZvb289PT3x8fHi4uKxsbGSkpJ0dHQwMDAtLS3KysrExMS8vLy4uLigoKCNjY2IiIiFhYV7e3tkZGRfX19cXFxMTExBQUEnJycdHR0JCQn29vbs7Oze3t7a2trX19fOzs7Hx8fBwcGtra2oqKiZmZl3d3dWVlZJSUkyMjIqKirn5%2BfR0dG7u7ujo6OBgYFUVFRFRUX5%2Bfn19fXl5eXT09O2tradnZ1%2Bfn5ra2tOTk64D2w2AAAALnRSTlMA%2Bjz99%2FX%2B6soc%2B%2FFuUBYG2qtlV0H37sSzhXxgSCoRDQoE4dPOeTAuI7yjl48yeQ609QAAAylJREFUOMuNlWdX4lAQhm8IHVRU7Lr27uYmQCD0XpUiHaUjxd5199%2FvBQIEjLLvl5z7znPmTiZzJoBXsrVN8J%2BalQqxrZWpMdTkxMysYHURS3k8Kbl0VoCOa9%2BxapWQlotEgaqGIDRnJkyEjpIZfnZ%2Fyxk0misVF9GRvlIxGwuN%2BQk%2BdmM6UyC%2B6Ma08%2BsrO6WcCxM88swtjdJ7S9K5uoYP1pwLt4%2FVnNZsLksMSQ%2FLksarpCmd8L5pu%2BeLaiKjOBpknxA5LGyI0FYDsCMm%2BsJapLsuX%2B7D0y0juu%2B1aLkktXYdZEXFXMVgKFjUo840t%2FuwKk4a7alrJuuPRxDbE21icjBHpyMuIja%2F0Wua5IF4oHr5TI%2F%2BzqPR6lnpG%2BJDIet9DYWdcLdY9vO1ZktA6LurWeJd2l8kiDORoMv%2Bms66CSJk6EQyZh%2FU2cW6Ux30Bem2EyihdzRmpJ3Uk4d0jUTnoJNqpzE2IeXVMWEKpm4NFMU4zJ2GWLM7kwg%2Bos8Qi%2BQKPzqdAdupMxmhcrVE%2Bu7Dee%2Bx9NpXyk4DsIbFENvXpyFie0AdMXjfY7SPEyDDuAysYiGCo1PIkYcbyeOr4EB4xc1sZgasLs%2FNfIbvAbCT4g6QPj2AnS5OQJtUAQCUYjc3QR3CaweEjyYI69wrXeJdBAuE1pGic96QtT0iQ74bm0DwjNzCNY3tj%2BOIUOiCS65%2FwbQHT%2Fp0wTW1qIYn%2B3tDDM%2FJIT%2BhAmw3OHoTU8lAJk7TeWJIVfw32BeGhk1ttDtRXnLYt%2BCzQCY61w67F74c6nFUM%2ByS9zgavBOdbSTHcznqvdWOsGXD4iaaugUGTdIYkVaGndFFQ34cfOlfkLFLQ2EfB1tFatDVuiQ6Dr5STAJWWw5yTMnnkqn%2B3jBd%2Fgzrn6RDG0nzQ96bmFw52KDLiuzfsJ4f1Zz6AorjdTCQYEkqir%2Fwb1H59pJgdD%2Fvzt3zlWATn6zzbP7Fa%2FdX2Nxa4GFRKZLmrb5gLevZ%2B0ulgt7cmFcDXq3M42Icwwy2ZzSod35MiIkpyQr4RgfK3RmB%2BhBv2sp%2FqAXB2qpSKQNjtKKCUKLk%2BSH%2FA%2B%2BQHmpvMd%2BAAAAAAElFTkSuQmCC&label=Type%20coverage&color=f8f8f8&cacheSeconds=3600)](https://shepherd.dev/github/phphd/exceptional-matcher)
[](https://packagist.org/packages/phphd/exceptional-validation)
[](https://github.com/phphd/exceptional-matcher/blob/main/LICENSE)
A lightweight bridge from domain exceptions to validation violations.

Your domain code that processes Dto (i.e. services / entities) can run [validation by throwing](./docs/exceptional-validation/exceptional-validation.md) business exceptions. \
Using Matcher, you can correlate it to the property that originated it –
thereby allowing to return precise field-specific validation errors inferred from the exceptions.
Thence it makes up for what was lacking in tools for relating validation exceptions to their originator fields.
## Quick Start ⚡
### Install 📥
1. Require via composer:
```sh
composer require phphd/exceptional-matcher
```
2. \[Symfony\] enable the bundles in the `bundles.php`:
```php
PhPhD\ExceptionalMatcher\Bundle\PhdExceptionalMatcherBundle::class => ['all' => true],
PhPhD\ExceptionToolkit\Bundle\PhdExceptionToolkitBundle::class => ['all' => true],
```
> Note: `PhdExceptionToolkitBundle` is a required dependency \
> that provides exception unwrapping needful for this library.
3. \[Non-Symfony\] configure the container:
You can use features of this library outside frameworks. \
See [Standalone Usage](#standalone-usage-).
### Map and Throw 🎯
Mark a command or dto with `#[Try_]` attribute, and properties with `#[Catch_]`.
`#[Try_]` lets the matcher know it's included for processing. \
`#[Catch_]` defines rules about "what" and "how" to catch.
Finally, throw those exceptions from your use-case handler:
```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
#[Try_]
class UserRegistration
{
#[Catch_(LoginAlreadyTakenException::class)]
public string $login;
#[Catch_(PasswordCompromisedException::class)]
public string $password;
public function process(UserRegistrationServices $services): void
{
$userWithTheSameLogin = $services->userRepository->whereLogin($this->login)->firstOrNull();
if (null !== $userWithTheSameLogin) {
throw new LoginAlreadyTakenException($this->login);
}
if ($services->passwordSecurity->isCompromised($this->password)) {
throw new PasswordCompromisedException($this->password);
}
$services->entityManager->persist(new User($this->login, $this->password));
$services->entityManager->flush();
}
}
```
> Note: `UserRegistration` embodies both data (DTO) and behavior (Service) over this data, \
> effectively attaining well-comprehensible Object-Oriented Design.
These mappings describe what exceptions what properties correlate with.
Here, `LoginAlreadyTakenException` is bound to the `login` property, \
while `PasswordCompromisedException` is bound to the `password` property.
> You can have additional matching conditions beyond just the exception class name. \
> See [Match Conditions 🖇️](docs/config/match-conditions.md).
The equivalent (very rough) simplified manual logic if not using this library:
```php
$registration = new UserRegistration('jzs', 'jn3.16');
$errors = [];
try {
return $registration->process($services);
} catch (LoginAlreadyTakenException $e) {
$errors['login'] = $e->getMessage();
} catch (PasswordCompromisedException $e) {
$errors['password'] = $e->getMessage();
}
```
### Catch and Match 🎣
[Exceptional Validation](./docs/exceptional-validation/exceptional-validation.md) is like a parable of fisherman.
> One man went out to **fish carps**. \
> Having cast a fishing rod, he waits... \
> And there it is! It's biting! \
> In anticipation of a catch, he starts reeling up... \
> Yet, to his disappointment, it's a fry and not a fish! \
> He throws it back.
>
> The second time he casts the line... \
> In faith of a good catch, he waits... \
> And now at last, he pulls a fine 5-pound carp. \
> He takes it home and roasts it to eat.
The code:
```php
use PhPhD\ExceptionalMatcher\ExceptionMatcher;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\ConstraintViolationListInterface;
#[AsController]
class RegisterUserApiPoint
{
public function __construct(
/** @var ExceptionMatcher */
#[Autowire(service: ExceptionMatcher::class.'<'.ConstraintViolationListInterface::class.'>')]
private ExceptionMatcher $matcher,
private UserRegistrationServices $services,
) { }
#[Route(
path: '/register',
name: 'user_register',
methods: ['POST'],
)]
public function __invoke(
#[MapRequestPayload] UserRegistration $registration,
): Response {
try {
$registration->process($this->services);
} catch (Throwable $exception) {
return $this->handleError($exception, $registration);
}
return new Response(status: HTTP_CREATED);
}
private function handleError(Throwable $exception, UserRegistration $registration): Response
{
/** @var ?ConstraintViolationListInterface $violationList */
$violationList = $this->matcher->match($exception, $registration);
if (null === $violationList) {
throw $exception;
}
return new JsonResponse($violationList, HTTP_UNPROCESSABLE_ENTITY);
}
}
```
The cue to a parable:
- Exception is the fish;
- Code that catches is the fisherman;
- Data object's mappings – man's expectation of a carp;
- ConstraintViolation – the fish after roasting.
Fisherman evaluates the fish against his expectation - \
`ExceptionMatcher` matches the exception against `UserRegistration` object.
Fisherman roasts the fish - \
`ExceptionMatcher` returns the `ConstraintViolation` list (or custom format) created of the exception.
Fisherman releases the fry - \
`ExceptionMatcher` didn't match, and the main code `throws $exception;` back.
Finally, the created `ConstraintViolationList` contains violation-objects with matched property path,
message translation, and invalid value.
You can serialize it into a json-response or render on a form.
```json
{
"propertyPath": "login",
"invalidValue": "jzs",
"message": "Login is already taken. Try another one."
}
```
## Why Exceptional Matcher ✨
Exceptional Matcher opens the way for [Exceptional Validation](./docs/exceptional-validation/exceptional-validation.md),
wherewith you can **omit** any **peripheral validation** off of your dto objects, \
and rely solely on validation in real code (services, value objects) – that belongs to and resides in the domain.
Thus, you have a full-fledged domain-embedded **validation** that naturally expresses itself with the **exceptions**.
### Where is the Power 🚀
Consider another use-case: after registration, the user should be able to _update_ his _profile_.
Updating the _login_ must ensure its _uniqueness_ in spite of the current user.
Here's what we'd have to do with an upfront attribute-driven validation:
```php
#[UniqueEntity(
fields: ['login'],
entityClass: User::class,
identifierFieldNames: ['user' => 'id'],
)]
class UserProfileUpdate
{
public string $login;
@@
```
Compare this to `#[Catch_]` approach and recognize which communicates the intent better:
```php
#[Try_]
class UserProfileUpdate
{
#[Catch_(LoginAlreadyTakenException::class)]
public string $login;
@@
```
The first approach is very imperative, verbose. \
The second declaratively states the fact.
Moreover, now you don't restrain yourself with the limitations of the framework. \
You don't have limitations at all. \
[Exceptional Validation](./docs/exceptional-validation/exceptional-validation.md) enables you to implement everything at the highest quality possible.
The mapping for profile update `Dto` is just as high-level as it was for [registration `Dto`](#map-and-throw-):
```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
#[Try_]
class UserProfileUpdate
{
#[Catch_(LoginAlreadyTakenException::class)]
public string $login;
#[Catch_(PasswordCompromisedException::class)]
#[Catch_(PasswordCannotBeReusedException::class)]
public string $password;
public function process(User $user, UserProfileUpdateServices $services): void
{
$userWithTheSameLogin = $services->userRepository->whereLogin($this->login)->firstOrNull();
if ($userWithTheSameLogin?->is($user) === false) {
throw new LoginAlreadyTakenException($this->login);
}
if ($services->passwordSecurity->isCompromised($this->password)) {
throw new PasswordCompromisedException($this->password);
}
// Could throw PasswordCannotBeReusedException
$user->updateProfile($this->login, $this->password);
$services->entityManager->flush();
}
}
```
No custom validators, no attribute-driven-rules - just pure business description.
The main code is just as simple as it could be, throwing the same elucidated exceptions:
- the same `LoginAlreadyTakenException` as used in registration, just under another condition.
- the same check of `PasswordCompromisedException` as with registration.
- additional `PasswordCannotBeReusedException`, specific to profile update.
This communicates the design much better than what we've seen thus far.
This is where the power comes from. You don't cram the validation into the framework. \
You broaden the framework so that it embraces your validation in a way that it naturally fits in.
Read more in: [Exceptional Validation](docs/exceptional-validation/exceptional-validation.md).
## Interaction approaches 🔁
The library provides a few interaction points:
- [Matcher Service](docs/interaction/direct-matcher-service-usage.md) – manual handling
(just [as shown](#catch-and-match-));
- [Bus Middleware](docs/interaction/command-bus-middleware.md) – automated handling.
## Features 💎
`#[Try_]` and `#[Catch_]` attributes allow implementation of very flexible matching rules. \
It's highly recommended to get acquainted with the examples to apprehend the full power of these solutions.
There are two configuration features:
- [Match Conditions 🖇️](docs/config/match-conditions.md) – determine whether a given exception should match the given
property;
- [Violation Formatters 🎨](docs/config/violation-formatters.md) – represent the exception in a desired format.
That's really all this library does – matches the exception and formats it (i.e. roasts the fish).
#### Cheat Sheet 📝
For a cheat-sheet example of configuration, check the following:
```php
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use Symfony\Component\Uid\Exception\InvalidArgumentException as InvalidUidException;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Enum\enum_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Uid\uid_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Value\exception_value;
use const PhPhD\ExceptionalMatcher\Rule\Object\Property\Match\Condition\Validator\validated_value;
use const PhPhD\ExceptionalMatcher\Validator\Formatter\Validator\validator_violations;
#[Try_]
class ImportProductDto
{
#[Catch_(InvalidUidException::class, match: uid_value, message: 'This is not a valid UUID.')]
public string $id;
#[Catch_(CategoryNotFoundException::class, match: exception_value)] // Message is derived from Exception
public string $categoryId;
#[Catch_(\ValueError::class, from: ProductStatus::class, match: enum_value, message: 'The value you selected is not a valid choice.')]
public string $status;
#[Catch_(ValidationFailedException::class, from: ProductDescription::class, match: validated_value, format: validator_violations)]
public string $description;
#[Catch_(BackorderDisabledForCategoryException::class, if: [self::class, 'thisProductViolatesBackorder'])]
public ?int $backorderLimit;
/**
* Needed in case of deep analysis.
*
* If this method returns TRUE, the exception is linked to $backorderLimit of *this object*;
* otherwise this exception has nothing to do with this object.
*/
public function thisProductViolatesBackorder(BackorderDisabledForCategoryException $exception): bool
{
if ($exception->categoryId !== $this->categoryId) {
return false; // Backorder configuration of the given category has nothing to do with this category.
}
if (null === $this->backorderLimit) {
return false; // The product didn't even enable backorder, much less violated it.
}
return true;
}
}
```
### Deep analysis 🌊
The matcher automatically picks all nested objects for analysis, provided that they define `#[Try_]` attribute.
```php
use PhPhD\ExceptionalMatcher\Rule\Object\Try_;
use PhPhD\ExceptionalMatcher\Rule\Object\Property\Catch_;
use Symfony\Component\Validator\Constraints as Assert;
#[Try_]
class ImportProductBatchDto
{
/** @var ImportProductDto[] */
public array $items;
}
```
With nested matching with array properties, property paths are formatted differently.
In the example above, when the exception is matched, the path would be `items[].`:
- `` - a particular array index;
- `` - a particualr property name of that object.
When nesting is really deep, the resulting property path of the formatted violation
would include all intermediary properties in its path,
starting from the root, down to the leaf item where the exception was actually matched.
#### Need for conditions
Finding a match for the exception in `array` field is like finding your luggage in the _baggage claim_ \
when everyone else took just the _same alike red backpack_ as you did.

When many backpacks are as yours, you must know which one is yours.
Similarly, finding a match for `BackorderDisabledForCategoryException` across `ImportProductDto[]` must know which
one to relate to, lest it would choose the first object by exception's `class:` condition (i.e. "grab the first red one
and go").
To find your backpack, you would look at some other characteristics that discern it from the rest, \
yea, up to the point of opening it and discovering (or not discovering) your stuff in there.
```php
if ($exception->categoryId !== $this->categoryId) {
// not my backpack
}
```
That's what `if:` condition is there for – to relate an exception to `$this` particular object.
In our example, we check that the object's category (e.g. stuff in a backpack) is the same as the one we seek for of the
exception.
If the category is different, the object is skipped and another is taken for consideration.
The same applies to the products that don't enable backorder (`backorderLimit` is not filled):
```php
if (null === $this->backorderLimit) {
// It's not my BackorderDisabledForCategoryException! I didn't enable backorder!
}
```
Thus, we prevent false attribution of the exception to an object that had nothing to do with it.
## Advanced 🛠️
- [Matching multiple exceptions 🕎](docs/multi-match/matching-multiple-exceptions.md) – beyond just one
thrown exception.
## Standalone Usage 🔧
If you are not using a Symfony framework, you can still have a great advantage of this library.
In your vanilla project, create a Service Container (`symfony/dependency-injection` is required) \
and use it to get necessary services:
```php
use PhPhD\ExceptionalMatcher\Bundle\DependencyInjection\PhdExceptionalMatcherExtension;
use PhPhD\ExceptionalMatcher\ExceptionMatcher;
use PhPhD\ExceptionalMatcher\Exception\MatchedExceptionList;
$container = (new PhdExceptionalMatcherExtension())->getContainer([
// These are not used but still required by Symfony DI
'kernel.environment' => 'prod',
'kernel.build_dir' => __DIR__.'/var/cache',
]);
$container->compile();
/** @var ExceptionMatcher $matcher */
$matcher = $container->get(ExceptionMatcher::class.'<'.MatchedExceptionList::class.'>');
```
Herein, you create a Container, compile it, and use to get `ExceptionMatcher`.
## Upgrading 👻
The basic upgrade should be performed by [Rector](https://getrector.com/documentation) using
`ExceptionalMatcherSetList` \
that comes with the library and contains automatic upgrade rules.
To upgrade a project to the latest version of `exceptional-matcher`, \
make the following configuration to your `rector.php` file:
```php
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](UPGRADE.md) for the list of breaking changes and additional instructions.