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:

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

final class User
{
    public function __construct(
        public readonly int $id,
        #[Email]
        public readonly string $email,
        #[Required]
        public readonly string $name
    ) {}
}

Once you've set up your validator, validating a User instance is as simple as:

$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.

Creating A Validator

An instance of IValidator will already be bound to the DI container, which you can inject.

You can enable attributes in GlobalModule:

use Aphiria\Application\IApplicationBuilder;
use Aphiria\Framework\Application\AphiriaModule;

final class GlobalModule extends AphiriaModule
{
    public function configure(IApplicationBuilder $appBuilder): void
    {
        $this->withValidatorAttributes($appBuilder);
    }
}

You can manually scan for attributes:

use Aphiria\Validation\Constraints\Attributes\AttributeObjectConstraintsRegistrant;
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 attributes in production
$constraintCache = \getenv('APP_ENV') === 'production'
    ? new FileObjectConstraintsRegistryCache('/tmp/constraints.txt')
    : null;

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

$validator = new Validator($objectConstraints);

If you prefer to not use attributes, you can use a fluent syntax to manually register constraints instead.

You can use a component to register constraints:

use Aphiria\Application\IApplicationBuilder;
use Aphiria\Framework\Application\AphiriaModule;
use Aphiria\Validation\Constraints\EmailConstraint;
use Aphiria\Validation\Constraints\RequiredConstraint;
use Aphiria\Validation\ObjectConstraintsRegistryBuilder;

final class UserModule extends AphiriaModule
{
    public function configure(IApplicationBuilder $appBuilder): void
    {
        $this->withObjectConstraints($appBuilder, function (ObjectConstraintsRegistryBuilder $constraints) {
            $constraints
                ->class(User::class)
                ->hasPropertyConstraints('email', new EmailConstraint())
                ->hasPropertyConstraints('name', new RequiredConstraint());
        });
    }
}
use Aphiria\Validation\Constraints\EmailConstraint;
use Aphiria\Validation\Constraints\RequiredConstraint;
use Aphiria\Validation\ObjectConstraintsRegistryBuilder;
use Aphiria\Validation\Validator;

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

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. 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)) {
    // ... Throw an error
}

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)) {
    // ... Throw an error
}

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)) {
    // ... Throw an error
}

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)) {
    // ... Throw an error
}

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 Attribute Description
AlphaConstraint #[Alpha] The value must only contain alphabet characters
AlphanumericConstraint #[Alphanumeric] The value must only contain alphanumeric characters
BetweenConstraint #[Between] The value must fall in between two values (takes in whether or not the min and max are inclusive)
CallbackConstraint N/A The value must satisfy a callback that returns a boolean
DateConstraint #[Date] The value must match a date-time format
EachConstraint #[Each] The value must satisfy a list of constraints (takes in a list of IConstraint)
EqualsConstraint #[Equals] The value must equal a value
InConstraint #[In] The value must be in a list of acceptable values
IntegerConstraint #[Integer] The value must be an integer
IPAddressConstraint #[IPAddress] The value must be an IP address
MaxConstraint #[Max] The value cannot exceed a max value (takes in whether or not the max is inclusive)
MinConstraint #[Min] The value cannot go below a min value (takes in whether or not the min is inclusive)
NotInConstraint #[NotIn] The value must not be in a list of values
NumericConstraint #[Numeric] The value must be numeric
RegexConstraint #[Regex] The value must satisfy a regular expression
RequiredConstraint #[Required] The value must not be null

Custom Constraints

Let's say you want a custom constraint that enforces the max length of a string. Easy - just implement IConstraint.

use Aphiria\Validation\Constraints\IConstraint;

final class MaxLengthConstraint implements IConstraint
{
    public string $errorMessageId {
        get => 'Length cannot exceed {maxLength}';
    }

    public function __construct(private int $maxLength) {}

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

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

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

Let's set up an attribute for this constraint.

use Aphiria\Validation\Constraints\Attributes\ConstraintAttribute;
use Attribute;

#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_PROPERTY)]
final class MaxLength extends ConstraintAttribute
{
    public function __construct(public int $maxLength, string $errorMessageId = null)
    {
        parent::__construct($errorMessageId);
    }
    
    public function createConstraintFromAttribute(): MaxLengthConstraint
    {
        return new MaxLengthConstraint($this->maxLength);
    }
}

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

final class BlogPost
{
    #[MaxLength(32)]
    public string $title;
    #[MaxLength(1000)]
    public string $content;
}

Error Messages

Error messages provide human-readable explanations of what failed during validation. IConstraint contains 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 Length cannot exceed {maxLength}, and it provides a maxLength error message placeholder so that you can display the actual max in the error message.

If you're using IValidator::validate*() methods, you can grab the violations from the ValidationException:

use Aphiria\Validation\ValidationException;

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

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

    // 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->errorMessage;
    }

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

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

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

You can set aphiria.validation.errorMessageTemplates.type in config.php to use your desired error message template registry.

You can 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:

use Aphiria\Validation\Constraints\Attributes\Required;

final class BlogPost
{
    #[Required('title-invalid')]
    public string $title;
    #[Required('content-invalid')]
    public string $content;
}

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.

You can configure the interpolator to use by updating aphiria.validation.errorMessageInterpolator.type in config.php.

Validating Request Bodies

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