Skip to main content

Validating input — fixing the boundary with a Form Request

From here we move to the writing side.

There is no telling what data arriving from outside contains. A field that should be an integer may hold a string, a required key may be missing, and a nonexistent product ID may be specified. This chapter decides where such requests get stopped.

The order creation API​

We are building POST /api/v1/orders. The request takes this shape.

An example request
{
"customer_email": "taro.test@example.com",
"items": [
{ "product_id": 1, "quantity": 2 },
{ "product_id": 5, "quantity": 1 }
]
}

items being an array is the heart of what this chapter covers. One order holds several products, each with a product ID and a quantity.

This chapter does not use registered users

Customers are identified by customer_email. Chapter 11 introduces sign-up and login and switches to orders.user_id there. For now, read it as a guest placing an order.

Setting up the route and the controller​

Chapter 4 published only the two product read endpoints. Let us add a route for orders.

php artisan make:controller OrderController
routes/api.php
use App\Http\Controllers\OrderController;
use App\Http\Controllers\ProductController;

Route::apiResource('products', ProductController::class)->only(['index', 'show']);
Route::post('/orders', [OrderController::class, 'store']);

We define a single route with Route::post rather than apiResource because creation is all we need at this point. The order list and detail need authorization, so chapter 12 adds them.

If you set apiPrefix: 'api/v1' in chapter 4, the actual URL is POST /api/v1/orders.

First, write it directly in the controller​

app/Http/Controllers/OrderController.php
use Illuminate\Http\Request;

public function store(Request $request)
{
$validated = $request->validate([
'customer_email' => ['required', 'email'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
]);

// The order creation processing starts here...
}

$request->validate() stops processing on the spot if the rules are not satisfied. For a request to an API, back comes 422 Unprocessable Entity and JSON showing which field was rejected and why1.

A 422 response
{
"message": "The customer email field is required. (and 1 more error)",
"errors": {
"customer_email": ["The customer email field is required."],
"items.0.quantity": ["The items.0.quantity field must be at least 1."]
}
}

Errors inside an array come back under flat keys that include the position, as in items.0.quantity. The client can use that key to pinpoint which item on which row is the problem.

Extracting it into a Form Request​

Writing it inline in the controller works, but we move it out for two reasons.

One is that it obscures what the controller is actually for. Ten lines of validation rules bury the "create the order" processing beneath them.

The other is that you will want the same rules somewhere else. Build an order update API and you end up writing similar rules a second time.

Laravel provides a place to extract them to.

php artisan make:request StoreOrderRequest
app/Http/Requests/StoreOrderRequest.php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class StoreOrderRequest extends FormRequest
{
/**
* Whether the caller is allowed to make this request
*/
public function authorize(): bool
{
return true;
}

/**
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'customer_email' => ['required', 'email', 'max:255'],
'items' => ['required', 'array', 'min:1', 'max:20'],
'items.*.product_id' => ['required', 'integer', 'exists:products,id'],
'items.*.quantity' => ['required', 'integer', 'min:1', 'max:99'],
];
}
}

On the controller side, all you change is the argument type, from Request to StoreOrderRequest.

✅ The controller is left with only its real job
public function store(StoreOrderRequest $request)
{
$validated = $request->validated();

// The order creation processing starts here...
}

The moment you write that type on the argument, validation finishes before the controller body runs1. If the rules are not satisfied, a 422 comes back and store is never called. Inside the controller you can assume the data you have has passed validation.

$request->validated() extracts only the fields you wrote rules for. Extra keys mixed into the request are not included. Combined with the mass assignment defense from chapter 3, you get two layers of protection.

authorize is where "who" is decided​

authorize() returns whether the caller is allowed to make this request. Returning false gives a 403.

There is no authentication yet, so it returns true; in chapter 12 it takes this shape.

The form we write in chapter 12
public function authorize(): bool
{
return $this->user()->can('update', $this->order);
}

Do not write business rules here. Decisions like "is there enough stock?" or "has the order already been confirmed?" are not questions of permission. When authorize() returns false a 403 goes back, and the client reads it as "you do not have permission." An API that returns 403 for insufficient stock makes people misdiagnose the cause.

Validating arrays and nested data​

An array like items has its own notation.

RuleTargetMeaning
'items' => ['required', 'array']The array itselfPresent, and an array
'items' => ['min:1', 'max:20']The array itselfBetween 1 and 20 elements
'items.*.product_id' => [...]Each element's product_id* stands for every element

You need rules for both the array itself and its contents. Without array on items, the handling of a string like "items": "abc" is undefined.

For deeper nesting, join with ..

'shipping.address.postal_code' => ['required', 'regex:/^\d{3}-\d{4}$/'],

Put an upper bound on the element count​

The 'items' => ['max:20'] is deliberate.

With no upper bound, one request can send 100,000 lines. Validation alone loops 100,000 times, and the processing after it handles the same count. Setting an upper bound puts a ceiling on resource consumption at the input stage.

For the same reason, items.*.quantity has max:99. An e-commerce site where you can order a million units of one product is usually not the intended specification.

Hooking in before and after validation​

Tidying data with prepareForValidation​

Sometimes you want to adjust data before it is validated, such as trimming whitespace around an email address.

app/Http/Requests/StoreOrderRequest.php
protected function prepareForValidation(): void
{
if (is_string($this->input('customer_email'))) {
$this->merge([
'customer_email' => trim($this->input('customer_email')),
]);
}
}

Replacing a request value with merge() means the rules that follow see the adjusted value1.

The is_string() check matters. This method runs before the rules do. In other words, there is no guarantee yet that customer_email is a string. A request like {"customer_email": []} makes an unchecked trim() throw a TypeError. You would get a 500 where a 422 validation error is what belongs.

The right response is to check the type and, if it differs from what you expect, pass it through untouched. The email rule that follows will reject it with a 422.

Do formatting only here. Write processing that changes the meaning of a value and the data the client sent will diverge from the data stored, making causes hard to trace.

Validating across several fields with after​

Some rules cannot be decided from a single field. "Has the same product been split across two rows?" is one.

app/Http/Requests/StoreOrderRequest.php
use Illuminate\Validation\Validator;

/**
* @return array<int, callable>
*/
public function after(): array
{
return [
function (Validator $validator) {
$items = $this->input('items');

if (! is_array($items)) {
return;
}

$productIds = array_column($items, 'product_id');

if (count($productIds) !== count(array_unique($productIds))) {
$validator->errors()->add('items', 'You cannot list the same product on more than one row.');
}
},
];
}

The closures returned by after() are called once all the normal rules are done1. Adding an error blends it into the same 422 response as the others.

Here too we start with a type check. If items holds a non-array value, the array rule will already have flagged an error. This closure still runs anyway. Calling array_column() without the check gives a TypeError on a request like {"items": "abc"}.

Do not assume inside after() that the earlier rules passed. You can only assume what you checked yourself.

If the processing grows long, you can extract it into a class and return that.

public function after(): array
{
return [
new ValidateNoDuplicateProducts,
];
}

Customizing the error messages​

The default messages are Laravel's own wording. There are two ways to change them.

Messages for one specific request​

app/Http/Requests/StoreOrderRequest.php
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'items.required' => 'Specify at least one product to order.',
'items.max' => 'A single order can list at most 20 different products.',
];
}

