Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Yii Validator Change Log

## 2.6.2 under development
## 2.7.0 under development

- no changes in this release.
- New #824: Add `ValidationContext::validateInCurrentScope()` method to validate a value in the current scope (@vjik)
- Bug #824: Validate rules inside `StopOnError`, `Composite` and `AnyRule` in the current scope, same as without these
wrappers: keep the current data set and property in validation context, don't iterate over public properties of
an object value and don't call its `PostValidationHookInterface::processValidationResult()` (@vjik)

## 2.6.1 September 22, 2026

Expand Down
9 changes: 7 additions & 2 deletions docs/guide/en/creating-custom-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -417,14 +417,19 @@ final class OnHandler implements RuleHandlerInterface

return $this->isSatisfied($rule, $scenario)
// With active scenario, perform the validation.
? $context->validate($value, $rule->getRules())
? $context->validateInCurrentScope($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]
Note that `$context->validateInCurrentScope()` is used here instead of `$context->validate()`. Use
`validateInCurrentScope()` for rules grouping other rules, when the inner rules must be applied to the same property
as the wrapping rule: the data set, the property and other context data are kept as is. Use `validate()` for
validating other data (for example, a nested object or array) as a separate data set.

This code snippet is based on [Yii Validator Scenarios] extension by [Sergei Predvoditelev]. Read more in [Scenarios]
section.

## Making an extension
Expand Down
2 changes: 1 addition & 1 deletion src/Rule/AnyRuleHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c
}

foreach ($rule->getRules() as $relatedRule) {
$result = $context->validate($value, $relatedRule);
$result = $context->validateInCurrentScope($value, $relatedRule);
if ($result->isValid()) {
return $result;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Rule/CompositeHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c
throw new UnexpectedRuleException(Composite::class, $rule);
}

return $context->validate($value, $rule->getRules());
return $context->validateInCurrentScope($value, $rule->getRules());
}
}
2 changes: 1 addition & 1 deletion src/Rule/StopOnErrorHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public function validate(mixed $value, RuleInterface $rule, ValidationContext $c
}

