Skip to content
intermediate Phase 13 · Laravel Framework Fundamentals

Validating Requests

Enforce input rules declaratively before processing.

45m
0 problems
Topic Progress 0%

The One Call That Guards Every Write: $request->validate()

Never trust the browser. A form can be filled by a script, an API client can send anything, and HTML required attributes are a courtesy to users, not a security control. Laravel's answer is declarative validation: you state the rules, and the framework enforces them before your business logic ever sees the data. The models from Querying with Eloquent ORM are only as trustworthy as the input that reaches them.

public function store(Request $request)
{
    $validated = $request->validate([
        'email' => 'required|email|unique:users,email',
        'password' => ['required', 'string', 'min:8', 'confirmed'],
        'plan' => ['required', Rule::in(['free', 'pro', 'team'])],
    ]);

    User::create($validated);
}

Read that carefully: one call both validates and returns the cleaned data. The rules are a field => rule-string map (or arrays of strings, which read better as they grow), and $request->validate() throws a ValidationException when anything fails — your code after the call is guaranteed to run on valid data only. No if ($validator->fails()) scaffolding, no chance of forgetting the check.

What does the framework do with that exception? For a browser request it redirects back to the previous page, flashing the errors and the old input into the session — which is exactly the machinery the Blade Templates and Layouts forms chapter consumes through $errors and old(). For a JSON request (Accept: application/json or an /api route) it returns HTTP 422 with a structured body:

{
    "message": "The given data was invalid.",
    "errors": {
        "email": ["The email has already been taken."],
        "password": ["The password confirmation does not match."]
    }
}

Same rules, two transports — a single validate() call covers your web forms and your API clients.

Rule names map directly to constraints: required (present and not empty), string, integer, numeric, email, date, boolean, min/max (size, depending on type), confirmed (matches a field_confirmation input), unique:table,column (database check), exists:table,column (must exist), and nullable (allow null, skip other rules when null). Rule order matters for one subtle reason: required makes presence mandatory; without it, a missing field passes every other rule silently. sometimes is the mirror image — apply rules only when the field is present, which is what you want for partial updates.

The database-backed rules deserve respect: unique and exists each run a query, so a form with five of them issues five queries before your controller does anything. On hot endpoints, rate-limit the route rather than letting a signup form be a cheap way to hammer the database — the rules themselves must run synchronously, so throttling is the lever you actually have.

A few rule details pay off under questioning. min/max change meaning by type: on a string they measure characters, on a numeric field the value itself, on an array the element count, on a file the kilobytes — 'max:2048' on an upload means 2 MB, not 2048 bytes. integer and numeric are different claims: numeric accepts "3.14" and "1e5", integer rejects them, and letting a float through an integer field is how a quantity of 1.5 reaches a shipping calculator. boolean accepts only true/false/0/1/"0"/"1" — a string "yes" fails, which is why API clients sending arbitrary strings trip well-meaning rules.

Error messages are localizable at the rule level: Laravel ships English messages for every built-in rule, and the lang/ directory lets you override any of them, per locale, per rule. Custom attributes via attributes() rename fields in every message at once — "The plan field is required" becomes "The subscription plan is required" without touching a single message string. When you build forms in more than one language, these two hooks are the whole integration.

Finally, validation runs before the controller body but after middleware — a rate limiter on your login endpoint protects the validator itself, keeping bots from turning your unique/email rules into a database stress tool. Ordering at every layer: middleware, then authorization, then validation, then business logic.

FormRequest: Rules That Outlive the Controller

Inline validate() calls are perfect until the same rules appear in three controller methods, or the rules grow past ten lines and bury the controller's actual job. A FormRequest extracts validation into its own class:

php artisan make:request StoreUserRequest
// app/Http/Requests/StoreUserRequest.php
class StoreUserRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user() !== null;   // e.g. $this->user()->can('create', Post::class)
    }

    public function rules(): array
    {
        return [
            'email' => ['required', 'email', 'max:255', 'unique:users,email'],
            'password' => ['required', 'string', 'min:8', 'confirmed'],
            'plan' => ['required', Rule::in(['free', 'pro', 'team'])],
        ];
    }

    public function messages(): array
    {
        return [
            'email.unique' => 'An account with that email already exists.',
            'password.min' => 'Passwords must be at least 8 characters.',
        ];
    }

    public function attributes(): array
    {
        return ['plan' => 'subscription plan'];
    }
}

Type-hint it on the controller method and Laravel resolves, validates, and rejects before the body runs:

public function store(StoreUserRequest $request)
{
    $user = User::create($request->validated());
    return redirect()->route('users.show', $user);
}

The two-method split is the design point. authorize() answers "may this user perform this action?" — an authorization question that runs before validation, so a forbidden user gets 403 without your rules being evaluated (and without leaking rule information). rules() answers "is this data acceptable?" — a data question. Keeping them separate mirrors how security reviews think.

$request->validated() returns only the keys that rules declared, which is your safe input to create() or update():

$request->validated();                  // only validated keys
$request->safe()->only(['email']);      // a ValidatedInput bag for partial reads
$request->safe()->except(['password']);