The key takes the form field_name.rule_name1.

Changing only the displayed field name​

When you want to change only the field name rather than the whole message, use this.

/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'customer_email' => 'email address',
'items.*.quantity' => 'quantity',
];
}

The :attribute inside the message is replaced1. "The customer email field is required." becomes "The email address field is required."

When you want consistent wording across the whole application, put it in the language files instead. Adding messages() to each request scatters the same content around.

How much to validate in the Form Request​

This is the most important judgment in the chapter.

A Form Request handles only what can be decided by looking at the request on its own.

DecisionExampleWhere
Required, type, formatIs customer_email shaped like an email address?Form Request
RangeIs quantity between 1 and 99?Form Request
ExistenceDoes product_id actually exist in products?Form Request
Consistency within the requestIs the same product split across two rows?Form Request (after)
Is there enough stock?Is there at least 2 in stock for product 1?Not decided here
Has the order already been confirmed?Are we trying to change an order that has shipped?Not decided here

Why the stock check does not go in the Form Request​

"Is there enough stock?" looks writable. Build a custom rule and you can cross-reference items.*.product_id and items.*.quantity against stock.

We do not write it because the moment you check and the moment you actually decrement stock are not the same moment.

Time 1 Request A validates: stock is 1 → OK
Time 2 Request B validates: stock is 1 → OK
Time 3 Request A decrements stock: 0 left
Time 4 Request B decrements stock: -1 left

Even after checking during validation, another request may take the stock right afterwards. To prevent it, checking stock and decrementing it must be one indivisible operation. That is chapter 14's territory, with transactions and row locks.

Writing the stock check in the Form Request as well would reject many cases early. But it only rejects them early; it is not a guarantee of correctness. The guarantee lives on chapter 14's side, and that is the real line of defense. Write it in both places and readers can no longer tell which one the line of defense is.

Design theory on how to separate layers

