Skip to content

contains/doesnt_contain validation rules use inconsistent comparison strictness #61491

Description

@iz-ahmad

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. 

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions