Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[12.x] Introduce Rule::anyOf() for Validating Against Multiple Rule Sets #55191

Merged
merged 28 commits into from
Apr 8, 2025

Conversation

brianferri
Copy link
Contributor

Note

Non-Breaking Change
This PR does not modify any existing validation rules—it only introduces a new rule.

This PR introduces a new validation rule, Rule::anyOf(), allowing a field to be validated against multiple predefined rule sets, ensuring that at least one set fully passes. This feature is particularly useful for validating tagged unions, discriminator-based validation (as seen in OpenAPI specs), or alternative input structures in FormRequest validation.

Some keypoints on how this would benefit end users:

  • Simplifies Complex Validation: Instead of manually writing conditional rules like chained required_if, sometimes, or custom logic, anyOf() provides an expressive way to define alternative validation paths using objects which don't need to be necessarily flat maps of validation rules.
  • OpenAPI Compliance: Aligns Laravel validation with OpenAPI’s Discriminator Object, useful for API validation.
  • Improves Maintainability, Validation generation: Developers can declare validation rules in a structured way.

A practical example is the one I've mentioned in my discussion, with some additional attempts to implement this as a custom rule as well.

A sort of "emergent behavior" from this implementation is also the possibility of nested validations using nested AnyOf rules as well, which would allow for deep parsing of body parameters in FormRequests

I look forward to receiving feedback and fixing, implementing or improving the implementation in the event that the current one is not deemed worthy.

Might it also be worth it to implement oneOf and allOf in the event that this gets merged?

Example Usage

Regular rule declaration (Base case for anyOf)

The p1 value effectively determines what the "shape" of the object to be validated should be

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreWorkflowServices extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'params' => [
                'required',
                Rule::anyOf([
                    [
                        'p1' => ['required', Rule::in([ArrayKeysBacked::key_1])],
                        'p2' => ['required'],
                        'p3' => ['required', 'url:http,https'],
                        'p4' => ['sometimes', 'required'],
                    ],
                    [
                        'p1' => ['required', Rule::in([ArrayKeysBacked::key_2])],
                        'p2' => ['required', 'email:rfc'],
                    ]
                ])
            ]
        ];
    }
}

Regular rule declaration using dynamic sets from OpenAPI Spec [Discussion Example]

<?php

namespace App\Http\Requests;

use App\Rules\anyOf;
use App\Util\OpenAPIGenerator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use OpenAPI\Client\Model\ModelServices;
use OpenAPI\Client\Model\Params;

class StoreWorkflowServices extends FormRequest
{
    /**
     * @return ValidationRule[]
     */
    public static function list(): array
    {
        $modelServiceValues = array_map(fn($case) => $case->value, ModelServices::cases());
        /** @var ValidationRule[] */
        $classCases = [];
        foreach ($modelServiceValues as $modelService) {
            $paramsRules = [];
            $modelClass = '\\OpenAPI\\Client\\Model\\' . $modelService . 'Params';
            /** @var Params */
            $params = new $modelClass();
            $formats = $params->openAPIFormats();
            foreach ($params->openAPITypes() as $key => $type) {
                $paramsRules[$key] = ['required',  match ($key) {
                    Params::DISCRIMINATOR => Rule::in($params->getServiceAllowableValues()),
                    default => OpenAPIGenerator::mapTypeToRule($type, $formats[$key], $params->isNullable($key))
                }];
            }
            array_push($classCases, $paramsRules);
        }
        return $classCases;
    }

    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'params' => ['required', Rule::anyOf($this::list())]
        ];
    }
}

Nested rules (Which becomes emergent from the nature of the passes() method in anyOf validation)

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreWorkflowServices extends FormRequest
{
    /**
     * Get the validation rules that apply to the request.
     *
     * @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
     */
    public function rules(): array
    {
        return [
            'params' => ['required', Rule::anyOf([
                [
                    'p1' => ['required', Rule::anyOf([
                        [
                            'p2' => ['required', 'string'],
                            'p3' => ['required', Rule::anyOf([[
                                'p4' => ['nullable', 'string'],
                            ]])],
                        ],
                    ])],
                ],
            ])]
        ];
    }
}

brianferri and others added 19 commits March 8, 2025 22:53
* feat: add more validation tests

* wip: add failing test

* wip: add basic string rule validations

* chore: rename object fields for better debugging

* refactor: rename ruleSets to rules

* fix: respect array rule validation

---------

Co-authored-by: Christian Ascone <[email protected]>

* fix: this should be passing because AnyOf has no type relevance and 'required' only checks to see if the field has something in it

---------

Co-authored-by: Christian Ascone <[email protected]>

---------

Co-authored-by: Christian Ascone <[email protected]>
@taylorotwell
Copy link
Member

taylorotwell commented Mar 28, 2025

@brianferri One thing that is driving me kinda crazy with this PR is the p1 and p2 stuff in the tests. I have no idea what p1 and p2 means so it's hard to read them and understand them. Can you please make them more realistic and format them better so it's easier to skim through?

@taylorotwell
Copy link
Member

I dunno - something about this just doesn't sit right with me. I don't like the nested array syntax for defining anyOf rules as it's inconsistent with how nested rules are defined in other ways.

If I wanted to define a anyOf rule for a nested attribute I would want to just do this:

