Creating custom rules
When desired validation logic is missing in both built-in rules and extensions - it's time to create a custom rule.
Rules concept
The key feature of the rules' concept is a separation into 2 parts:
- Rule (a class implementing
RuleInterface
). It only stores configuration options and a reference to its handler. Does not perform actual validation. - Rule handler (a class implementing
RuleHandlerInterface
). Given a rule and input data, performs the actual validation within a current validation context.
Besides responsibilities' separation, this approach allows to automatically resolve dependencies for a handler. For example, if you need a database connection object within a handler, you don't have to pass it there explicitly - it can be automatically obtained from a dependency container.
Instructions for creating a custom rule and what to avoid
Let's try to create a rule for checking that a value is a valid RGB color.
Creating a rule
The 1st step is creating a rule:
use Yiisoft\Validator\RuleInterface;
final class RgbColor implements RuleInterface
{
public function __construct(
public readonly string $message = 'Invalid RGB color value.',
) {
}
public function getHandler(): string
{
return RgbColorHandler::class;
}
}
Note:
readonly properties are supported only starting from PHP 8.1.
Besides required interface method implementations it only contains customizable error message. Of course, more features can be added - conditional validation, client options, etc. But this is a bare minimum to start with.
Creating a handler
The 2nd step is creating the handler. Let's define what is exactly a valid RGB color:
- It's an array (list to be exact).
- Contains exactly 3 items.
- Each item is an integer number within 0 - 255 range.
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;
final class RgbColorHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
// Every rule handler must start with this check.
if (!$rule instanceof RgbColor) {
throw new UnexpectedRuleException(RgbColor::class, $rule);
}
if (!is_array($value) || array_keys($value) !== [0, 1, 2]) {
return (new Result())->addError($rule->getMessage());
}
foreach ($value as $item) {
if (!is_int($item) || $item < 0 || $item > 255) {
return (new Result())->addError($rule->getMessage());
}
}
return new Result();
}
}
Note:
A validate()
method is not intended to be called directly. Both resolving handler and calling the method happen automatically when using Validator
.
Tips for improving code
More specific error messages
Prefer more specific error messages to broad ones. Even this requires a bigger amount of messages and code, it helps to quicker understand what exactly is wrong with input data. RGB color is quite simple and compact structure, but in case of more complex data it will definitely pay off.
Keeping this in mind the rule can be rewritten something like this:
use Yiisoft\Validator\RuleInterface;
final class RgbColor implements RuleInterface
{
public function __construct(
public readonly string $incorrectInputTypeMessage = 'Value must be an array. {type} given.',
public readonly string $incorrectInputRepresentationMessage = 'Value must be a list.',
public readonly string $incorrectItemsCountMessage = 'Value must contain exactly 3 items. ' .
'{itemsCount} {itemsCount, plural, one{item} other{items}} given.',
public readonly string $incorrectItemTypeMessage = 'Every item must be an integer. {type} given at ' .
'{position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} position.',
public readonly string $incorrectItemValueMessage = 'Every item must be between 0 and 255. {value} given at ' .
'{position, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} position.',
) {
}
public function getHandler(): string
{
return RgbColorHandler::class;
}
}
Note:
readonly properties are supported only starting from PHP 8.1.
Note:
Formatting used in $incorrectItemTypeMessage
and $incorrectItemValueMessage
requires intl
PHP extension.
The handler needs to be changed accordingly. Let's also add error parameters to be able to use them as placeholders in message templates:
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;
final class RgbColorHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof RgbColor) {
throw new UnexpectedRuleException(RgbColor::class, $rule);
}
if (!is_array($value)) {
return (new Result())->addError($rule->getIncorrectInputMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'type' => get_debug_type($value),
]);
}
$itemsCount = 0;
foreach (array_keys($value) as $index => $keyValue) {
if ($keyValue !== $index) {
return (new Result())->addError($rule->getIncorrectInputRepresentationMessage(), [
'attribute' => $context->getTranslatedAttribute(),
]);
}
$itemsCount++;
}
if ($itemsCount !== 3) {
return (new Result())->addError($rule->getIncorrectItemsCountMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'itemsCount' => $itemsCount,
]);
}
foreach ($value as $index => $item) {
if (!is_int($item)) {
return (new Result())->addError($rule->getIncorrectItemTypeMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'position' => $index + 1,
'type' => get_debug_type($item),
]);
}
if ($item < 0 || $item > 255) {
return (new Result())->addError($rule->getIncorrectItemValueMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'position' => $index + 1,
'value' => $value,
]);
}
}
return new Result();
}
}
Note:
It's also a good idea to utilize the features of used language version. For example, for PHP >= 8.1 we can simplify checking that a given array is a list with array_is_list function.
Using built-in rules if possible
Before creating a custom rule, please check if it can be replaced with a built-in rule or set of rules. If so, it's unnecessary to create a custom rule.
Replacing with Composite
The example with RGB color can be significantly simplified after realizing that it's also possible to achieve the same effect by just using only built-in rules:
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Integer;
$rules = [
new Count(3),
new Each([new Integer(min: 0, max: 255)])
];
Making them reusable isn't much harder - the whole set can be placed inside a Composite
rule and used as a single regular rule.
use Yiisoft\Validator\Rule\Composite;
use Yiisoft\Validator\Rule\Count;
use Yiisoft\Validator\Rule\Each;
use Yiisoft\Validator\Rule\Integer;
use Yiisoft\Validator\Validator;
final class RgbColorRuleSet extends Composite
{
public function getRules(): array
{
return [
new Count(3),
new Each([new Integer(min: 0, max: 255)])
];
}
}
$result = (new Validator())->validate([205, 92, 92], new RgbColorRuleSet());
Replacing with separate rules and when
Below is an attempt at using validation context for validating attributes depending on each other:
- Validate company name only when the other attribute
hasCompany
name is filled. - The company name must be a string with a length between 1 and 50 characters.
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;
final class CompanyNameHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof CompanyName) {
throw new UnexpectedRuleException(CompanyName::class, $rule);
}
if ($context->getDataSet()->getAttributeValue('hasCompany') !== true) {
return new Result();
}
if (!is_string($value)) {
return (new Result())->addError($rule->getIncorrectInputMessage());
}
$length = strlen($value);
if ($length < 1 || $length > 50) {
return (new Result())->addError($rule->getMessage());
}
return new Result();
}
}
This custom rule can also be separated and refactored using built-in rules reducing coupling:
use Yiisoft\Validator\Rule\BooleanValue;
use Yiisoft\Validator\Rule\Length;
use Yiisoft\Validator\ValidationContext;
$rules = [
'hasCompany' => new BooleanValue(),
'companyName' => new Length(
min: 1,
max: 50,
when: static function (mixed $value, ValidationContext $context): bool {
return $context->getDataSet()->getAttributeValue('hasCompany') === true;
},
),
];
More examples
The idea for previous examples was to show the process of creating custom rules with handlers using "learn by mistakes" principle. So in terms of practical usage they probably less valuable because of replacement with built-in-rules. Knowing the core principles, let's explore more appropriate real-life examples.
Verifying YAML
There is built-in rule for validating JSON. But what if we need the same, but for YAML? Let's try to implement it.
Rule:
use Yiisoft\Validator\RuleInterface;
final class Yaml implements RuleInterface
{
public function __construct(
public readonly string $incorrectInputMessage = 'Value must be a string. {type} given.',
public readonly string $message = 'The value is not a valid YAML.',
) {
}
public function getHandler(): string
{
return YamlHandler::class;
}
}
Note:
readonly properties are supported only starting from PHP 8.1.
Handler:
use Exception;
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;
final class YamlHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof Yaml) {
throw new UnexpectedRuleException(RgbColor::class, $rule);
}
if (!is_string($value)) {
return (new Result())->addError($rule->getMessage(), [
'attribute' => $context->getTranslatedAttribute(),
'type' => get_debug_type($value),
]);
}
try {
$data = yaml_parse($value);
} catch (Exception $e) {
return (new Result())->addError($rule->getMessage(), [
'attribute' => $context->getTranslatedAttribute(),
]);
}
if ($data === false) {
return (new Result())->addError($rule->getMessage(), [
'attribute' => $context->getTranslatedAttribute(),
]);
}
return new Result();
}
}
Note:
Using yaml_parse additionally requires yaml
PHP extension.
Note:
Processing untrusted user input with yaml_parse()
can be dangerous with certain settings. Please refer to yaml_parse docs for more details.
Wrapping validation
One of the right usages of validation context can be wrapping validation with some additional logic. This can be used for implementing scenarios from Yii 2 for example.
use Yiisoft\Validator\Exception\UnexpectedRuleException;
use Yiisoft\Validator\Result;
use Yiisoft\Validator\RuleHandlerInterface;
use Yiisoft\Validator\ValidationContext;
final class OnHandler implements RuleHandlerInterface
{
public function validate(mixed $value, object $rule, ValidationContext $context): Result
{
if (!$rule instanceof On) {
throw new UnexpectedRuleException(On::class, $rule);
}
$scenario = $context->getParameter(On::SCENARIO_PARAMETER);
try {
$scenario = $this->prepareScenarioValue($scenario);
} catch (InvalidArgumentException) {
return (new Result())
->addError(
sprintf(
'Scenario must be null, a string or "\Stringable" type, "%s" given.',
get_debug_type($scenario),
),
);
}
return $this->isSatisfied($rule, $scenario)
// With active scenario, perform the validation.
? $context->validate($value, $rule->getRules())
// With all other scenarios, skip the validation.
: new Result();
}
}
This code snippet is taken from Yii Validator Scenarios extension by Sergei Predvoditelev. Read more in Scenarios section.
Making an extension
With a custom rule, you can go even further. If it's not too project-specific, and you feel that it might be useful to someone else - make it available as an extension.
Back to Yii Validator