Prefer these over $request->all() every time — all() includes anything the client sent, including fields your rules never intended to accept. If someone adds ?is_admin=1 to the payload, validated() drops it while all() would forward it to the model (where $fillable is the last line of defense; two independent guards beat one).

FormRequests also integrate with the redirect flow automatically, so the Blade @error and old() machinery keeps working unchanged — the failure behavior is identical to inline validate(), only the organization differs.

FormRequests carry extra data needs without bloating the rules. prepareForValidation() runs before validation and is the place to normalize input — trim whitespace, lowercase an email, or merge a route parameter into the data being validated:

protected function prepareForValidation(): void
{
    $this->merge([
        'email' => strtolower(trim($this->input('email'))),
    ]);
}

Normalization before validation means your rules see the canonical form: an email trimmed of a trailing space passes email instead of failing mysteriously, and the stored value is already lowercase. Doing this after validation is how duplicates sneak into a case-insensitive-looking but case-sensitive unique constraint.

FormRequests also flash and redirect exactly like inline validate(), because underneath it is the same Validator — so the Blade half of the application does not know or care which style each endpoint used. And they compose with authorize() context: inside rules() you can still reach the route parameter via $this->route('post'), which is how update rules scope uniqueness to the record being edited without threading the id through manually.

For teams, the payoff shows up in review: a controller with StoreUserRequest type-hinted reads as "this action requires valid user data and the user may perform it", with the details one click away. Test them with a feature test that posts a boundary case and asserts 422 plus the exact error key — because the rules are ordinary code, proving a rejection costs three lines.

Conditional and Contextual Rules: rule(), after(), and Real-World Cases

The string rules cover the common cases; the interesting ones need context. Laravel's Rule object and the dynamic helpers carry that context, and knowing which tool matches which situation is the difference between rules that hold up in production and rules that pass the demo and fail on the second user.

Conditional required — a field is mandatory only when another field has a value:

use Illuminate\Validation\Rule;

public function rules(): array
{
    return [
        'type' => ['required', Rule::in(['personal', 'business'])],
        'company_name' => ['required_if:type,business', 'string', 'max:255'],
        'vat_number' => [Rule::requiredIf($this->input('type') === 'business'), 'string'],
    ];
}

required_if:field,value is the static form; Rule::requiredIf(closure) is the dynamic one for conditions you cannot express as a single field=value pair — a closure can read the payload, the route parameter, or the authenticated user. Related helpers: required_without, prohibited_if, and nullable. The distinction between required and nullable is a favorite interview follow-up: required rejects a missing or empty value, while nullable permits null and skips the remaining rules for that value — decide deliberately, because ['nullable', 'email'] accepts an absent email and ['required', 'email'] does not.

Unique that ignores the current row — the update case that every CRUD app hits:

'email' => ['required', 'email', Rule::unique('users', 'email')->ignore($this->route('user')->id)],

Without ->ignore(), saving your own profile fails because your email already exists — in your own row. For soft deletes, chain ->whereNull('deleted_at') so archived rows do not block re-registration. The same object style gives Rule::exists('regions', 'code') when a foreign key must reference a table you do not model; both run as one indexed query against the database, so the check reflects real state rather than a pattern match on the string.

Cross-field rules — when one field must relate to another:

'starts_at' => ['required', 'date'],
'ends_at' => ['required', 'date', 'after_or_equal:starts_at'],

after, before, and after_or_equal compare against another field, a literal date, or today. For comparisons against three or more fields, or any business rule the helpers cannot express, use the after hook — a closure that runs once every field rule passed and can add errors itself:

public function withValidator($validator)
{
    $validator->after(function (Validator $validator) {
        if ($this->input('seats') > 10 && ! $this->filled('sales_contact')) {
            $validator->errors()->add('sales_contact', 'Bookings over 10 seats need a sales contact.');
        }
    });
}

Partial updates — a PATCH must not require fields the client did not send:

public function rules(): array
{
    return [
        'email' => ['sometimes', 'email', 'max:255'],
        'bio' => ['nullable', 'string', 'max:1000'],
    ];
}

sometimes validates a key only when it is present in the payload, so an update that changes just the bio leaves email untouched instead of failing required. Pair it with $request->validated() and you get a true partial update: keys absent from the request are absent from the result, and update() never writes a column the client did not mention. For the opposite problem — a field where the first rule that fails should end the check rather than piling up confusing messages — prepend bail, as in 'password' => ['bail', 'string', 'min:8'].

Custom rule objects — when the same check appears in two forms, a string rule stops being maintainable:

php artisan make:rule SlugTakenUnlessArchived
class SlugTakenUnlessArchived implements ValidationRule
{
    public function validate(string $attribute, mixed $value, Closure $fail): void
    {
        if (Post::where('slug', $value)->where('archived', false)->exists()) {
            $fail('The slug is already used by a live post.');
        }
    }
}

Use it like any built-in: 'slug' => ['required', new SlugTakenUnlessArchived]. A self-contained rule you can unit-test on its own, compose anywhere, and read as English at the call site — the pattern interviewers recognize as the difference between knowing the API and knowing the design.

