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\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);

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

Validating Data

Several types of data can be validated:

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);

// Or

$violations = [];

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

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');

// Or

$violations = [];

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

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');

// Or

$violations = [];

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

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()]);

// Or

$violations = [];

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

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:

Name Description
AlphaConstraint The value must only contain alphabet characters
AlphanumericConstraint The value must only contain alphanumeric characters
BetweenConstraint The value must fall in between two values (takes in whether or not the min and max are inclusive)
CallbackConstraint The value must satisfy a callback that returns a boolean
DateConstraint The value must match a date-time format
EachConstraint The value must satisfy a list of constraints (takes in a list of IConstraint)
EqualsConstraint The value must equal a value
InConstraint The value must be in a list of acceptable values
IntegerConstraint The value must be an integer
IPAddressConstraint The value must be an IP address
MaxConstraint The value cannot exceed a max value (takes in whether or not the max is inclusive)
MinConstraint The value cannot go below a min value (takes in whether or not the min is inclusive)
NotInConstraint The value must not be in a list of values
NumericConstraint The value must be numeric
RegexConstraint The value must satisfy a regular expression
RequiredConstraint The value must not be null

Custom Constraints

Creating a custom constraint is simple - just implement IConstraint.

use Aphiria\Validation\Constraints\IConstraint;

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($value): array
    {
        return ['maxLength' => $this->maxLength];
    }

    public function passes($value): 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. IConstraints contain error message IDs and 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.

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\StringReplaceErrorMessageInterpolator;
use Aphiria\Validation\{ValidationException, Validator};

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

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

    foreach ($ex->getViolations() as $violation) {
        $errors[] = $violation->getErrorMessage();
    }

    // Do something with the errors...
}

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

$violations = [];

if (!$validator->tryValidateObject($blogPost, $violations)) {
    $errors = [];

    foreach ($violations as $violation) {
        $errors[] = $violation->getErrorMessage();
    }

    // Do something with the errors...
}

Error Message Templates

Aphiria allows you to configure how your error message IDs map to error message templates via error template registries. The IDs are typically used in one of two ways:

  1. As the error message template itself, which works best if you're not doing i18n
    • DefaultErrorMessageTemplateRegistry is recommended
  2. As a sort of pointer (eg a slug) to the message template to use, which works best if you need to support i18n
    • Implementing your own template registry is recommended

Let's look at an example of option 2. Let's say that your templates are stored in a PHP file and are separated by locale, eg:

// These messages messages are in the ICU format
return [
    'en' => [
        'tooLong' => 'Value can not exceed {maxLength, plural, one {# character}, other {# characters}}'
    ],
    'es' => [
        'tooLong' => 'El valor no puede superar {maxLength, plural, one {un # caracter}, other {los # caracteres}}'
    ]
];

Let's create a registry to read from this file:

use Aphiria\Validation\ErrorMessages\IErrorMessageTemplateRegistry;

final class ResourceFileErrorMessageTemplateRegistry implements IErrorMessageTemplateRegistry
{
    private array $errorMessages;
    private string $defaultLocale;

    public function __construct(string $path, string $defaultLocale)
    {
        $this->errorMessages = require $path;
        $this->defaultLocale = $defaultLocale;
    }

    public function getErrorMessageTemplate(string $errorMessageId, string $locale = null): string
    {
        return $this->errorMessages[$locale][$errorMessageId] ?? $this->errorMessages[$this->defaultLocale][$errorMessageId];
    }
}

To use this registry, just pass it into your interpolator, and pass the interpolator into your validator.

use Aphiria\Validation\ErrorMessages\IcuFormatErrorMessageInterpolator;
use Aphiria\Validation\Validator;

$errorMessageTemplates = new ResourceFileErrorMessageTemplateRegistry('/resources/errorMessageTemplates.php');
$errorMessageInterpolator = new IcuFormatErrorMessageInterpolator($errorMessageTemplates);

// Assume we've configured the object constraints
$validator = new Validator($objectConstraints, $errorMessageInterpolator);

You can override the default error message ID of a constraint by passing one in via the constructor:

use Aphiria\Validation\Builders\ObjectConstraintsRegistryBuilder;
use Aphiria\Validation\Constraints\EmailConstraint;

$constraintsBuilder = new ObjectConstraintsRegistryBuilder();
$constraintsBuilder->class(User::class)
    ->hasPropertyConstraints('email', new EmailConstraint('email-invalid'));

Built-In Error Message Interpolators

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

If you do require i18n and are using the ICU format, IcuErrorMessageInterpolator is probably the better choice.

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 application builder library provides a convenience method for this:

use Aphiria\Configuration\Builders\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 application builder 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.