Yii Validator
The package provides data validation capabilities.
Features
- Could be used with any object.
- Supports PHP 8 attributes.
- Skip further validation if an error occurred for the same field.
- Skip validation of empty value.
- Error message formatting.
- Conditional validation.
- Could pass context to validation rule.
- Common rules bundled.
Requirements
- PHP 8.0 or higher.
JSON
PHP extension.mbstring
PHP extension.
Installation
The package could be installed with composer:
composer require yiisoft/validator
General usage
Library could be used in two ways: validating a single value and validating a set of data.
Validating a single value
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\Rule\Required;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Result;
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$rules = [
new Required(),
new Number(min: 10),
static function ($value): Result {
$result = new Result();
if ($value !== 42) {
$result->addError('Value should be 42.');
// or
$result->addError('Value should be {value}.', ['value' => 42]);
}
return $result;
}
];
$result = $validator->validate(41, $rules);
if (!$result->isValid()) {
foreach ($result->getErrorMessages() as $error) {
// ...
}
}
Validating a set of data
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\Validator;
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Result;
final class MoneyTransfer implements DataSetInterface
{
private $amount;
public function __construct($amount) {
$this->amount = $amount;
}
public function getAttributeValue(string $key){
if (!isset($this->$key)) {
throw new \InvalidArgumentException("There is no \"$key\" in MoneyTransfer.");
}
return $this->$key;
}
}
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$moneyTransfer = new MoneyTransfer(142);
$rules = [
'amount' => [
new Number(asInteger: true, max: 100),
static function ($value): Result {
$result = new Result();
if ($value === 13) {
$result->addError('Value should not be 13.');
}
return $result;
}
],
];
$result = $validator->validate($moneyTransfer, $rules);
if ($result->isValid() === false) {
foreach ($result->getErrors() as $error) {
// ...
}
}
Skipping validation on error
By default, if an error occurred during validation of an attribute, further rules for this attribute are processed. To
change this behavior, use skipOnError: true
when configuring rules:
use Yiisoft\Validator\Rule\Number;
new Number(asInteger: true, max: 100, skipOnError: true)
Skipping empty values
By default, missing and empty values are validated (if the value is missing, it's considered null
). That is
undesirable if you need a field to be optional. To change this behavior, use skipOnEmpty: true
.
Note that not every rule has this option, but only the ones that implement Yiisoft\Validator\SkipOnEmptyInterface
. For
example, Required
rule doesn't. For more details see "Requiring values" section.
use Yiisoft\Validator\Rule\Number;
new Number(asInteger: true, max: 100, skipOnEmpty: true);
What exactly to consider to be empty is vague and can vary depending on a scope of usage.
skipOnEmpty
value is normalized to callback automatically:
- If
skipOnEmpty
isfalse
ornull
,Yiisoft\Validator\EmptyCriteria\NeverEmpty
is used automatically as callback - every value is considered non-empty and validated without skipping (default). - If
skipOnEmpty
istrue
,Yiisoft\Validator\EmptyCriteria\WhenEmpty
is used automatically for callback - only passed and non-empty values (notnull
,[]
, or''
) are validated. - If custom callback is set, it's used to determine emptiness.
Using first option is usually good for HTML forms. The second one is more suitable for APIs.
The empty values can be also limited to null
only:
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\EmptyCriteria\WhenNull;
new Number(asInteger: true, max: 100, skipOnEmpty: new WhenNull());
For even more customization you can use your own class implementing __invoke()
magic method:
use Yiisoft\Validator\Rule\Number;
final class SkipOnZero
{
public function __invoke(mixed $value, bool $isAttributeMissing): bool
{
return $value === 0;
}
}
new Number(asInteger: true, max: 100, skipOnEmpty: new SkipOnZero());
or just a callable:
use Yiisoft\Validator\Rule\Number;
new Number(
asInteger: true,
max: 100,
skipOnEmpty: static function (mixed $value, bool $isAttributeMissing): bool {
return $value === 0;
}
);
For multiple rules this can be also set more conveniently at validator level:
use Yiisoft\Validator\RuleHandlerResolver\SimpleRuleHandlerContainer;
use Yiisoft\Validator\Validator;
$validator = new Validator(new SimpleRuleHandlerContainer(), skipOnEmpty: true);
$validator = new Validator(
new SimpleRuleHandlerContainer(),
skipOnEmpty: static function (mixed $value, bool $isAttributeMissing): bool {
return $value === 0;
}
);
Using $isAttributeMissing
parameter such as $context
also allows to check if attribute is missing / present:
$skipOnEmpty = static function (mixed $value, bool $isAttributeMissing): bool {
return $isAttributeMissing || $value === '';
};
Nested and related data
Basic usage
In many cases there is a need to validate related data in addition to current entity / model. There is a Nested
rule
for this purpose:
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Nested;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$data = ['author' => ['name' => 'Alexey', 'age' => '31']];
$rule = new Nested([
'title' => [new Required()],
'author' => new Nested([
'name' => [new HasLength(min: 3)],
'age' => [new Number(min: 18)],
)];
]);
$errors = $validator->validate($data, [$rule])->getErrorMessagesIndexedByPath();
Other configuration options
A dot notation can be used as an alternative way of configuration. In this case the example above will be presented as following:
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Nested;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;
$rule = new Nested([
'title' => [new Required()],
'author.name' => [new HasLength(min: 3)],
'author.age' => [new Number(min: 18)],
)];
It's also possible to combine both of these approaches:
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Nested;
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\Rule\Required;
$data = ['author' => ['name' => 'Alexey', 'age' => '31']];
$rule = new Nested([
'author' => new Nested([
'name' => [new HasLength(min: 3)],
'age' => [new Number(min: 18)],
)];
]);
Advanced usage
A more complex real-life example is a chart that is made of points. This data is represented as arrays. Nested
can be
combined with Each
rule to validate such similar structures:
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Nested;
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$data = [
'charts' => [
[
'points' => [
['coordinates' => ['x' => -11, 'y' => 11], 'rgb' => [-1, 256, 0]],
['coordinates' => ['x' => -12, 'y' => 12], 'rgb' => [0, -2, 257]]
],
],
[
'points' => [
['coordinates' => ['x' => -1, 'y' => 1], 'rgb' => [0, 0, 0]],
['coordinates' => ['x' => -2, 'y' => 2], 'rgb' => [255, 255, 255]],
],
],
[
'points' => [
['coordinates' => ['x' => -13, 'y' => 13], 'rgb' => [-3, 258, 0]],
['coordinates' => ['x' => -14, 'y' => 14], 'rgb' => [0, -4, 259]],
],
],
],
];
$rule = new Nested([
'charts' => new Each([
new Nested([
'points' => new Each([
new Nested([
'coordinates' => new Nested([
'x' => [new Number(min: -10, max: 10)],
'y' => [new Number(min: -10, max: 10)],
]),
'rgb' => new Each([
new Count(exactly: 3);
new Number(min: 0, max: 255),
]),
]),
]),
]),
]),
]);
$errors = $rule->validate($data, [$rule])->getErrorMessagesIndexedByPath();
The contents of the errors will be:
$errors = [
'charts.0.points.0.coordinates.x' => ['Value must be no less than -10.'],
// ...
'charts.0.points.0.rgb.0' => ['Value must be no less than 0. -1 given.'],
// ...
];
Using shortcut
A shortcut can be used to simplify Nested
and Each
combinations:
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Nested;
$rule = new Nested([
'charts.*.points.*.coordinates.x' => [new Number(min: -10, max: 10)],
'charts.*.points.*.coordinates.y' => [new Number(min: -10, max: 10)],
'charts.*.points.*.rgb' => [
new Count(exactly: 3);
new Number(min: 0, max: 255),
]),
]);
With additional grouping it can also be rewritten like this:
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Nested;
$rule = new Nested([
'charts.*.points.*.coordinates' => new Nested([
'x' => [new Number(min: -10, max: 10)],
'y' => [new Number(min: -10, max: 10)],
]),
'charts.*.points.*.rgb' => [
new Count(exactly: 3);
new Number(min: 0, max: 255),
]),
]);
This is less verbose, but the downside of this approach is that you can't additionally configure dynamically generated
Nested
and Each
pairs. If you need to that, please refer to example provided in "Basic usage" section.
Using keys containing separator / shortcut
If a key contains the separator (.
), it must be escaped with backslash (\
) in rule config in order to work
correctly. In the input data escaping is not needed. Here is an example with two nested keys named author.data
and
name.surname
:
use Yiisoft\Validator\Rule\Nested;
$rule = new Nested([
'author\.data.name\.surname' => [
new HasLength(min: 3),
],
]);
$data = [
'author.data' => [
'name.surname' => 'Dmitry',
],
];
Note that in case of using multiple nested rules for configuration escaping is still required:
use Yiisoft\Validator\Rule\Nested;
$rule = new Nested([
'author\.data' => new Nested([
'name\.surname' => [
new HasLength(min: 3),
],
]),
]);
$data = [
'author.data' => [
'name.surname' => 'Dmitriy',
],
];
The same applies to shortcut:
use Yiisoft\Validator\Rule\Nested;
$rule = new Nested([
'charts\.list.*.points\*list.*.coordinates\.data.x' => [
// ...
],
'charts\.list.*.points\*list.*.coordinates\.data.y' => [
// ...
],
'charts\.list.*.points\*list.*.rgb' => [
// ...
],
]);
$data = [
'charts.list' => [
[
'points*list' => [
[
'coordinates.data' => ['x' => -11, 'y' => 11], 'rgb' => [-1, 256, 0],
],
],
],
],
];
Using attributes
Basic usage
Common flow is the same as you would use usual classes:
- Declare property.
- Add rules to it.
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Nested;
use Yiisoft\Validator\Rule\Number;
final class Chart
{
#[Each([
new Nested(Point::class),
])]
private array $points;
}
final class Point
{
#[Nested(Coordinates::class)]
private $coordinates;
#[Count(exactly: 3)]
#[Each([
new Number(min: 0, max: 255),
])]
private array $rgb;
}
final class Coordinates
{
#[Number(min: -10, max: 10)]
private int $x;
#[Number(min: -10, max: 10)]
private int $y;
}
Here are some technical details:
- In case of a flat array
Point::$rgb
, a property typearray
needs to be declared.
Pass object directly to validate()
method:
use Yiisoft\Validator\ValidatorInterface;
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$coordinates = new Coordinates();
$errors = $validator->validate($coordinates)->getErrorMessagesIndexedByPath();
Traits
Traits are supported too:
use Yiisoft\Validator\Rule\HasLength;
trait TitleTrait
{
#[HasLength(max: 255)]
private string $title;
}
final class Post
{
use TitleTrait;
}
Callbacks
Callback::$callback
property is not supported, also you can't use callable
type with attributes. However,
Callback::$method
can be set instead:
<?php
declare(strict_types=1);
namespace Yiisoft\Validator\Tests\Stub;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\Rule\Callback;
use Yiisoft\Validator\ValidationContext;
final class Author
{
#[Callback(method: 'validateName')]
private string $name;
public static function validateName(mixed $value, object $rule, ValidationContext $context): Result
{
$result = new Result();
if ($value !== 'foo') {
$result->addError('Value must be "foo"!');
}
return $result;
}
}
Note that the method must exist and have public and static modifiers.
Limitations
Nested attributes
PHP 8.0 supports attributes, but nested declaration is allowed only in PHP 8.1 and above.
So attributes such as Each
, Nested
and Composite
are not allowed in PHP 8.0.
The following example is not allowed in PHP 8.0:
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Number;
final class Color
{
#[Each([
new Number(min: 0, max: 255),
])]
private array $values;
}
But you can do this by creating a new composite rule from it.
namespace App\Validator\Rule;
use Attribute;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Composite;
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class RgbRule extends Composite
{
public function getRules(): array
{
return [
new Each([
new Number(min: 0, max: 255),
]),
];
}
}
And use it after as attribute.
use App\Validator\Rule\RgbRule;
final class Color
{
#[RgbRule]
private array $values;
}
Function / method calls
You can't use a function / method call result with attributes. This problem can be overcome either with custom rule or
Callback::$method
property. An example of custom rule:
use Attribute;
use Yiisoft\Validator\FormatterInterface;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\Rule;
use Yiisoft\Validator\RuleInterface
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\ValidationContext;
final class CustomFormatter implements FormatterInterface
{
public function format(string $message, array $parameters = []): string
{
// More complex logic
// ...
return $message;
}
}
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class ValidateXRule implements RuleInterface
{
public function __construct(
private $value,
) {
}
public function getValue()
{
return $this->value;
}
}
final class Coordinates
{
#[Number(min: -10, max: 10)]
#[ValidateXRule()]
private int $x;
#[Number(min: -10, max: 10)]
private int $y;
}
Passing instances
If you have PHP >= 8.1, you can utilize passing instances in attributes' scope. Otherwise, again fallback to custom rules approach described above.
use Yiisoft\Validator\Rule\HasLength;
final class Post
{
#[HasLength(max: 255, formatter: new CustomFormatter())]
private string $title;
}
Requiring values
Use Yiisoft\Validator\Rule\Required
rule to make sure value is provided. What values are considered empty can be
customized via $emptyCallback
option. Normalization is not performed here, so only a callable or special class is
needed. For more details see "Skipping empty values" section.
use Yiisoft\Validator\Rule\Required;
use Yiisoft\Validator\EmptyCriteria\WhenNull;
$rules = [new Required(emptyCallback: new WhenNull())];
Conditional validation
In some cases there is a need to apply rule conditionally. It could be performed by using when()
:
use Yiisoft\Validator\Rule\Number;
use Yiisoft\Validator\ValidationContext;
new Number(
when: static function ($value, ValidationContext $context): bool {
return $context->getDataSet()->getAttributeValue('country') === Country::USA;
},
asInteger: true,
min: 100
);
If callable returns true
rule is applied, when the value returned is false
, rule is skipped.
Lazy or early exiting validation
When validation propagation is not needed \Yiisoft\Validator\Rule\StopOnError
rule may be used.
use Yiisoft\Validator\Rule\StopOnError;
new StopOnError([
new Rule1();
new Rule2();
new Rule3();
])
When the validator get negative result of validation it stop all the rest rules inside StopOnError
.
According to the example above, if Rule2
fails Rule3
won't even be tried to run.
Validation rule handlers
Creating your own validation rule handlers
Basic usage
To create your own validation rule handler you should implement RuleHandlerInterface
:
namespace MyVendor\Rules;
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\Exception\UnexpectedRuleException;use Yiisoft\Validator\FormatterInterface;use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;use Yiisoft\Validator\RuleInterface;
final class PiHandler implements RuleHandlerInterface
{
use FormatMessageTrait;
private FormatterInterface $formatter;
public function __construct(
?FormatterInterface $formatter = null,
) {
$this->formatter = $this->createFormatter();
}
public function validate(mixed $value, object $rule, ?ValidationContext $context = null): Result
{
if (!$rule instanceof Pi) {
throw new UnexpectedRuleException(Pi::class, $rule);
}
$result = new Result();
$equal = \abs($value - M_PI) < PHP_FLOAT_EPSILON;
if (!$equal) {
$result->addError($this->formatter->format('Value is not PI.'));
}
return $result;
}
private function createFormatter(): FormatterInterface
{
// More complex logic
// ...
return CustomFormatter();
}
}
Note that third argument in validate()
is an instance of ValidationContext
so you can use it if you need
whole data set context. For example, implementation might be the following if you need to validate "company"
property only if "hasCompany" is true:
namespace MyVendor\Rules;
use Yiisoft\Validator\DataSetInterface;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\Rule;
use Yiisoft\Validator\ValidationContext;
final class CompanyNameHandler implements Rule\RuleHandlerInterface
{
use FormatMessageTrait;
private FormatterInterface $formatter;
public function validate(mixed $value, object $rule, ?ValidationContext $context = null): Result
{
if (!$rule instanceof CompanyName) {
throw new UnexpectedRuleException(CompanyName::class, $rule);
}
$result = new Result();
$dataSet = $context->getDataSet();
$hasCompany = $dataSet->getAttributeValue('hasCompany') === true;
if ($hasCompany && $this->isCompanyNameValid($value) === false) {
$result->addError('Company name is not valid.');
}
return $result;
}
private function isCompanyNameValid(string $value): bool
{
// check company name
}
}
Note: Do not call handler's
validate()
method directly. It must be used via Validator only.
Resolving rule handler dependencies
Basically, you can use SimpleRuleHandlerResolver
to resolve rule handler.
In case you need extra dependencies, this can be done by ContainerRuleHandlerResolver
.
That would work with the following implementation:
final class NoLessThanExistingBidRuleHandler implements RuleHandlerInterface
{
use FormatMessageTrait;
private FormatterInterface $formatter;
public function __construct(
private ConnectionInterface $connection,
?FormatterInterface $formatter = null
) {
$this->formatter = $formatter ?? new Formatter();
}
}
public function validate(mixed $value, object $rule, ?ValidationContext $context): Result
{
$result = new Result();
$currentMax = $connection->query('SELECT MAX(price) FROM bid')->scalar();
if ($value <= $currentMax) {
$result->addError($this->formatter->format('There is a higher bid of {bid}.', ['bid' => $currentMax]));
}
return $result;
}
}
$ruleHandlerContainer = new ContainerRuleHandlerResolver(new MyContainer());
$ruleHandler = $ruleHandlerContainer->resolve(NoLessThanExistingBidRuleHandler::class);
MyContainer
is a container for resolving dependencies and must be an instance of
Psr\Container\ContainerInterface
. Yii Dependency Injection implementation also can
be used.
Yii config
Usinguse Yiisoft\Di\Container;
use Yiisoft\Di\ContainerConfig;
use Yiisoft\Validator\RuleHandlerResolverInterface;
use Yiisoft\Validator\RuleHandlerResolver\RuleHandlerContainer;
// Needs to be defined in common.php
$config = [
RuleHandlerResolverInterface::class => RuleHandlerContainer::class,
];
$containerConfig = ContainerConfig::create()->withDefinitions($config);
$container = new Container($containerConfig);
$ruleHandlerResolver = $container->get(RuleHandlerResolverInterface::class);
$ruleHandler = $ruleHandlerResolver->resolve(PiHandler::class);
Using common arguments for multiple rules of the same type
Because concrete rules' implementations (Number
, etc.) are marked as final, you can not extend them to set up
common arguments. For this and more complex cases use composition instead of inheritance:
use Yiisoft\Validator\RuleInterface;
final class Coordinate implements RuleInterface
{
private Number $baseRule;
public function __construct()
{
$this->baseRule = new Number(min: -10, max: 10);
}
public function validate(mixed $value, ?ValidationContext $context = null): Result
{
return $this->baseRule->validate($value, $context);
}
}
Rules
Passing single rule
use Yiisoft\Validator\Validator;
/** @var Validator $validator */
$validator->validate(3, new Number(min: 5));
Grouping multiple validation rules
To reuse multiple validation rules it is advised to group rules like the following:
use Yiisoft\Validator\Rule\HasLength;
use Yiisoft\Validator\Rule\Regex;
use \Yiisoft\Validator\Rule\Composite;
final class UsernameRule extends Composite
{
public function getRules(): array
{
return [
new HasLength(min: 2, max: 20),
new Regex('~[a-z_\-]~i'),
];
}
}
Then it could be used like the following:
use Yiisoft\Validator\Validator;
use Yiisoft\Validator\ValidatorInterface;
use Yiisoft\Validator\Rule\Email;
// Usually obtained from container
$validator = $container->get(ValidatorInterface::class);
$rules = [
'username' => new UsernameRule(),
'email' => [new Email()],
];
$result = $validator->validate($user, $rules);
if ($result->isValid() === false) {
foreach ($result->getErrors() as $error) {
// ...
}
}
Testing
Unit testing
The package is tested with PHPUnit. To run tests:
./vendor/bin/phpunit
Mutation testing
The package tests are checked with Infection mutation framework with Infection Static Analysis Plugin. To run it:
./vendor/bin/roave-infection-static-analysis-plugin
Static analysis
The code is statically analyzed with Psalm. To run static analysis:
./vendor/bin/psalm
Code style
Use Rector to make codebase follow some specific rules or use either newest or any specific version of PHP:
./vendor/bin/rector
Dependencies
Use ComposerRequireChecker to detect transitive Composer dependencies.
License
The Yii Validator is free software. It is released under the terms of the BSD License.
Please see LICENSE
for more information.
Maintained by Yii Software.