Inline validation inside controller actions feels fine when you're spinning up a quick prototype. You write $request->validate(['name' => 'required']), hit save, and move on. Six months later, your Next.js 16 frontend is sending complex JSON trees containing order line items, customer context, dynamic metadata, and discount codes. Your controller action now spans 180 lines, half of which is raw validation rules, error message strings, and manual array manipulations. That's a maintenance nightmare.
Form Requests in Laravel 12 provide a clean boundary to isolate HTTP validation from your domain logic. However, handling nested JSON payloads while maintaining reusable logic requires more than just listing array keys in the rules() method. You need custom rule objects, execution order tricks with request lifecycle methods, and isolated after-validation hooks.
Validating Nested Payloads from Modern Frontends
When React 19 components ship complex nested JSON payloads—like an invoice creation form with an array of items—you must validate both the structural envelope and the individual array elements. A naive implementation validates array items independently, but real-world domain rules often depend on sibling fields or parent attributes.
Consider an order payload generated by a Next.js 16 application:
{
"warehouse_id": 42,
"items": [
{
"product_id": 101,
"quantity": 5,
"unit_price": 1250
},
{
"product_id": 102,
"quantity": 1,
"unit_price": 4500
}
]
}Validating this payload requires checking that the warehouse exists, every array entry has positive integer values, and the combinations are valid. Notice how wildcard syntax (items.*.product_id) allows deep validation down the JSON tree. But what happens when you need to enforce a business rule across the entire collection, such as preventing duplicate product_id values in the same payload or verifying that total item counts don't exceed warehouse limits?
Creating Reusable Invokable Rules in PHP 8.3
Laravel's string-based validation rules like 'required|integer|min:1' work well for primitive constraints. But for business logic, string rules break down quickly. In PHP 8.3, custom rule objects implementing Illuminate\Contracts\Validation\ValidationRule offer strict typing and clean dependency injection.
Let's build a reusable rule that ensures an array of nested objects contains no duplicate key values. This rule accepts the target array key as a constructor argument, making it usable across any array payload in your application.
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class UniqueNestedKey implements ValidationRule
{
public function __construct(private string $key)
{
}
/**
* Run the validation rule.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_array($value)) {
return;
}
$extracted = array_column($value, $this->key);
$duplicates = array_filter(array_count_values($extracted), fn (int $count) => $count > 1);
if (! empty($duplicates)) {
$fail("The :attribute array contains duplicate {$this->key} entries.");
}
}
}By typing $fail as a Closure and utilizing PHP 8.3 property promotion in constructor arguments, the rule remains compact and easy to unit test using Pest or PHPUnit. You can pass this directly into your Form Request rules array alongside standard rules.
Preparing Payloads with PrepareForValidation
Frontend frameworks often serialize form data into unexpected types. Numbers might arrive as string representations (for example, "42" instead of 42), or optional text fields might be empty strings rather than null. Trying to handle type conversions in your service layer after validation leads to repetitive code.
The prepareForValidation() method runs before any rules execute. This gives you an opportunity to normalize incoming payloads, cast types, or inject context attributes derived from the HTTP headers or route parameters.
Here is a complete, production-grade Form Request class demonstrating payload preparation, custom rule application, and array validation:
<?php
namespace App\Http\Requests;
use App\Rules\UniqueNestedKey;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreOrderRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Order::class);
}
protected function prepareForValidation(): void
{
// Sanitize incoming payload before rules run
if ($this->has('items') && is_array($this->items)) {
$sanitizedItems = array_map(function ($item) {
if (isset($item['quantity'])) {
$item['quantity'] = (int) $item['quantity'];
}
return $item;
}, $this->items);
$this->merge([
'items' => $sanitizedItems,
]);
}
}
public function rules(): array
{
return [
'warehouse_id' => ['required', 'integer', Rule::exists('warehouses', 'id')],
'items' => ['required', 'array', 'min:1', new UniqueNestedKey('product_id')],
'items.*.product_id' => ['required', 'integer', Rule::exists('products', 'id')],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:100'],
'items.*.unit_price' => ['required', 'integer', 'min:0'],
];
}
public function after(): array
{
return [
function ($validator) {
if ($validator->errors()->isNotEmpty()) {
return;
}
// Perform cross-field validation after initial field rules pass
$warehouseId = $this->input('warehouse_id');
$items = $this->input('items', []);
$totalPrice = array_reduce($items, function ($sum, $item) {
return $sum + ($item['quantity'] * $item['unit_price']);
}, 0);
if ($totalPrice > 1000000) {
$validator->errors()->add(
'items',
'Orders exceeding $10,000 total price require senior manager approval.'
);
}
}
];
}
}Cross-Field Validation with the After Hook Method
In older Laravel versions, writing validation logic that ran after standard rules required overriding the withValidator() method and calling $validator->after(). Modern Laravel releases simplify this pattern by allowing you to define a dedicated after() method directly on your Form Request class.
The after() method returns an array of callables. Each callable receives the underlying validator instance. This design keeps cross-field validation rules isolated from field-level definitions in rules().
There is a key advantage to checking $validator->errors()->isNotEmpty() inside your after hook: it prevents expensive database queries or CPU-bound calculations from running when basic field constraints (like integer checks or existence checks) have already failed. In our order request example, calculating order totals or querying external services for inventory checks only happens when every item record contains valid fields.
The Validated Data Gotcha That Breaks Production
A trap that catches developers working with nested payloads involves the difference between $request->all(), $request->input(), and $request->validated().
When your controller processes the validated request, you should always pass $request->validated() into your actions, repositories, or DTOs. Never use $request->all(). However, validated() only returns keys that were explicitly declared in your rules() method array.
If you add calculated fields inside prepareForValidation()—for instance, setting 'currency' => 'USD'—those injected keys will be silently discarded by $request->validated() unless you add a matching rule entry for them in rules() (such as 'currency' => ['sometimes', 'string']).
Furthermore, when working with array wildcards like items.*.quantity, calling $request->validated('items') returns the full filtered array with all validated nested attributes intact. If an unvalidated parameter was injected by a client into a nested array item, Laravel strips it out, protecting your application from unexpected mass-assignment vulnerabilities.
Architectural Recommendations for Clean Validation
To keep validation maintainable across large projects, follow these pragmatic guidelines:
Keep controllers empty of validation rules: Typehint the Form Request in the controller action signature. The action should execute assuming payload integrity is guaranteed.
Isolate multi-attribute business logic in rule objects: If a rule involves database queries or complex matching against multiple values, encapsulate it in a class under
App\Rules.Use after hooks for aggregate payload rules: Do not bloat individual custom rule classes with global context rules. Put payload-wide checks in the
after()method.Expose helper methods on your Form Request: If your controller needs a custom Data Transfer Object (DTO) created from the input, write a
toDTO()method on the Form Request class itself. This keeps payload parsing encapsulated in the request layer.











