Laravel / Application boundaries and reliable workflows / Lesson 8
Form Requests and trusted input boundaries
Use a Form Request to declare request-specific authorization and validation, then pass an explicit data shape into domain work.
On this page
Lesson goals
- Validation answers a specific question
- Declare only the writable fields
- Handle nested input and concurrent constraints carefully
Validation answers a specific question
Validation establishes whether submitted values satisfy declared rules for this operation. It does not prove the caller may perform the operation, that a referenced row will remain unchanged, or that a business transaction will succeed. Define field meaning, allowed sizes, optionality, and cross-field relationships before writing rules. A create request and a partial update can legitimately have different requirements. Laravel’s nullable and sometimes rules describe distinct conditions; do not add them everywhere to silence failures. Keep server-owned fields, such as the author id and moderation state, outside the client’s writable contract even when they look like ordinary model attributes.
Give the request an explicit lifecycle
A Form Request groups authorization and validation for a route operation. When the container resolves a type-hinted request for a controller action, the framework performs its request-validation lifecycle before that action runs. The authorize method can delegate to a policy, while rules describes admissible input. A failed authorization and a failed validation are different outcomes: the former is normally forbidden, while validation redirects a browser workflow with errors or returns the documented JSON error response when appropriate. Authentication middleware still decides whether a caller is logged in. Do not expect a controller’s first line to repair a request that never reached it.
Declare only the writable fields
The first class belongs in app/Http/Requests and assumes authenticated web routes plus a registered Post create policy. It accepts a bounded title and body, while authorization asks whether the current user can create a Post. The policy is introduced in the authorization lesson; its registration and decision must exist in the training app. No user_id or published rule is present because this endpoint does not accept those decisions from the client. Unknown top-level input is not automatically a validation error under these rules, so downstream code must deliberately consume only the validated allowlist rather than request all input.
<?php
namespace App\Http\Requests;
use App\Models\Post;
use Illuminate\Foundation\Http\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->can("create", Post::class) ?? false;
}
public function rules(): array
{
return [
"title" => ["required", "string", "max:120"],
"body" => ["required", "string", "max:5000"],
];
}
}
Authorized JSON request with missing title: validation response 422 with a title error.
A registered policy must permit creation before validation reaches the controller. The accepted write contract contains only bounded title and body.
Consume validated data without trusting ownership fields
The second controller uses validated title and body, creates through the authenticated user’s posts relation, and assigns published=false on the server. It assumes the User inverse relation, Post fillable fields, database default or explicit boolean column, and posts.show named route exist in the isolated training application. A malicious submitted user_id therefore does not choose the author in this code path. Validation and mass-assignment configuration are complementary defenses, not substitutes for an explicit write shape. A successful redirect is only the intended observation after the insert succeeds; no database execution or saved record is claimed by reading this fragment.
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StorePostRequest;
use Illuminate\Http\RedirectResponse;
class PostController extends Controller
{
public function store(StorePostRequest $request): RedirectResponse
{
$data = $request->validated();
$post = $request->user()->posts()->create([
"title" => $data["title"],
"body" => $data["body"],
"published" => false,
]);
return redirect()->route("posts.show", $post);
}
}
On a successful isolated insert: author is the authenticated user, published is false, then redirect to posts.show.
The existing inverse relation supplies the authenticated author. The server fixes publication state and requires a defined posts.show route; this is an application fragment, not an executed write.
Handle nested input and concurrent constraints carefully
For nested arrays, specify permitted array keys as well as rules for the nested values; validating a parent array alone can retain unexpected keys in validated output. Validation also does not normalize every accepted value into a domain type. An integer-valid textual value may still need deliberate conversion when constructing a typed command. A unique validation query is useful feedback but cannot prevent a concurrent conflicting insert by itself, so the database still needs the required unique constraint and an appropriate failure response. Test browser and JSON behavior separately and provide field-specific correction messages without leaking internal records or credentials.
Common errors and fixes
An authenticated request stops before the controller
- Why it happens
- Form Request authorization denies the operation.
- How to fix it
- Inspect the policy decision and request lifecycle.
- Prevent it next time
- Test authorization separately from valid payload rules.
A client changes a server-owned attribute
- Why it happens
- The action passes all request input into persistence.
- How to fix it
- Build the write shape from approved validated fields.
- Prevent it next time
- Test extra ownership and moderation fields.
Unexpected nested keys survive validation
- Why it happens
- Only the parent array or selected descendants were constrained.
- How to fix it
- Declare allowed array keys and consume a narrow shape.
- Prevent it next time
- Include extra nested keys in negative tests.
Check your understanding
1. Why must a unique database constraint remain even when a Form Request uses a unique rule?
Show worked solution
Two concurrent requests can both validate before either inserts. The database constraint arbitrates the actual write, while validation provides earlier feedback. Handle the constraint failure under the operation’s response contract rather than treating a prior validation pass as a reservation.
2. Design validation for preferences containing only locale and timezone.
Show worked solution
Constrain preferences with an allowed-key array rule, then validate preferences.locale and preferences.timezone under explicit accepted values or suitable rules. Reject additional keys and construct the domain value from the approved fields. A plain array rule does not establish that narrower contract.
Key takeaways
- Validation and authorization answer different questions.
- Validated output still needs a deliberate write shape.
- Database constraints close concurrency gaps.