laravellaravel-6laravel-validation

Validating array - get current iteration


I'm trying to validate a POST request using Laravel's FormRequest.

The customer is submitting an order, which has an array of items. We are requiring the user to indicate whether the item needs special_delivery only if the asking_price > 500 and the quantity > 10.

The following are my intended rules:

public function rules() {
    'customer_id' => 'required|integer|exists:customers,id',
    'items' => 'required|array',
    'items.*.name' => 'required|string',
    'items.*.asking_price' => 'required|numeric',
    'items.*.quantity' => 'required|numeric',
    'items.*.special_delivery' // required if price > 500 && quantity > 10
}

I've attempted to do something along these lines:

Rule::requiredIf($this->input('item.*.asking_price') > 500 && $this->input('item.*.quantity' > 10));

The problem with this is that I can't find a way to access the current items iteration index to indicate which item to validate against.

I also tried the following custom validation:

function ($attribute, $value, $fail) {

    preg_match('/\d+/', $attribute, $m);

    $askingPrice = $this->input('items')[$m[0]]['asking_price'];
    $quantity= $this->input('items')[$m[0]]['quantity'];

    if ($askingPrice > 500 && $quantity > 10) {
        $fail("$attribute is required");
    }
}

Although this function gives me access to the current $attribute,the problem is that it will only run if special_delivery exists. Which defeats the entire purpose!

Any help will be much appreciated! Thank you!


Solution

  • I might've come up with a solution to your problem, a index aware sometimes if you so will.

    Since it's unfortunately not possible to add macros to the Validator, you would either have to override the validation factory (that's what I suggest) and use your own custom validation class or make a helper function based off the method, pass the Validator instance as an additional parameter and use this instead of $this.

    Sauce first: the indexAwareSometimes validation function

    function indexAwareSometimes(
        \Illuminate\Contracts\Validation\Validator $validator,
        string $parent,
        $attribute,
        $rules,
        \Closure $callback
    ) {
        foreach (Arr::get($validator->getData(), $parent) as $index => $item) {
            if ($callback($validator->getData(), $index)) {
                foreach ((array) $attribute as $key) {
                    $path = $parent.'.'.$index.'.'.$key;
                    $validator->addRules([$path => $rules]);
                }
            }
        }
    }
    

    A lot of inspiration obviously came from the sometimes method and not much has changed. We're basically iterating through the array (the $parent array, in your case items) containing all our other arrays (items.*) with actual data to validate and adding the $rules (required) to $attribute (special_delivery) in the current index if $callback evaluates to true.

    The callback closure requires two parameters, first being the form $data of your parent validation instance, retrieved by Validator::getData(), second the $index the outer foreach was at the time it called the callback.

    In your case the usage of the function would look a little like this:

    use Illuminate\Support\Arr;
    
    class YourFormRequest extends FormRequest
    {
        public function rules()
        {
            return [
                'customer_id'          => 'required|integer|exists:customers,id',
                'items'                => 'required|array',
                'items.*.name'         => 'required|string',
                'items.*.asking_price' => 'required|numeric',
                'items.*.quantity'     => 'required|numeric',
            ];
        }
    
        public function getValidatorInstance()
        {
            $validator = parent::getValidatorInstance();
    
            indexAwareSometimes(
                $validator, 
                'items',
                'special_delivery',
                'required',
                fn ($data, $index) => Arr::get($data, 'items.'.$index.'.asking_price') > 500 &&
                    Arr::get($data, 'items.'.$index.'.quantity') > 10
            );
        }
    }
    

    Extending the native Validator class

    Extending Laravel's native Validator class isn't as hard as it sounds. We're creating a custom ValidationServiceProvider and inherit Laravel's Illuminate\Validation\ValidationServiceProvider as a parent. Only the registerValidationFactory method needs to be replaced by a copy of it where we specify our custom Validator resolver that should be used by the factory instead:

    <?php
    
    namespace App\Providers;
    
    use App\Validation\CustomValidator;
    use Illuminate\Contracts\Translation\Translator;
    use Illuminate\Validation\Factory;
    use Illuminate\Validation\ValidationServiceProvider as ParentValidationServiceProvider;
    
    class ValidationServiceProvider extends ParentValidationServiceProvider
    {
        protected function registerValidationFactory(): void
        {
            $this->app->singleton('validator', function ($app) {
                $validator = new Factory($app['translator'], $app);
    
                $resolver = function (
                    Translator $translator,
                    array $data,
                    array $rules,
                    array $messages = [],
                    array $customAttributes = []
                ) {
                    return new CustomValidator($translator, $data, $rules, $messages, $customAttributes);
                };
    
                $validator->resolver($resolver);
    
                if (isset($app['db'], $app['validation.presence'])) {
                    $validator->setPresenceVerifier($app['validation.presence']);
                }
    
                return $validator;
            });
        }
    }
    

    The custom validator inherits Laravel's Illuminate\Validation\Validator and adds the indexAwareSometimes method:

    <?php
    
    namespace App\Validation;
    
    use Closure;
    use Illuminate\Support\Arr;
    use Illuminate\Validation\Validator;
    
    class CustomValidator extends Validator
    {
        /**
         * @param  string  $parent
         * @param string|array $attribute
         * @param string|array $rules
         * @param Closure $callback
         */
        public function indexAwareSometimes(string $parent, $attribute, $rules, Closure $callback)
        {
            foreach (Arr::get($this->data, $parent) as $index => $item) {
                if ($callback($this->data, $index)) {
                    foreach ((array) $attribute as $key) {
                        $path = $parent.'.'.$index.'.'.$key;
                        $this->addRules([$path => $rules]);
                    }
                }
            }
        }
    }
    

    Then we just need to replace Laravel's Illuminate\Validation\ValidationServiceProvider with your own custom service provider in config/app.php and you're good to go.

    It even works with Barry vd. Heuvel's laravel-ide-helper package.

    return [
        'providers' => [
            //Illuminate\Validation\ValidationServiceProvider::class,
            App\Providers\ValidationServiceProvider::class,
        ]
    ]
    

    Going back to the example above, you only need to change the getValidatorInstance() method of your form request:

    public function getValidatorInstance()
    {
        $validator = parent::getValidatorInstance();
    
        $validator->indexAwareSometimes(
            'items',
            'special_delivery',
            'required',
            fn ($data, $index) => Arr::get($data, 'items.'.$index.'.asking_price') > 500 &&
                Arr::get($data, 'items.'.$index.'.quantity') > 10
        );
    }