[ Daryl Legion ]

Validating Array Inputs in Laravel Without the N+1

April 5, 2026 • 8 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) {
            if ($validator->errors()->isNotEmpty()) {
                return;
            }

            $submittedIds = collect($validator->getData()['items'])
                ->pluck('product_id')
                ->unique();

            $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.'
                );
            }
        },
    ];
}

The early return is important: after() callbacks fire regardless of whether earlier rules passed, so without the guard, a payload that failed the integer rule could still pass raw strings into whereIn. On Postgres that crashes the request outright, and on MySQL it does something quieter and worse, silently matching nothing. With the guard, the query only runs against data that has already cleared the shape rules.

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: query in passedValidation()

We can have both the single query and the per-item error messages by letting Laravel’s own rules enforce the shape first — integer, max:100, and so on — and then running the existence check in passedValidation(), which only fires after all those rules have passed:

// CheckoutRequest

use Illuminate\Validation\ValidationException;

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

protected function passedValidation(): void
{
    $items = $this->validated()['items'];

    $submittedIds = collect($items)->pluck('product_id')->unique();

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

    $errors = [];

    foreach ($items as $index => $item) {
        if (! isset($validIds[(int) $item['product_id']])) {
            $errors["items.{$index}.product_id"] = 'The selected product is not available.';
        }
    }

    if ($errors) {
        throw ValidationException::withMessages($errors);
    }
}

Here is what is happening:

  • rules() handles the shape: the array must exist, have between 1 and 100 items, every product_id must be an integer, and every quantity must be a valid integer. No database queries run at this stage.
  • passedValidation() only fires after all those rules have passed, so by the time we build the whereIn, the data is already bounded to at most 100 IDs and every ID is an integer. There is no way an attacker can slip a 100,000-element array or a nested object into the query.
  • The existence check runs as a single whereIn. We flip the result so the valid IDs become array keys, which gives us O(1) lookups with isset. The (int) cast on that lookup is doing real work. Laravel’s integer rule is filter_var($value, FILTER_VALIDATE_INT), which happily accepts " 42", "42 " and "+42", while PHP only coerces canonical numeric strings into integer array keys. Without the cast, a padded ID clears validation, misses the isset, and the customer is told that a perfectly available product is unavailable. The database would have matched it: MySQL resolves where id in (' 42') straight to row 42. The two halves of the comparison disagree on exactly the inputs the cast normalizes.
  • We then loop through the items to attach per-item error messages via ValidationException::withMessages, which Laravel turns into the same error-bag response the rest of your validation produces. items.2.product_id still points at the exact offending line.

This gives us one query, per-item error paths, and a database lookup that only ever runs against data Laravel has already validated.

If the endpoint only ever receives JSON, Laravel 12 added a stricter form of the rule that removes the string-or-integer ambiguity at the source:

'items.*.product_id' => ['required', 'integer:strict'],

integer:strict compiles down to is_int($value), so 42 passes and "42" is rejected outright, which lets us drop the (int) cast. Do not reach for it on an endpoint that also accepts HTML form submissions, though. Everything arriving that way is a string, so nothing would ever pass.

Why not prepareForValidation()?

Another option is to run the prefetch in prepareForValidation() and cache the valid IDs on the request instance for a closure rule to consult. It works, but it gets the ordering wrong: prepareForValidation() runs before any rule has fired, so:

  • The integer rule on items.*.product_id has not run yet. $this->input('items') can contain strings, nested arrays, nulls, booleans. A nested array breaks the query on any driver, because PDO cannot bind it. A junk string is more interesting, because on MySQL nothing breaks at all: where id in ('abc') converts the string to 0, returns an empty set, and leaves behind a Warning 1292 that nobody reads. Strict mode does not change this, since strict mode governs writes rather than comparisons. PostgreSQL is the one that fails loudly, with invalid input syntax for type integer. Silently wrong on one driver and a hard error on the other is a worse pair of outcomes than either on its own.
  • The max:100 rule on items has not run yet either. An attacker can submit 100,000 IDs and we will happily whereIn all of them before the max rule fires. That is effectively querying the entire products table on every malicious request.

You can patch both by hard-capping the slice and filtering to int-castable values inside prepareForValidation(), but at that point you are re-implementing shape checks the validator would have done for free. passedValidation() avoids the problem because validation has already happened by the time it runs.

Validate first, query second. Anything that reaches the database should already have been through the validator.

UUID primary keys

If your products use UUID primary keys, swap integer for uuid in the rules:

'items.*.product_id' => ['required', 'uuid'],

The passedValidation() body stays the same apart from the (int) cast, which has to go: UUIDs are not numeric strings, so casting one yields 0 and every lookup misses. Drop it and compare the raw value.

if (! isset($validIds[$item['product_id']])) {
if (! isset($validIds[(int) $item['product_id']])) {

Everything else carries over. whereIn accepts a collection of UUID strings the same way it accepts integers, and the flip() + isset pattern works identically with string keys. The UUID path is actually the sturdier of the two, because Str::isUuid matches against an anchored pattern, so the padded values that slip past the integer rule cannot get through the uuid rule at all.

Beyond validation

This pattern handles the validation-specific N+1, but validation is only one layer of a real checkout endpoint. There are three follow-ups worth doing on top of it.

Fetch once, reuse in the controller. After passedValidation() passes, the controller will almost always load those same products again to compute prices, check inventory, or attach them to the order. That is the same query twice. We can avoid the second trip by fetching the full models in passedValidation() instead of just the IDs, caching them on the request, and exposing them to the controller:

protected array $products = [];

protected function passedValidation(): void
{
    $items = $this->validated()['items'];
    $submittedIds = collect($items)->pluck('product_id')->unique();

    $this->products = Product::query()
        ->whereIn('id', $submittedIds)
        ->where('status', 'published')
        ->get()
        ->keyBy('id')
        ->all();

    $errors = [];

    foreach ($items as $index => $item) {
        if (! isset($this->products[(int) $item['product_id']])) {
            $errors["items.{$index}.product_id"] = 'The selected product is not available.';
        }
    }

    if ($errors) {
        throw ValidationException::withMessages($errors);
    }
}

public function products(): array
{
    return $this->products;
}

The controller calls $request->products() and skips the refetch.

Lock rows at write time. passedValidation() is a pre-check, not a guarantee. Between the moment validation runs and the moment the order is written, a product can go out of stock, get unpublished, or have its price changed. For anything financial, the real protection is a transaction that locks the rows at commit time:

DB::transaction(function () use ($request) {
    $products = Product::query()
        ->whereIn('id', collect($request->validated('items'))->pluck('product_id'))
        ->where('status', 'published')
        ->lockForUpdate()
        ->get();

    // re-verify and create the order
});

Without this, two requests validating at the same time can both pass and both write, even though only one should have succeeded.

Scope by tenant. The intro called out IDs belonging to a different tenant as part of the threat model, but the whereIn query does not filter by tenant, so a threat actor who knows an ID from another tenant can still get past the check. The fix is to scope the query to the authenticated user’s tenant:

->whereIn('id', $submittedIds)
->where('tenant_id', $this->user()->tenant_id)
->where('status', 'published')

This belongs in the same query, not in a separate check, so the existence check and the tenant check share a single round trip.