phphd/exceptional-validation

Capture domain exceptions and map them to the corresponding properties that caused them

Installs: 1 711

Dependents: 0

Suggesters: 0

Security: 0

Stars: 2

Watchers: 0

Forks: 2

Open Issues: 3

Type:symfony-bundle

1.5.0 2024-09-07 12:10 UTC

This package is auto-updated.

Last update: 2025-04-15 09:13:27 UTC


README

🧰 Transform Domain Exceptions Into Validation Errors

Build Status Codecov Psalm coverage Psalm level Packagist Downloads Licence

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.

Exceptional Validation.svg

Installation 📥

  1. Install via composer

    composer require phphd/exceptional-validation
  2. 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 from validator.translation_domain parameter. You can change it by setting phd_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 with ValidationFailedExceptionFormatter.

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 that ExceptionViolationFormatter 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.