If you want to frame "which validation goes in which layer" as an architectural question, Designing the presentation layer in A Practical Guide to Laravel × DDD × Clean Architecture covers it. This series does not separate layers; it decides where things go within Laravel's standard structure.

What matters in production​

The 422 response shape becomes unchangeable​

The error body is a contract the client reads. Client code depends on the structure of errors being an array keyed by field name.

Change that shape after publishing and error displays break. The safe move is to use Laravel's default shape as is. If you want a shape of your own, decide it once when chapter 9 settles the overall error design.

The exists rule issues a query​

'items.*.product_id' => ['exists:products,id'] issues one SELECT per array element. With 20 lines, that is 20 queries.

Setting an upper bound on the count limits the impact, but be aware of it when raising that bound. To check them together, you can issue a single whereIn inside after().

Do not confuse nullable and sometimes​

They look alike but mean different things.

  • nullable — the key is required, but its value may be null
  • sometimes — if the key is absent, no validation; if present, the other rules apply

For an update API where you want to update only the fields that were sent, sometimes is the one. Using nullable there leaves you in the halfway state of "not required, but validated" when the key is omitted.

Use only the data that passed validation​

❌ Bad: using a value that was not validated
$order = Order::create([
'customer_email' => $request->input('customer_email'),
'total_amount' => $request->input('total_amount'), // not in the rules
]);
✅ Good: use only what validated() returns
$validated = $request->validated();

$request->input() returns a value regardless of whether it was validated. Pull a field you never wrote a rule for and an unvalidated value goes straight into the database. An amount like total_amount in particular must never be the client's to decide. Calculate it on the server.

Summary​

  • Type-declaring a Form Request means validation is finished before the controller runs
  • $request->validated() returns only the fields you wrote rules for; extra keys are dropped
  • An array needs rules for both "the array itself" and "each element." Set an upper bound on the element count too
  • authorize() is for permission decisions only. Writing business rules there breaks what a 403 means
  • Do not check stock in the Form Request. Another request can slip in between the check and the update, so it cannot guarantee correctness. Chapter 14 handles it
  • The 422 response shape is a contract with the client. Do not change the default shape

The next chapter, Where business logic belongs, writes the body of this store method. Written straightforwardly, checking stock, creating the order, creating the lines, and sending mail all line up in the controller. We diagnose that and extract it into a service class. How Laravel's service container assembles dependencies is covered there too.

Exercises​

The following Form Request has a problem that breaks what the response means. Point it out
class UpdateOrderRequest extends FormRequest
{
public function authorize(): bool
{
$order = Order::find($this->route('order'));

return $order && $order->status === OrderStatus::Pending;
}

public function rules(): array
{
return [
'customer_email' => ['sometimes', 'email'],
];
}
}

Sample answer

There is a business rule written in authorize(). "Is the order in the Pending state?" is not a question of permission.

When authorize() returns false, back comes 403 Forbidden. To a client that means "you do not have permission to do this." What is actually happening is "this order has already shipped and cannot be changed," which has nothing to do with permission.

When a user gets in touch, support answers "it is a permissions problem." Since permissions are not the cause, the investigation goes nowhere.

The code for a conflicting state is 409 Conflict. Make that decision in the controller or the service class and throw a dedicated exception. Chapter 9 builds that shape.

There is also waste in writing Order::find() inside authorize(). With route model binding you can get it from $this->order, so you do not query the same row twice.

A request comes in to add a rule accepting total_amount on the order creation API. How do you respond?

Sample answer

Do not accept it. Store the amount the client calculated and it can be rewritten at will.

{
"customer_email": "taro.test@example.com",
"items": [{ "product_id": 1, "quantity": 100 }],
"total_amount": 1
}

You could order 100 units of a 100,000-yen product and have it stored with a total of 1 yen. Validating integer or min:0 cannot decide whether the value is correct. Only the server knows the correct amount.

Calculate the amount on the server. Look up the current price from product_id and multiply by quantity. The reason chapter 3 gave us order_items.unit_price is to pin that result down as the value at order time.

If the request comes from wanting to display the total on the client side too, the answer is a different endpoint. An API that only calculates and returns the amount can be used for display without affecting what gets stored.

The same judgment applies to applying discounts and spending points. Rather than validating a value the client sent, do not accept it at all — that is the baseline.


Footnotes​

  1. Source: Validation (Laravel documentation 13.x). On validation running before the controller thanks to the Form Request type declaration, the 422 response for XHR requests, validating arrays and nested data, and the prepareForValidation, after, messages, and attributes methods. ↩ ↩2 ↩3 ↩4 ↩5 ↩6