Everything in this chapter protects rows on the way in; it does nothing for the request that takes two seconds to answer. When validation passes and the work is slow, hand it off to Dispatching Queued Jobs.

Quiz

1. A controller calls $request->validate([...]) and the rules fail. What does the user's browser receive?

Question 1 options

2. An update endpoint uses $request->all() and passes the result to $user->update(). A client sends is_admin=1 alongside the legitimate fields. What is the correct fix?

Question 2 options

3. In a FormRequest, what is the correct division of labor between authorize() and rules()?

Question 3 options

4. A user updates their own profile and the email rule 'required|email|unique:users,email' fails with 'The email has already been taken', even though the email is unchanged. What is wrong?

Question 4 options

Flashcards

Question

What does $request->validate() return on success, and what does it do on failure?

Answer

On success it returns the validated data — only the keys declared in the rules. On failure it throws ValidationException: browser requests get a redirect back with errors and old input flashed; JSON requests get HTTP 422 with a {message, errors} body. Business logic after the call only ever sees valid data.

Question

Why prefer $request->validated() over $request->all() before create() or update()?

Answer

validated() filters to rule-declared keys, so undeclared payload fields (e.g. is_admin=1) are dropped at the request layer. all() forwards everything the client sent, relying solely on the model's $fillable as the last guard — one guard instead of two, and a mass-assignment incident waiting for a fillable mistake.

Question

What does a FormRequest's authorize() method control, and when does it run?

Answer

It answers whether the authenticated user may perform the action at all — returning false yields 403. It runs before rules(), so a forbidden request never has its data validated (and rule details do not leak). Keep it about permissions; put data constraints in rules().

Question

How do you validate an email on update so it does not match the user's own row?

Answer

Rule::unique('users', 'email')->ignore($this->route('user')->id) — the string form unique:users,email checks every row, including the record being saved, so an unchanged email fails. For soft-deleted rows blocking re-registration, add ->whereNull('deleted_at').

Question

When do you use required_if / Rule::requiredIf, after() hooks, and custom ValidationRule objects?

Answer

required_if/Rule::requiredIf for conditionally mandatory fields (company_name when type=business); the withValidator after() hook for cross-field or business rules the vocabulary cannot express, adding errors manually; custom ValidationRule classes when a check is reused across forms and should be unit-testable in isolation.

Revision Notes

Key Takeaways

  • 1. $request->validate() both validates and returns the cleaned data; failure throws ValidationException — a redirect with errors for browsers, 422 JSON for APIs
  • 2. Only validated keys should reach create()/update(); $request->all() forwards client-supplied junk and collapses your defense to a single $fillable check
  • 3. FormRequests split authorization (authorize(), 403 before validation) from data rules (rules()), and add messages()/attributes() for readable errors
  • 4. Unique-on-update needs ->ignore($id); conditional requirements use required_if or Rule::requiredIf; cross-field checks graduate to withValidator after() hooks
  • 5. Custom ValidationRule objects package reusable checks into testable classes, used like any built-in rule in the rules() array

Interview Tips

  • • "Where does validation belong?" — in a FormRequest, before the controller body, with authorization separated from data rules; then contrast with manual if-checks and explain the ValidationException flow
  • • "How does Laravel handle validation errors for an API versus a web form?" — same rules, different transport: 422 JSON with an errors map for JSON requests, redirect-back with flashed errors and old input for browsers
  • • "How do you prevent mass assignment through an update endpoint?" — $request->validated() feeding update(), with $fillable as the second layer; mention is_admin as the classic smuggling example
  • • "Give an example of a rule you could not express declaratively" — cross-record business logic in a withValidator after() hook or a custom ValidationRule class; naming the escape hatch shows you know both the vocabulary and its edges

Cheat Sheet

Validating Requests — Cheat Sheet

Inline (small forms):

$validated = $request->validate([
    'email' => ['required', 'email', 'max:255', 'unique:users,email'],
    'password' => ['required', 'string', 'min:8', 'confirmed'],
]);
User::create($validated);
  • Fails → redirect back + errors + old input (web) or 422 JSON (API)
  • Passes → returns only rule-declared keys

FormRequest (reusable rules):

php artisan make:request StoreUserRequest
// authorize(): may this user act? -> 403 before validation
// rules(): is the data acceptable?
// messages()/attributes(): humanize errors
public function store(StoreUserRequest $request) {
    return User::create($request->validated());
}

Contextual rules:

Rule::unique('users','email')->ignore($user->id)   // updates: skip own row
Rule::requiredIf($this->input('type') === 'business')
'ends_at' => 'after_or_equal:starts_at'            // cross-field
// withValidator($v) { $v->after(fn ($v) => ... ) } // business rules
' slug' => [new MyRule]                            // custom ValidationRule object

Display (Blade): old('email') to repopulate, @error('email') {{ $message }} @enderror to annotate, $errors->all() for a summary.

Rules you will use daily: required, string, integer, numeric, email, date, boolean, min/max, confirmed, nullable, sometimes, unique:table,col, exists:table,col, required_if, after_or_equal.

Continue Learning

Before You Start

Related Reading