Skip to content

Conversation

@bonroyage
Copy link
Contributor

@bonroyage bonroyage commented Aug 13, 2021

Building upon the conditional rule support added in #38361, this PR adds support conditional rule merging through Rule::mergeWhen(bool|callable $condition, array|callable $rules). Rules for attributes are merged, not overwritten, in the example below state would be validated to string, size:2, in:NY,CA,TX.

request()->validate([
    'state' => 'string',
    'country' => ['required', 'size:2'],
    
    // Option 1: Directly as an array
    Rule::mergeWhen(fn($inputs) => $inputs['country'] === 'US', [
        'state' => ['size:2', Rule::in('NY', 'CA', 'TX', '...')],
        'zipcode' => ['integer', 'digits:5']
    ]),

    Rule::mergeWhen(fn($inputs) => $inputs['country'] === 'NL', [
        'state' => 'nullable',
        'zipcode' => ['string', 'regex:/^[A-Z]{4}\d{2}$/']
    ]),
    
    // Option 2: As a closure/callable
    Rule::mergeWhen(fn($inputs) => $inputs['country'] === 'US', fn() => [
        'state' => ['size:2', Rule::in('NY', 'CA', 'TX', '...')],
        'zipcode' => ['integer', 'digits:5'],
    ]),
]);

@bonroyage bonroyage changed the title Conditionally merge validation rules [8.x] Conditionally merge validation rules Aug 13, 2021
@taylorotwell
Copy link
Member

So in your example could you not have just used Rule::when within the attribute's list of rules?

@bonroyage
Copy link
Contributor Author

I've updated the example in the original comment with some additional conditions and attributes (I agree that adding just 1 attribute inside a mergeWhen has no real benefit).

The benefit for mergeWhen is in cases where based on a single condition, you want to add multiple attributes and/or add rules to multiple existing attributes. For the entire ruleset you want to merge, the condition is only called once, saving on potentially heavy computations or the need to store them in variables to reuse in multiple Rule::when() calls. Grouping the attributes/rules by the condition that adds them creates a hierarchy - similar to if statements - that can be easily followed by the user.

Below a more elaborate real-world example, chunks of a larger FormRequest (containing many more conditions).

// Currently written as:

public function rules()
{
    $rules = [
        'details.download' => ['nullable', 'file'],
        'groups'           => ['array'],
        'groups.*'         => 'required|exists:groups,id',
    ];
    
    if (Gate::allows('get-paid')) {
        $rules['is_paid'] = 'required|boolean';
    
        if ($this->boolean('is_paid')) {
            $rules = array_merge($rules, [
                'price'           => 'nullable|numeric|min:1|required_without:subscriptions',
                'subscriptions'   => 'nullable|array',
                'subscriptions.*' => 'required|exists:subscriptions,id'
            ]);
        }
    }
    
    if(app(Plan::class)->id === 'free') {
        $rules['details.download'][] = 'max:100000';
        $rules['groups'][] = 'max:1';
    }
    
    return $rules;
}

// Could be written as this with Rule::mergeWhen()

public function rules()
{
    return [
        'details.download' => 'nullable|file',
        'groups'           => 'array',
        'groups.*'         => 'required|exists:groups,id',
        
        Rule::mergeWhen(Gate::allows('get-paid'), [
           'is_paid' => 'required|boolean',
           
           Rule::mergeWhen(fn($input) => $input['is_paid'], [
               'price'           => 'nullable|numeric|min:1|required_without:subscriptions',
               'subscriptions'   => 'nullable|array',
               'subscriptions.*' => 'required|exists:subscriptions,id'
           ]),
        ]),
        
        Rule::mergeWhen(app(Plan::class)->id === 'free', [
            'details.download' => 'max:100000',
            'groups'           => 'max:1',
        ]),
    ];
} 

// Would have to be written as this with Rule::when().

public function rules()
{
    // Gate::allows() and app(Plan::class) could be stored in a variable 
    // here, but doesn't necessarily improve readability of the method.

    return [
        'details.download' => [
            'nullable', 
            'file',
            Rule::when(app(Plan::class)->id === 'free', 'max:100000'),
        ],
        'groups'           => [
            'array',
            Rule::when(app(Plan::class)->id === 'free', 'max:1'),
        ],
        'groups.*'         => 'required|exists:groups,id',
        'is_paid'          => Rule::when(Gate::allows('get-paid'), 'required|boolean'),
        'price'            => Rule::when(fn($input) => Gate::allows('get-paid') && $input['is_paid'], 'nullable|numeric|min:1|required_without:subscriptions'),
        'subscriptions'    => Rule::when(fn($input) => Gate::allows('get-paid') && $input['is_paid'], 'nullable|array'),
        'subscriptions.*'  => Rule::when(fn($input) => Gate::allows('get-paid') && $input['is_paid'], 'required|exists:subscriptions,id'),
    ];
} 

@taylorotwell
Copy link
Member

I dunno, is it me or is the plain Rule::when example the easiest to read? 😅

I can just look at one place to get all of the rules for a given attribute, instead of having to scan for merges.

@bonroyage
Copy link
Contributor Author

It's a subjective matter of preference I guess. Yes, the Rule::when is easy to read when all rules are centralised. On the other hand, they're decentralised from the condition that applies those rules, so you have to scan for all the places where the rules are applied because of that condition. It's my opinion in that in some scenarios mergeWhen would be more readable and in other scenarios the when, but like I said, it comes down to preference.

@taylorotwell
Copy link
Member

I think I'll hold off on this for now.

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