Validation

Introduction

Validating your data, especially input, is critical for ensuring that your application runs smoothly. Let's take a look at how you can do this with your POPOs in Aphiria. Assume we have the following model in your application:

final class User
{
    public int $id;
    public string $email;
    public string $name;

    public function __construct(int $id, string $email, string $name)
    {
        $this->id = $id;
        $this->email = $email;
        $this->name = $name;
    }
}

Let's set up some constraints.

use Aphiria\Validation\Builders\ObjectConstraintsRegistryBuilder;
use Aphiria\Validation\Constraints\{EmailConstraint, Requiredconstraint};
use Aphiria\Validation\ValidationContext;
use Aphiria\Validation\Validator;
use App\Users\User;

// Set up our validator
$constraintsBuilder = new ObjectConstraintsRegistryBuilder();
$constraintsBuilder->class(User::class)
    ->hasPropertyConstraints('email', new EmailConstraint())
    ->hasPropertyConstraints('name', new Requiredconstraint());
$validator = new Validator($constraintsBuilder->build());

// Let's validate
$user = new User(123, 'dave@example.com', 'Dave');
$validator->validateObject($user, new ValidationContext($user));

If the object was not valid, a ValidationException will be thrown. That's it - validation, made simple.

Validating Data

To validate data, you need three things:

  1. The data itself
  2. A list of constraints to apply
  3. A context in which to perform the validation

Having a context allows us to keep track of things like circular dependencies, which property/method we're validating, and any violations found during validation.

Validating Objects

To validate an object, simply map the properties and methods in that object to constraints. Aphiria will then recursively validate the object and any properties/methods that contain objects. Use ObjectConstraintsRegistryBuilder to help set up the constraints on your objects' properties/methods like in the above example. To validate an object, we have two options:

$blogPost = new BlogPost('How to Reticulate Splines');

// Will throw a ValidationException if $blogPost is invalid
$valdiator->validateObject($blogPost, new ValidationContext($blogPost));

// Or

// Will return true if $blogPost is valid, otherwise false
if ($validator->tryValidateObject($blogPost, new ValidationContext($blogPost))) {
    // ...
}

Validating Properties

You can validate an individual property from an object:

$blogPost = new BlogPost('How to Reticulate Splines');

// Will throw a ValidationException if $blogPost->title is invalid
$valdiator->validateProperty($blogPost, 'title', new ValidationContext($blogPost, 'title'));

// Or

// Will return true if $blogPost->title is valid, otherwise false
if ($validator->tryValidateProperty($blogPost, 'title', new ValidationContext($blogPost, 'title'))) {
    // ...
}

In the case that the property holds an object value, it will be recursively validated, too.

Validating Methods

You can validate an individual method very similarly to how you validate properties:

$blogPost = new BlogPost('How to Reticulate Splines');

// Will throw a ValidationException if $blogPost->getTitleSlug() is invalid
$valdiator->validateMethod($blogPost, 'getTitleSlug', new ValidationContext($blogPost, null, 'getTitleSlug'));

// Or

// Will return true if $blogPost->getTitleSlug() is valid, otherwise false
if ($validator->tryValidateMethod($blogPost, 'getTitleSlug', new ValidationContext($blogPost, null, 'getTitleSlug'))) {
    // ...
}

In the case that the method holds an object value, it will also be recursively validated.

Validating Values

If you want to validate an individual value, you can:

// Will throw a ValidationException if $email is invalid
$valdiator->validateValue($email, [new EmailConstraint()], new ValidationContext($email));

// Or

// Will return true if $email is valid, otherwise false
if ($validator->tryValidateValue($email, [new EmailConstraint()], new ValidationContext($email))) {
    // ...
}

Constraints

A constraint is something that a value must pass to be considered valid. For example, if you want a value to only contain alphabet characters, you can enforce the AlphaConstraint on it. All constraints must implement IConstraint.

Built-In Constraints

Aphiria comes with some useful constraints built-in:

Custom Constraints

Creating a custom constraint is simple - just implement IConstraint.

use Aphiria\Validation\Constraints\IConstraint;
use Aphiria\Validation\ValidationContext;

final class MaxLengthConstraint implements IConstraint
{
    private int $maxLength;

    public function __construct(int $maxLength)
    {
        $this->maxLength = $maxLength;
    }

    public function getErrorMessageId(): string
    {
        return 'Length cannot exceed {maxLength}';
    }

    public function getErrorMessagePlaceholders(): array
    {
        return ['maxLength' => $this->maxLength];
    }

    public function passes($value, ValidationContext $context): bool
    {
        if (!is_string($value)) {
            throw new \InvalidArgumentException('Value must be string');
        }

        return \mb_strlen($value) <= $this->maxLength;
    }
}

You can now use this constraint just like any other built-in constraint:

$constraintsBuilder->class(BlogPost::class)
    ->hasPropertyConstraints('title', new MaxLengthConstraint(32));

Error Messages

Error messages provide human-readable explanations of what failed during validation. IConstraint provides an error message ID, which can be used in two ways:

  1. As the error message itself, which works best if you're not doing i18n
  2. As a sort of pointer (eg a slug) to the message to use, which works best if you're using a third-party i18n library

Constraints also provide error message placeholders, which can give more specifics on why a constraint failed. For example, MaxConstraint has a default error message ID of Field must be less than {max}, and it provides a max error message placeholder so that you can display the actual max in the error message.

To actually interpolate error messages, you can inject an instance of IErrorMessageInterpolater into your validator. Depending on how you're validating a value, there are different ways of grabbing the constraint violations. If you're using IValidator::validate*() methods, you can grab the violations from the ValidationException:

use Aphiria\Validation\ErrorMessages\StringReplaceErrorMessageInterpolater;
use Aphiria\Validation\{ValidationException, Validator};

// Assume we already have our object constraints configured
$errorMessageInterpolater = new StringReplaceErrorMessageInterpolater();
$validator = new Validator($objectConstraints, $errorMessageInterpolater);

try {
    $validator->validateObject($blogPost);
} catch (ValidationException $ex) {
    $errors = $ex->getValidationContext()->getErrorMessages();

    // Do something with the errors...
}

If you're using one of the IValidator::tryValidate*() methods, you can grab the violations from the ValidationContext:

use Aphiria\Validation\ValidationContext;

$validationContext = new ValidationContext($blogPost);

if (!$validator->tryValidateObject($blogPost, $validationContext)) {
    $errors = $validationContext->getErrorMessages();

    // Do something with the Interpolated error messages
}

Built-In Error Message Interpolaters

Aphiria comes with a couple error message interpolaters. StringReplaceErrorMessageInterpolater simply replaces {placeholder} in the constraints' error message IDs with the constraints' placeholders. It is the default interpolater, and is most suitable for applications that do not require i18n.

Validation Annotations

Aphiria offers the option to use annotations to map object properties and methods to constraints. The benefit to doing this is that it keeps the validation rules close (literally) to your models. Let's recreate the example in the introduction.

use Aphiria\Validation\Constraints\Annotations\{Email, Required};

final class User
{
    public int $id;
    /** @Email */
    public string $email;
    /** @Required */
    public string $name;

    public function __construct(int $id, string $email, string $name)
    {
        $this->id = $id;
        $this->email = $email;
        $this->name = $name;
    }
}

Once you configure your application to use annotations, you can validate your objects just like you do when not using annotations.

Built-In Annotations

The following annotations come with Aphiria:

Using Annotations

To actually use annotations, you'll need to configure Aphiria to scan for them. The configuration library provides a convenience method for this:

use Aphiria\Configuration\AphiriaComponentBuilder;
use Aphiria\Validation\Constraints\Annotations\AnnotationObjectConstraintsRegistrant;

// Assume we already have $container set up
$annotationConstraintRegistrant = new AnnotationObjectConstraintsRegistrant(['PATH_TO_SCAN']);
$container->bindInstance(AnnotationObjectConstraintsRegistrant::class, $annotationConstraintRegistrant);

(new AphiriaComponentBuilder($container))
    ->withValidationComponent($appBuilder)
    ->withValidationAnnotations($appBuilder);

If you're not using the configuration library, you can manually scan for annotations:

use Aphiria\Validation\Constraints\Annotations\AnnotationObjectConstraintsRegistrant;
use Aphiria\Validation\Constraints\Caching\FileObjectConstraintsRegistryCache;
use Aphiria\Validation\Constraints\ObjectConstraintsRegistrantCollection;
use Aphiria\Validation\Constraints\ObjectConstraintsRegistry;
use Aphiria\Validation\Validator;

// It's best to cache the results of scanning for annotations in production
if (\getenv('APP_ENV') === 'production') {  
    $constraintCache = new FileObjectConstraintsRegistryCache('/tmp/constraints.txt');
} else {
    $constraintCache = null;
}

$objectConstraints = new ObjectConstraintsRegistry();
$objectConstraintsRegistrants = new ObjectConstraintsRegistrantCollection($constraintCache);
$objectConstraintsRegistrants->add(new AnnotationObjectConstraintsRegistrant(['PATH_TO_SCAN']));
$objectConstraintsRegistrants->registerConstraints($objectConstraints);

$validator = new Validator($objectConstraints);

Validating Request Bodies

It's possible to use the validation library along with the serialization library to validate deserialized request bodies. Read the controller documentation for more details.