[ Daryl Legion ]

Validating Array Inputs in Laravel Without the N+1

April 5, 2026 • 5 min read

When users submit a cart to a checkout endpoint, we have to assume the payload is hostile. A threat actor can inject arbitrary product IDs, IDs from draft or archived products, or IDs that belong to a different tenant, and we need to reject the request before any of that reaches the database or the payment processor. The catch is that the most obvious way to validate these inputs, using exists:products,id on each array item, quietly introduces an N+1 query pattern. In this blog post, we will look at how to validate nested array inputs safely without paying that cost.

Let’s take a look at an example checkout payload:

{
    "items": [
        { "product_id": 42, "quantity": 2 },
        { "product_id": 58, "quantity": 1 },
        { "product_id": 999999, "quantity": 1 }
    ]
}

For each item, we need to verify that the product_id exists and belongs to a published product. The 999999 in the example is the kind of thing a threat actor might drop in to probe for 500 errors or enumerate product IDs, so silently failing is not an option.

The naive approach: exists per item

The most straightforward form request rules might look like this:

// CheckoutRequest

public function rules(): array
{
    return [
        'items' => ['required', 'array', 'min:1'],
        'items.*.product_id' => [
            'required',
            'integer',
            Rule::exists('products', 'id')->where('status', 'published'),
        ],
        'items.*.quantity' => ['required', 'integer', 'min:1'],
    ];
}

As you can see in the code above, we are applying Rule::exists to each item in the items array. This works, and the errors are precise (we get items.2.product_id back with the exact field that failed). But there is a hidden cost: Laravel runs the exists rule once per array item. A cart with 20 items fires 20 separate SELECT COUNT(*) queries before validation even finishes.

For a small cart this is fine, but the cost scales linearly with the cart size, and every malicious request probing the endpoint pays the same price.

A better approach: after() with a single query

We can collapse those N queries into one by moving the existence check into an after() callback and using whereIn:

// CheckoutRequest

public function rules(): array
{
    return [
        'items' => ['required', 'array', 'min:1'],
        'items.*.product_id' => ['required', 'integer'],
        'items.*.quantity' => ['required', 'integer', 'min:1'],
    ];
}

public function after(): array
{
    return [
        function (Validator $validator) {
            $submittedIds = collect($validator->getData()['items'] ?? [])
                ->pluck('product_id')
                ->filter();

            $validIds = Product::query()
                ->whereIn('id', $submittedIds)
                ->where('status', 'published')
                ->pluck('id');

            if ($submittedIds->diff($validIds)->isNotEmpty()) {
                $validator->errors()->add(
                    'items',
                    'One or more of the submitted products are invalid.'
                );
            }
        },
    ];
}

This runs one query no matter how many items are in the cart. The trade-off is that we lose per-item precision: the user sees “one or more products are invalid” without knowing which line of their cart failed, which is frustrating UX and also worse for our own telemetry when debugging abuse patterns.

The sweet spot: prefetch in prepareForValidation()

We can have both the single query and the per-item error messages by prefetching the valid product IDs before the validation rules run, and then using an inline closure to check each item against that pre-fetched set:

// CheckoutRequest

protected array $validProductIds = [];

protected function prepareForValidation(): void
{
    $submittedIds = collect($this->input('items', []))
        ->pluck('product_id')
        ->filter()
        ->unique();

    $this->validProductIds = Product::query()
        ->whereIn('id', $submittedIds)
        ->where('status', 'published')
        ->pluck('id')
        ->all();
}

public function rules(): array
{
    return [
        'items' => ['required', 'array', 'min:1'],
        'items.*.product_id' => [
            'required',
            'integer',
            function (string $attribute, mixed $value, Closure $fail) {
                if (! in_array($value, $this->validProductIds, true)) {
                    $fail('The selected product is not available.');
                }
            },
        ],
        'items.*.quantity' => ['required', 'integer', 'min:1'],
    ];
}

Here is what is happening:

  • prepareForValidation() runs before rules(). We use it to pull the submitted product IDs out of the request and fetch the valid ones in one query, caching them on the request instance.
  • The inline closure in rules() checks each item’s product_id against that cached array in memory. No additional queries.
  • Because the closure is attached to items.*.product_id, errors are still reported per item: items.2.product_id gets its own error message pointing at the exact offending line.

One query, clean rules, and precise error paths.

What prepareForValidation() is actually for

The documented purpose of prepareForValidation() is input mutation. It is the hook Laravel provides for trimming strings, casting types, merging defaults, or stripping fields before the rules run. For example:

protected function prepareForValidation(): void
{
    $this->merge([
        'email' => strtolower(trim($this->input('email', ''))),
    ]);
}

Using it to prefetch lookup data is a practical extension of that idea. Strictly speaking, we are not modifying the input, we are caching context that the rules need. That is a mild deviation from its documented purpose, but it keeps all the “set up the data we are about to validate against” logic in one place, which is the same spirit.

If the deviation bothers you, the alternative is a custom rule class that prefetches in its constructor and exposes the check as a dedicated rule object. Either way, the pattern is the same: fetch the lookup set once, check array items in memory. That is what gets us safe, efficient nested array validation without N+1.