`user.profile.age' => ['required', Rule::anyOf(...)],

I just want to use this like a normal rule but it feels like it has all this special behavior I have to think about.

@taylorotwell taylorotwell marked this pull request as draft March 29, 2025 17:19
@brianferri
Copy link
Contributor Author

brianferri commented Mar 29, 2025

If I wanted to define a anyOf rule for a nested attribute I would want to just do this:

'user.profile.age' => ['required', Rule::anyOf(...)],

That is definitely still possible, I've added some assertions in the test to verify that.

The nesting tests I made we're mostly to make sure that the the validations don't break with nesting of different anyOf rules; As well as demonstrate the possibility of using anyOf as a way to make nested validations without having to necessarily use the dot notation. Should this behavior be prevented in some way instead? Maybe prevent recursive anyOf rules on instantiation; Though I find that limiting, and it'd go against the spec

@brianferri brianferri marked this pull request as ready for review April 1, 2025 12:40
@taylorotwell
Copy link
Member

taylorotwell commented Apr 1, 2025

I can't figure out why this passes... @brianferri

$v = Validator::make([
    'persons' => [
        [
            'age' => 12,
        ],
        [
            'age' => 'foobar',
        ],
    ],
], [
    'persons.*.age' => [
        'required',
        Rule::anyOf([
            ['min:10'],
            ['integer'],
        ]),
    ],
]);

@taylorotwell taylorotwell marked this pull request as draft April 1, 2025 18:46
@brianferri brianferri marked this pull request as ready for review April 7, 2025 09:47
@taylorotwell taylorotwell merged commit 08960db into laravel:12.x Apr 8, 2025
58 checks passed
@taylorotwell
Copy link
Member

Thanks!

taylorotwell added a commit to illuminate/translation that referenced this pull request Apr 8, 2025
…ets (#55191)

* [12.x] introduce `Rule::oneOf()` (#laravel/framework#54880)

* chore: apply styleCI

* feat: add nested oneOf validation test

* chore: apply styleCI

* refactor: rename `oneof` into `anyof` to fit implementation

* fix: wrong failure message

* feat: update base tests

* feat: add test case

* chore: apply styleCI

* formatting

* feat: allow string fields

* feat: add test and clean nested rules

* chore: apply styleCI

* failing test

* Validation tests (#1)

* feat: add more validation tests

* wip: add failing test

* wip: add basic string rule validations

* chore: rename object fields for better debugging

* refactor: rename ruleSets to rules

* fix: respect array rule validation

---------

Co-authored-by: Christian Ascone <[email protected]>

* fix: this should be passing because AnyOf has no type relevance and 'required' only checks to see if the field has something in it

---------

Co-authored-by: Christian Ascone <[email protected]>

---------

Co-authored-by: Christian Ascone <[email protected]>

* chore: correspond with recent changes brianferri/framework@de3b902

* chore: remove unused private property

* feat: attribute mapping in favor of potentially indexed mapping

* feat: add more tests

* refactor(tests): remove unnecessary amount of tests, rename parameter properties to be more descriptive/analogous to use cases

* chore: apply styleCI

* feat: add tests to verify compliance with dot notation nesting validator

* formatting

* fix: remove messages

* fix(wip): regression introduced in 14598f62bf8305bbe2a94ee4e2848d6ec2e05587

laravel/framework#55191 (comment)

* feat: implement star rule counter tests for simple and nested rules

Co-authored-by: Christian Ascone <[email protected]>

* chore: apply styleCI

---------

Co-authored-by: Taylor Otwell <[email protected]>
Co-authored-by: Christian Ascone <[email protected]>
taylorotwell added a commit to illuminate/validation that referenced this pull request Apr 8, 2025
…ets (#55191)

* [12.x] introduce `Rule::oneOf()` (#laravel/framework#54880)

* chore: apply styleCI

* feat: add nested oneOf validation test

* chore: apply styleCI

* refactor: rename `oneof` into `anyof` to fit implementation

* fix: wrong failure message

* feat: update base tests

* feat: add test case

* chore: apply styleCI

* formatting

* feat: allow string fields

* feat: add test and clean nested rules

* chore: apply styleCI

* failing test

* Validation tests (#1)

* feat: add more validation tests

* wip: add failing test

* wip: add basic string rule validations

* chore: rename object fields for better debugging

* refactor: rename ruleSets to rules

* fix: respect array rule validation

---------

Co-authored-by: Christian Ascone <[email protected]>

* fix: this should be passing because AnyOf has no type relevance and 'required' only checks to see if the field has something in it

---------

Co-authored-by: Christian Ascone <[email protected]>

---------

Co-authored-by: Christian Ascone <[email protected]>

* chore: correspond with recent changes brianferri/framework@de3b902

* chore: remove unused private property

* feat: attribute mapping in favor of potentially indexed mapping

* feat: add more tests

* refactor(tests): remove unnecessary amount of tests, rename parameter properties to be more descriptive/analogous to use cases

* chore: apply styleCI

* feat: add tests to verify compliance with dot notation nesting validator

* formatting

* fix: remove messages

* fix(wip): regression introduced in 14598f62bf8305bbe2a94ee4e2848d6ec2e05587

laravel/framework#55191 (comment)

* feat: implement star rule counter tests for simple and nested rules

Co-authored-by: Christian Ascone <[email protected]>

* chore: apply styleCI

---------

Co-authored-by: Taylor Otwell <[email protected]>
Co-authored-by: Christian Ascone <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants