Laravel Version
13.30.1
PHP Version
8.3.33
Database Driver & Version
MySQL 8.0 (issue is related to validation logic, no db involved)
Description
While validating a field with both contains and doesnt_contain rules, we expect them to always be logical opposites.. that means, a value either contains a given item or it doesn't, never can be both. Instead, for certain loosely-typed array values, both rules pass at the same time on identical input, which shouldn't be possible.
The cause here is - validateContains and validateDoesntContain use inconsistent comparison strictness.. Both live in validateContains() (ValidatesAttributes.php:L568, loose comparison on L574) and validateDoesntContain() (ValidatesAttributes.php:L585, strict comparison on L591):
public function validateContains($attribute, $value, $parameters)
{
if (! is_array($value)) {
return false;
}
return array_all($parameters, fn ($parameter) => in_array($parameter, $value)); // here is the loose comparison
}
public function validateDoesntContain($attribute, $value, $parameters)
{
if (! is_array($value)) {
return false;
}
return array_all($parameters, fn ($parameter) => ! in_array($parameter, $value, true)); // here is the strict comparison
}
contains uses a loose in_array(), while doesnt_contain uses a strict in_array(..., true). So as they don't share the same comparison logic, a value like true in the array and a rule parameter like "1" can satisfy both rules at the same time: loosely, "1" == true is true, so contains:1 passes; whereas strictly, "1" !== true is also true, so doesnt_contain:1 also passes!
I checked at the git history regarding this and found that the two rules were briefly aligned before. PR #61318 made doesnt_contain strict specifically to close numeric-string loose-comparison collision.. and a companion PR (#61320) made contains strict too, which would have kept the two rules consistent, but it was reverted later (#61330). Per the discussion on #61320, @crynobone flagged that the strict change may cause a breaking change, because rule parameters (contains:1) always arrive as strings, while array values can be int/bool/float, so strict comparison against a string parameter stops matching non-string values.. it's the same class of issue that in rule's fix (#61146) had already solved by casting the value to string before comparing strictly. But contains never got that string-casting fix, so its strict attempt was reverted outright and it has remained loose since then..
Steps To Reproduce
use Illuminate\Support\Facades\Validator;
$data = ['flags' => [true]];
$contains = Validator::make($data, ['flags' => 'contains:1'])->passes();
$doesntContain = Validator::make($data, ['flags' => 'doesnt_contain:1'])->passes();
var_dump($contains); // true - "1" loosely equals true here
var_dump($doesntContain); // true - "1" is not strictly equals true
// this is the contradiction: a value can't both contain and not contain the "same" item
// expected: exactly one of these should pass.
Laravel Version
13.30.1
PHP Version
8.3.33
Database Driver & Version
MySQL 8.0 (issue is related to validation logic, no db involved)
Description
While validating a field with both
containsanddoesnt_containrules, we expect them to always be logical opposites.. that means, a value either contains a given item or it doesn't, never can be both. Instead, for certain loosely-typed array values, both rules pass at the same time on identical input, which shouldn't be possible.The cause here is -
validateContainsandvalidateDoesntContainuse inconsistent comparison strictness.. Both live invalidateContains()(ValidatesAttributes.php:L568, loose comparison on L574) andvalidateDoesntContain()(ValidatesAttributes.php:L585, strict comparison on L591):containsuses a loosein_array(), whiledoesnt_containuses a strictin_array(..., true). So as they don't share the same comparison logic, a value liketruein the array and a rule parameter like"1"can satisfy both rules at the same time: loosely,"1" == trueistrue, socontains:1passes; whereas strictly,"1" !== trueis alsotrue, sodoesnt_contain:1also passes!I checked at the git history regarding this and found that the two rules were briefly aligned before. PR #61318 made
doesnt_containstrict specifically to close numeric-string loose-comparison collision.. and a companion PR (#61320) madecontainsstrict too, which would have kept the two rules consistent, but it was reverted later (#61330). Per the discussion on #61320, @crynobone flagged that the strict change may cause a breaking change, because rule parameters (contains:1) always arrive as strings, while array values can beint/bool/float, so strict comparison against a string parameter stops matching non-string values.. it's the same class of issue thatinrule's fix (#61146) had already solved by casting the value to string before comparing strictly. Butcontainsnever got that string-casting fix, so its strict attempt was reverted outright and it has remained loose since then..Steps To Reproduce