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: query in passedValidation()
We can have both the single query and the per-item error messages by letting Laravel’s own rules enforce 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')
->all();
$errors = [];
foreach ($items as $index => $item) {
if (! in_array($item['product_id'], $validIds, true)) {
$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, everyproduct_idmust be an integer, and everyquantitymust 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 thewhereIn, the data is already bounded (at most 100 IDs) and typed (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 then walk the items to attach per-item error messages viaValidationException::withMessages, which Laravel turns into the same error-bag response the rest of your validation produces (items.2.product_idstill points at the exact offending line).
One query. Bounded by max:100. Typed by integer. Per-item error paths. And — critically — the database never sees data that has not already been validated.
Why not prepareForValidation()?
A common instinct 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
integerrule onitems.*.product_idhas not run yet.$this->input('items')can contain strings, nested arrays, nulls, booleans. On PostgreSQL or MySQL in strict mode, passing those values intowhereIncrashes the request with a database error before validation ever gets a chance to reject it cleanly. - The
max:100rule onitemshas not run yet either. An attacker can submit 100,000 IDs and we will happilywhereInall of them before themaxrule 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() sidesteps the whole problem because validation has already happened by the time it runs.
The rule is simple: 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, the rules change but the pattern does not. Swap integer for uuid:
'items.*.product_id' => ['required', 'uuid'],The passedValidation() body stays identical — whereIn accepts a collection of UUID strings the same way it accepts integers, and by the time that code runs, every submitted ID has already been validated as a well-formed UUID.