foreach ($rule->getRules() as $relatedRule) {
$result = $context->validate($value, $relatedRule);
$result = $context->validateInCurrentScope($value, $relatedRule);
if (!$result->isValid()) {
return $result;
}
Expand Down
48 changes: 48 additions & 0 deletions src/ValidationContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Yiisoft\Validator;

use Closure;
use RuntimeException;
use Yiisoft\Arrays\ArrayHelper;
use Yiisoft\Strings\StringHelper;
Expand Down Expand Up @@ -38,6 +39,14 @@ final class ValidationContext
*/
private ?ValidatorInterface $validator = null;

/**
* @var Closure|null A callback validating a value according to a rule or a list of rules without changing the
* current scope of the context. `null` means context data was not set with {@see setContextDataOnce()} yet.
*
* @psalm-var (Closure(mixed, callable|iterable|RuleInterface, ValidationContext): Result)|null
*/
private ?Closure $currentScopeValidator = null;

/**
* @var mixed The raw validated data. `null` means context data was not set with {@see setContextDataOnce()} yet.
*/
Expand Down Expand Up @@ -90,6 +99,10 @@ public function __construct(
* is specified via {@see setPropertyTranslator()}, it will be used instead.
* @param mixed $rawData The raw validated data.
* @param DataSetInterface $dataSet Global data set ({@see $globalDataSet}).
* @param Closure $currentScopeValidator A callback validating a value according to a rule or a list of rules
* without changing the current scope of the context ({@see validateInCurrentScope()}).
*
* @psalm-param Closure(mixed, callable|iterable|RuleInterface, ValidationContext): Result $currentScopeValidator
*
* @internal
*
Expand All @@ -100,12 +113,14 @@ public function setContextDataOnce(
PropertyTranslatorInterface $propertyTranslator,
mixed $rawData,
DataSetInterface $dataSet,
Closure $currentScopeValidator,
): self {
if ($this->validator !== null) {
return $this;
}

$this->validator = $validator;
$this->currentScopeValidator = $currentScopeValidator;
$this->defaultPropertyTranslator = $propertyTranslator;
$this->rawData = $rawData;
$this->globalDataSet = $dataSet;
Expand Down Expand Up @@ -173,6 +188,38 @@ public function validate(mixed $data, callable|iterable|object|string|null $rule
return $result;
}

/**
* Validate a value according to a rule or a list of rules in the current scope: the data set, the property and
* other context data are kept as is. Useful for rules grouping other rules, such as {@see StopOnError}.
*
* The value is usually the one currently validated, but it could be another one as well (for example, a modified
* current value). In the latter case {@see PARAMETER_VALUE_AS_ARRAY} is not used for the value. Note that
* the context still describes the current property, for example, {@see isPropertyMissing()} checks the current
* property, not the passed value.
*
* @param mixed $value The validated value.
* @param callable|iterable|RuleInterface $rules A single rule or a list of rules to apply. Keys of the list are
* ignored: all rules are applied to the passed value.
*
* @psalm-param callable|RuleInterface|iterable<int, callable|RuleInterface> $rules
*
* @throws RuntimeException If validator is not set in validation context.
*
* @return Result Validation result.
*/
public function validateInCurrentScope(mixed $value, callable|iterable|RuleInterface $rules): Result
{
$this->requireValidator();

$currentParameters = $this->parameters;

try {
return ($this->currentScopeValidator)($value, $rules, $this);
} finally {
$this->parameters = $currentParameters;
}
}

/**
* Get the raw validated data.
*
Expand Down Expand Up @@ -331,6 +378,7 @@ public function isPropertyMissing(): bool
* Ensure that validator is set in validation context.
*
* @psalm-assert ValidatorInterface $this->validator
* @psalm-assert Closure $this->currentScopeValidator
* @psalm-assert DataSetInterface $this->globalDataSet
*
* @throws RuntimeException If validator is not set in validation context.
Expand Down
61 changes: 55 additions & 6 deletions src/Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@
use Yiisoft\Validator\Helper\DataSetNormalizer;
use Yiisoft\Validator\Helper\MessageProcessor;
use Yiisoft\Validator\Helper\RulesNormalizer;
use Yiisoft\Validator\Helper\RulesNormalizerIterator;
use Yiisoft\Validator\Helper\SkipOnEmptyNormalizer;
use Yiisoft\Validator\RuleHandlerResolver\SimpleRuleHandlerContainer;

use function extension_loaded;
use function is_int;
use function is_iterable;
use function is_string;

/**
Expand Down Expand Up @@ -123,7 +125,7 @@ public function validate(
?ValidationContext $context = null,
): Result {
$dataSet = DataSetNormalizer::normalize($data);
$originalData = $dataSet instanceof DataWrapperInterface ? $dataSet->getSource() : $data;
$originalData = $this->getOriginalData($dataSet);

$rules = RulesNormalizer::normalize(
$rules,
Expand All @@ -137,7 +139,13 @@ public function validate(

$context ??= new ValidationContext();
$context
->setContextDataOnce($this, $defaultPropertyTranslator, $data, $dataSet)
->setContextDataOnce(
$this,
$defaultPropertyTranslator,
$data,
$dataSet,
$this->validateInCurrentScope(...),
)
->setDataSet($dataSet);

$result = new Result();
Expand Down Expand Up @@ -167,7 +175,7 @@ public function validate(
$result->addErrorWithoutPostProcessing(
$this->messageProcessor->process($error),
$error->getParameters(),
$error->getValuePath(),
is_string($property) ? [$property, ...$error->getValuePath()] : $error->getValuePath(),
);
}
}
Expand Down Expand Up @@ -214,9 +222,6 @@ private function validateInternal(mixed $value, iterable $rules, ValidationConte

foreach ($ruleResult->getErrors() as $error) {
$valuePath = $error->getValuePath();
if ($context->getProperty() !== null) {
$valuePath = [$context->getProperty(), ...$valuePath];
}
match ($error->getMessageProcessing()) {
Error::MESSAGE_TRANSLATE => $compoundResult->addError($error->getMessage(), $error->getParameters(), $valuePath),
Error::MESSAGE_FORMAT => $compoundResult->addErrorWithFormatOnly(
Expand All @@ -235,6 +240,50 @@ private function validateInternal(mixed $value, iterable $rules, ValidationConte
return $compoundResult;
}

/**
* Validates a value according to a rule or a list of rules without changing the current scope of the validation
* context ({@see ValidationContext::validateInCurrentScope()}).
*
* @param mixed $value The validated value of any type.
* @param callable|iterable|RuleInterface $rules A single rule or a list of rules to apply.
* @param ValidationContext $context Validation context.
*
* @return Result The result of validation.
*/
private function validateInCurrentScope(
mixed $value,
callable|iterable|RuleInterface $rules,
ValidationContext $context,
): Result {
if (
$context->getProperty() === null
&& $value !== $this->getOriginalData($context->getDataSet())
) {
$context->setParameter(ValidationContext::PARAMETER_VALUE_AS_ARRAY, null);
}
return $this->validateInternal(
$value,
new RulesNormalizerIterator(
is_iterable($rules) ? $rules : [$rules],
$this->defaultSkipOnEmptyCondition,
),
$context,
);
}

/**
* Gets the original data from a data set.
*
* @param DataSetInterface $dataSet A data set to get the original data from.
*
* @return mixed The original data wrapped by the data set ({@see DataWrapperInterface}), or the data set itself
* when it's not a wrapper.
*/
private function getOriginalData(DataSetInterface $dataSet): mixed
{
return $dataSet instanceof DataWrapperInterface ? $dataSet->getSource() : $dataSet;
}

/**
* Acts like a pre-validation phase allowing to skip validation for specific rule within a set if any of these
* conditions are met:
Expand Down
Loading
Loading