Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Building Web Apps with Laravel  Module 7 – Validation, form requests and file uploads

Validation rules and error messages

Write Laravel 13 rule lists for text, numbers, dates, choices and nested rows, read validated data instead of all(), and translate every message.

  • Beginner
  • 15 minutes
  • Examples run with PHP 8.5.11, Composer 2.10.3, Laravel 13.34.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Validate text, numbers, dates, choices and nested rows with one rule list
  • Apply conditional rules such as sometimes, required_if and exclude_if
  • Read validated input with validated() and safe() instead of all()
  • Replace a message and an attribute name, and translate both into Hindi

Before you start

What you’ll learn · Before you start

What you will learn

  • Validate text, numbers, dates, choices and nested rows with one rule list
  • Apply conditional rules such as sometimes, required_if and exclude_if
  • Read validated input with validated() and safe() instead of all()
  • Replace a message and an attribute name, and translate both into Hindi

Before you start

On this page

An admission form posts four fields and three of them are wrong: a date as 05/01/2016, a batch called night, and seven seats where three are the limit. One rule list turns that into three messages, a redirect back to the form and the fields the parent already typed, and your controller never runs. The same payload sent by a mobile client gets a 422 with the same three messages as JSON.

This lesson writes that rule list and reads what it hands back. You will see which keys survive validation and which types they keep, how rules for dates, choices and nested rows differ from rules for text, how one field can switch another field’s rules on or remove it, why a field sometimes gets two messages and sometimes one, and how the same failures read in Hindi. Every run was recorded on Laravel 13.34.

One rule list decides what reaches your code

The shortest way to validate is to let the request do it. $request->validate([...]) applies the rules and returns the data when they pass; when a rule fails it throws, so the next line of the action is never reached. Here is the whole route for the admission form:

routes/web.php of the admission probe PHP · form-probe/routes/web.php
<?php

use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;

// The probe is a script with no browser, so it carries no forgery token; a real form route keeps PreventRequestForgery.

// The page the form sits on. It reads the errors the redirect left in the session.
Route::get('/admissions/create', function () {
    $errors = session('errors');

    return response(
        $errors === null
            ? "the form, with no errors\n"
            : "the form, with errors: ".json_encode($errors->getBag('default')->messages())."\n"
    );
})->name('admissions.create');

// One rule list, applied by the request itself. A failure never reaches the line that saves.
Route::post('/admissions', function (Request $request) {
    $validated = $request->validate([
        'name' => ['required', 'string', 'max:120'],
        'born_on' => ['required', 'date_format:Y-m-d'],
        'batch' => ['required', 'in:morning,evening'],
        'seats' => ['required', 'integer', 'between:1,3'],
    ]);

    return response()->json(['saved' => $validated]);
})->withoutMiddleware(PreventRequestForgery::class);

Follow the wrong post through it, then the same post with three fields corrected:

Interactive · stage by stage Where a wrong field stops Step through one post whose three fields are wrong, and watch where it stops.
Where a wrong field stops, and what the browser gets back, step 7 of 7. Showing The post arrives (three messages), Every rule runs (three fields fail), Laravel answers (302 or 422), validated() data, Your action runs. In focus: Your action runs.errors, old inputThe post arrivesthree messagesEvery rule runsthree fields failLaravel answers302 or 422validated() datafields with rulesYour action runsit saves the row

The drawing shows the whole scene; its 7 steps are listed below.

Every step as text
  1. Four fields of text. The form posts name, born_on, batch and seats. Every one of them is text, because a form body carries nothing else, and three of the four are wrong in a way no column type would catch.
  2. The rule list runs. The action begins with $request->validate([...]): one rule list for the four fields. The validator applies every rule of every field, not only the first field that fails.
  3. The answer is built for you. Three fields failed, so validate() throws a validation exception and the first line of your action never runs. Laravel turns that exception into the answer the request asked for.
  4. Back to the form, with help. A browser form gets 302 and a Location of the page it came from, and the messages and the fields the visitor typed are flashed into the session: errors for the three fields, old input for all four.
  5. The page reads them once. The page the redirect points at answered 200 and read the three messages out of the session. Flashed data lives for exactly one request, so a reload shows the form without them.
  6. With input that passes. Send born_on in the Y-m-d shape, batch as morning and seats as 2, and validate() returns instead of throwing. It hands back the four fields it had rules for, and nothing else the request carried.
  7. Your action gets the data. The action runs with that array: the keys it declared rules for, each still the text the form sent. seats is the string "2", not the number 2, because validation checks a value and never changes it.

The recorded run sends both, as a browser form and as JSON, and prints the status, the headers and what the session carries afterwards:

One wrong form, answered twice, then a form that passes Python · form_session.py
from session import run

# The same wrong admission form as a browser post and as a JSON post, through the real HTTP kernel.
run(
    """
php form.php
""",
    app=True,
    overlay=("form-probe",),
)

Output

$ php form.php
== a browser form with three wrong fields
  status    302
  Location      http://localhost/admissions/create
  Content-Type  text/html; charset=utf-8
  flashed   errors: {"default":{"format":":message","messages":{"born_on":["The born on field must match the format Y-m-d."],"batch":["The selected batch is invalid."],"seats":["The seats field must be between 1 and 3."]}}}
  flashed   old input: ["name","born_on","batch","seats"]
== the page the redirect points at
  status    200
  Content-Type  text/html; charset=utf-8
  body      the form, with errors: {"born_on":["The born on field must match the format Y-m-d."],"batch":["The selected batch is invalid."],"seats":["The seats field must be between 1 and 3."]}
== the same payload with Accept: application/json
  status    422
  Content-Type  application/json
  body      {"message":"The born on field must match the format Y-m-d. (and 2 more errors)","errors":{"born_on":["The born on field must match the format Y-m-d."],"batch":["The selected batch is invalid."],"seats":["The seats field must be between 1 a…
== input that passes every rule
  status    200
  Content-Type  application/json
  body      {"saved":{"name":"Meera Nair","born_on":"2016-01-05","batch":"morning","seats":"2"}}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 form_session.py

The browser form got a 302 with Location: http://localhost/admissions/create, the page it came from. Two things were flashed into the session: the error bag: The collection of validation messages for a request, keyed by field. For a browser form it is flashed into the session so the page can show it once. with one message for each of born_on, batch and seats, and the old input for all four fields, so the page can fill the boxes in again. The page itself answered 200 and read those three messages out of the session. The JSON request got a 422 whose body carries message and an errors object keyed by field, which is the shape every Laravel API answers with.

The last block is the interesting one. The form that passes returns {"saved":{"name":"Meera Nair","born_on":"2016-01-05","batch":"morning","seats":"2"}}. The seats value is still the string "2", although the field has the integer rule. The next section’s probe shows the other half of the same point: a key the request carried but no rule named does not appear in the data at all.

Key idea

validated data: The array a validator hands back: only the fields that had rules, holding the values the request carried, with no key the rules did not declare. is the array of the fields you wrote rules for, holding the values that arrived. A rule decides whether a value is acceptable; it never changes the value, and it never carries a key you did not declare.

That is why validated(), or safe()->only([...]) when you want a subset, is what you pass on, and $request->all() is not. Everything a caller sends is in all(), including fields you never put in the form, and handing that array to a model is the mass assignment: Creating or updating a model from a whole array of fields in one call, which is why a model declares the columns such an array is allowed to write. More in “Your first CRUD feature with Eloquent” weakness that OWASP devotes a cheat sheet to.

Checkpoint

Check: What a rule list returns

  1. Question 1 of 2 The rules name name, born_on, batch and seats. The request also carries referrer. Input passes. Which statements about validated() match the recorded run?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • seats is the string "2", not the number 2
    • referrer is not in the array

    validated() answers two questions at once: which keys you declared, and what they held. It drops everything else, which is why it is safer than all(), and it casts nothing, which is why a typed cast or an Eloquent cast still has work to do.

  2. Question 2 of 2 The same wrong payload is posted twice: once as a browser form, once with Accept: application/json. What did each answer with in the recorded run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 302 to the form page for the browser, 422 with an errors object for JSON

    validate() throws a validation exception and the framework turns it into the answer the caller can read: a redirect back with flashed errors for a form, a 422 with a message and an errors object for JSON.

Rules for text, numbers, dates, choices and nested arrays

A validation rule: One named check applied to one field, such as digits:6 or a rule object. It reports a message and never changes the value it checked. is one named check. A field takes a list of them, in order, and every rule of every field is applied, so one post can report a problem in each field at once. The probe below runs six fields against this list:

Field Rules What it refuses
name required, string, max:120 text with no bound, or no name at all
born_on required, date_format:Y-m-d, before:2020-01-01 a date in another shape, or too recent
batch required, Rule::in(['morning', 'evening']) any batch that does not exist
seats required, integer, between:1,3 text, and a count outside the limit
items sometimes, array, max:5 more rows than the form allows
items.*.qty required, integer, min:1 a row whose quantity is text or zero
Six fields, four of them wrong, then input that passes Python · rules_session.py
from session import run

# One rule list for an admission form, run against the validator itself: the messages it produces and what it keeps.
run(
    """
php rules.php
""",
    app=True,
    overlay=("rules-probe",),
)

Output

$ php rules.php
== a PIN code of four digits
  message   The pin field must be 6 digits.
== a password of three letters
  message   The password field must be at least 8 characters.
  message   The password field must contain at least one number.
== six fields, four of them wrong
  message   The name field must not be greater than 120 characters.
  message   The born on field must match the format Y-m-d.
  message   The selected batch is invalid.
  message   The items.0.qty field must be an integer.
== the same input, named for a reader
  message   The name field must not be greater than 120 characters.
  message   The date of birth field must match the format Y-m-d.
  message   Choose the morning or the evening batch.
  message   The number of books field must be an integer.
== input that passes every rule
  kept      name      string "Meera Nair"
  kept      born_on   string "2016-01-05"
  kept      batch     string "morning"
  kept      seats     string "2"
  kept      items     array [{"qty":"3"}]
== two rules fail on one field
  message   The pin field must be 6 digits.
  message   The pin field must be a number.
== the same field with bail
  message   The pin field must be 6 digits.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rules_session.py

Read the messages of the third block. A date in the wrong shape reported The born on field must match the format Y-m-d. and nothing about before, though both rules ran. For text that does not fit the declared format, before hands it to PHP’s own date parser, which reads slashes month first: 05/01/2016 became 2016-05-01, earlier than the limit, so that rule passed and only the format rule caught the slip. The batch reported The selected batch is invalid., which is the sentence every in rule uses, and the nested row reported The items.0.qty field must be an integer. — the field path with the row number in it, which is accurate and unreadable; the section on messages below replaces it.

Two rules deserve a closer look. digits:6 on 4110 answered The pin field must be 6 digits., so a PIN code needs no regular expression. Password::min(8)->letters()->numbers() on abc answered twice, once for the length and once for the missing digit, and said nothing about letters, because abc has them. A rule object like this one builds its own list of requirements, and reports every requirement the value misses, so the visitor can fix them in one attempt.

A numeric string passes integer

seats arrived as the text "2" and the integer rule accepted it, because a form body has no numbers. The rule asks whether the value is an integer as PHP reads it, which a numeric string is. The value you receive is still text.

For nested data, write rules at both levels: on the container, so its size is bounded, and on the star path, so every row is checked. items.*.qty applies to row 0, row 1 and any row a caller invents.

The block that passes also sent a referrer key, which no rule named. It is not in the five kept lines: a key with no rule is dropped rather than kept as null, and that is the difference between the validated data and the whole payload.

Rules that switch on because of another field

Most forms have fields that only matter in some cases. A conditional rule: A rule that reads another field before it applies, such as required_if, exclude_if or sometimes, so one rule list can describe a form whose fields depend on each other. reads the rest of the payload and decides. Three of them cover nearly everything: required_if demands a field when another field has a value, exclude_if removes a field in that case, and sometimes applies the rest of a field’s rules only when the key is present at all.

Interactive · stage by stage Transport decides what happens to the stop Step through the four payloads and watch which rule switches on.
How transport decides what happens to the stop, step 6 of 6. Showing What was sent (guardian_phone sent), required_if (the regex runs), exclude_if (the stop is dropped), validated() keys (batch, transport). In focus: required_if.nothing sentWhat was sentguardian_phone sentrequired_ifthe regex runsexclude_ifthe stop is droppedvalidated() keysbatch, transport

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Transport asked for, no stop. The payload says transport: 1 and carries no stop. The stop field has required_if:transport,1, so the rule switches on and the form comes back with one message.
  2. A stop that is named. Send the same payload with stop: Kacheripady and every rule passes. validated() hands back batch, transport and stop, so the action can save the stop.
  3. No transport, a stop anyway. Now transport: 0 with a stop still in the payload. exclude_if:transport,0 removes the field before the other rules read it, so max:60 never runs on it.
  4. The dropped field is gone. validated() returns batch and transport only. A field that exclude_if removed cannot reach your action, which is the point: a stop without transport would be a row nobody reads.
  5. A field nobody sent. The guardian phone has sometimes, so when the payload has no such key the validator skips its rules instead of failing them. No message, and no key in the data.
  6. The same field, sent badly. Send guardian_phone as "98765 43210" and sometimes no longer applies: the key is there, so the regex runs and reports that the format is invalid.

The recorded run sends six payloads through one rule list and prints, for each, its message or the keys that survived; its last block adds a check after the rules:

required_if, exclude_if, sometimes and a check added after the rules Python · conditional_session.py
from session import run

# Rules that depend on other fields: which rule switches on, which field is dropped, and which keys survive.
run(
    """
php conditional.php
""",
    app=True,
    overlay=("conditional-probe",),
)

Output

$ php conditional.php
== transport yes, no stop
  sent      {"batch":"morning","transport":"1"}
  message   The stop field is required when transport is 1.
== transport yes, a stop named
  sent      {"batch":"morning","transport":"1","stop":"Kacheripady"}
  kept      ["batch","transport","stop"]
== transport no, a stop sent anyway
  sent      {"batch":"morning","transport":"0","stop":"Kacheripady"}
  kept      ["batch","transport"]
== no guardian phone sent
  sent      {"batch":"morning","transport":"0"}
  kept      ["batch","transport"]
== a guardian phone in the wrong shape
  sent      {"batch":"morning","transport":"0","guardian_phone":"98765 43210"}
  message   The guardian phone field format is invalid.
== an evening batch with a waiver note
  sent      {"batch":"evening","transport":"0","fee_waiver_note":"sibling"}
  message   The fee waiver note field is prohibited when batch is evening.
== a cross-field check added after the rules
  message   The morning bus does not pass Kacheripady.

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 conditional_session.py

With transport set to 1 and no stop, the answer is The stop field is required when transport is 1. With a stop named, validated() returns ["batch","transport","stop"]. With transport set to 0 and a stop sent anyway, it returns ["batch","transport"]: exclude_if removed the field before the other rules read it, so max:60 never ran and the stop cannot reach the action. That is the point of excluding rather than ignoring — a stop with no transport would be a row nobody reads, written because a caller sent an extra key.

The guardian phone shows the difference between sometimes and nullable. A payload with no guardian_phone key passes with no message and no key kept. A payload that sends 98765 43210 fails the +91 pattern, because the key is there and the regular expression runs. nullable is a third thing again: the key is present and may hold null, which is what a text box the visitor left empty arrives as, since ConvertEmptyStringsToNull ran before the rules.

The last block adds a check the rule strings cannot express. A timetable knows that the morning bus does not pass one of the stops; after() runs once the field rules have been applied and adds The morning bus does not pass Kacheripady. to the stop field, where it appears beside the rule messages.

Checkpoint

Check: Rules that depend on another field

  1. Question 1 of 2 guardian_phone has the rules sometimes and a regex. The payload does not carry guardian_phone at all. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: No rule runs for it, and it is not in validated()

    sometimes decides whether the other rules apply at all. nullable is different: the key is there and may hold null. A form field the visitor left empty arrives as null, because ConvertEmptyStringsToNull ran first.

  2. Question 2 of 2 stop has exclude_if:transport,0 and max:60. The payload is transport 0 with stop "Kacheripady". Which keys did validated() hand back in the recorded run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: batch and transport

    exclude_if and exclude_unless are the way to say "this field is meaningless in this case". They keep impossible combinations out of your action and out of your table, with no if statement in the controller.

How many messages one field gets

A field collects one message for each rule that fails, in the order the rules are listed. The last two blocks of the rules probe above sent the text abc to a pin field twice, the second time with bail at the front of its list:

Rules on pin Messages for abc
digits:6, numeric The pin field must be 6 digits. and The pin field must be a number.
bail, digits:6, numeric The pin field must be 6 digits.

Without bail both rules reported. With it the field stopped at its first failure, and numeric never ran.

bail is a flag for the field, not a position in the list: it stops that field at its first failing rule wherever it is written, and it says nothing about the other fields, which keep reporting. Use it where the later messages only repeat the first in other words, such as a format check behind a type check. Leave it off where every requirement is a separate thing to fix, as for the password rule, whose two messages told the visitor both problems at once.

Put the most useful rule first

The order of the rules is the order of the messages, so put the check that says what is wrong first: a type or format rule before a range rule.

Checkpoint

Check: How many messages a field gets

  1. One question The rules for pin are bail, digits:6 and numeric, and the input is "abc". How many messages does the field get?

    Type a number.

    Show the answer: One question

    Answer: 1 message

    bail stops at the first rule of that field which fails, so digits:6 reports and numeric is never applied. Without bail the same input produced two messages in the recorded run.

Messages and field names a reader understands

Three things can be replaced, from the most specific to the least: the sentence for one rule of one field, the name of the field inside every sentence, and the default sentences of every rule in a locale file: A file of translated strings for one language, keyed by name. A key that is missing falls back to the application's fallback locale, one key at a time.. The second block of the rules probe passes a message for batch.in and attribute name: The reader-facing name of a field, used inside every message about it, so a message says "date of birth" rather than the column name. for born_on and items.*.qty, and three of the four messages change:

Default message With a message and attribute names
The born on field must match the format Y-m-d. The date of birth field must match the format Y-m-d.
The selected batch is invalid. Choose the morning or the evening batch.
The items.0.qty field must be an integer. The number of books field must be an integer.

The attribute name for items.*.qty is written with the star, so one entry renames the field in every row’s message. Prefer an attribute name to a replaced sentence: one entry fixes every rule of that field, while a replaced sentence has to be written again for each rule.

A fresh Laravel 13 project has no lang directory, because the defaults live in the framework. php artisan lang:publish writes them where you can edit them, and a second language is a folder beside en holding only the keys the application uses:

lang/hi/validation.php: the keys this form needs PHP · hindi-probe/lang/hi/validation.php
<?php

// lang/hi/validation.php: the messages this form needs, in Hindi, and the names the reader should see.
// php artisan lang:publish writes the full English file; a second language is a folder beside it with the
// keys the application actually uses. A key that is missing falls back to the application's fallback locale.
return [
    'required' => ':attribute भरना आवश्यक है।',
    'integer' => ':attribute में पूरी संख्या होनी चाहिए।',
    'digits' => ':attribute में :digits अंक होने चाहिए।',
    'in' => 'चुना गया :attribute मान्य नहीं है।',
    'date_format' => ':attribute का प्रारूप :format होना चाहिए।',
    'between' => [
        'numeric' => ':attribute :min और :max के बीच होना चाहिए।',
        'string' => ':attribute में :min से :max अक्षर होने चाहिए।',
    ],
    'max' => [
        'string' => ':attribute :max अक्षरों से अधिक नहीं होना चाहिए।',
    ],

    'attributes' => [
        'name' => 'नाम',
        'born_on' => 'जन्म तिथि',
        'batch' => 'बैच',
        'seats' => 'सीटों की संख्या',
        'pin' => 'पिन कोड',
        'items.*.qty' => 'पुस्तकों की संख्या',
    ],
];
Publishing the files, then the same failures in English and Hindi Python · hindi_session.py
from session import run

# Publishing the language files, the Hindi file this form needs, and the same failures in both locales.
run(
    """
php artisan lang:publish
ls lang
head -12 lang/hi/validation.php
php hindi.php
""",
    app=True,
    overlay=("hindi-probe",),
)

Output

$ php artisan lang:publish
   INFO  Language files published successfully.
$ ls lang
en
hi
$ head -12 lang/hi/validation.php
<?php

// lang/hi/validation.php: the messages this form needs, in Hindi, and the names the reader should see.
// php artisan lang:publish writes the full English file; a second language is a folder beside it with the
// keys the application actually uses. A key that is missing falls back to the application's fallback locale.
return [
    'required' => ':attribute भरना आवश्यक है।',
    'integer' => ':attribute में पूरी संख्या होनी चाहिए।',
    'digits' => ':attribute में :digits अंक होने चाहिए।',
    'in' => 'चुना गया :attribute मान्य नहीं है।',
    'date_format' => ':attribute का प्रारूप :format होना चाहिए।',
    'between' => [
$ php hindi.php
== locale en
  The born on field must match the format Y-m-d.
  The selected batch is invalid.
  The seats field must be between 1 and 3.
  The pin field must be 6 digits.
  The items.0.qty field must be an integer.
== locale hi
  जन्म तिथि का प्रारूप Y-m-d होना चाहिए।
  चुना गया बैच मान्य नहीं है।
  सीटों की संख्या 1 और 3 के बीच होना चाहिए।
  पिन कोड में 6 अंक होने चाहिए।
  पुस्तकों की संख्या में पूरी संख्या होनी चाहिए।
== a rule the Hindi file has no message for
  The सीटों की संख्या field must be a number.
  fallback locale: en

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hindi_session.py

The Hindi block reports the same five failures in Hindi, with the field names from the attributes array: पिन कोड में 6 अंक होने चाहिए। for the PIN code, and पुस्तकों की संख्या में पूरी संख्या होनी चाहिए। for the nested quantity. Read the last block carefully. The Hindi file has an attribute name for seats but no numeric message, and the answer is The सीटों की संख्या field must be a number. — an English sentence around a Hindi name.

A half-translated validation file

Each key is looked up on its own, and a missing one falls back to app.fallback_locale, which the run printed as en. The fix is not a clever default: it is a check that fails every rule the form uses in each locale and reads the result, so a missing key is found before a parent sees half a sentence.

Checkpoint

Check: Messages and attribute names

  1. Question 1 of 2 items.*.qty failed for the first row. The default message named "items.0.qty". Which change made it read "The number of books field must be an integer."?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: An attributes entry for items.*.qty

    Two different things can be replaced: the sentence (messages, keyed field.rule) and the name inside it (attributes, keyed by field). Prefer the attribute name: one entry fixes every rule of that field.

  2. Question 2 of 2 The Hindi file has an attributes entry for seats but no numeric message. With the locale set to hi, seats "two" fails numeric. What did the recorded run print?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An English sentence with the Hindi field name inside it

    Translations are per key, not per file. A half-translated validation file produces mixed sentences, so the check before a release is to fail every rule the form uses in each locale and read the result.

Say what is allowed, not what is forbidden

The OWASP input validation cheat sheet’s advice is to prefer an allowlist: A list of exactly what is accepted, so anything absent from it is refused. The opposite of a denylist, and the safer default for input. to a denylist: A list of what is refused, which accepts everything nobody thought to list. Useful as a second layer, unsafe as the only check on input., to bound what you accept, and to use the framework’s validators rather than hand-written checks. Every rule in this lesson’s list follows it. Rule::in and Rule::enum name the values that exist. date_format names the one shape a date may arrive in. max:120 bounds a field that would otherwise accept as much text as the server will read. digits:6 describes a PIN code exactly.

A denylist reads reasonably and fails open. Here is the batch field with each kind of rule on its own, given five values:

Value sent for batch Rule::in(['morning', 'evening']) not_in:night
morning passes passes
night refused refused
admin refused passes
the text NULL refused passes
an empty array refused passes

not_in:night refuses the one batch somebody thought of and accepts the rest, including whatever next year’s client sends. The difference matters most for fields that choose behaviour rather than describe a person: a role, a status, a disk, a sort column. For those, write the list of what exists and let the rule refuse everything else.

Key idea

A rule list is an allowlist of shapes: the fields that may arrive, the values each may hold and the size each may reach. A value outside it is refused, so the list alone tells a reader what the action can receive.

Two habits come from this. Give every text field a maximum, even one you think is generous, so no field is unbounded. And keep the allowlist in the rules, not in the action: a match statement in a controller refuses the same values, but no message, no form helper and no API client can see it.

Checkpoint

Check: Allowlists over denylists

  1. Question 1 of 2 A batch field must accept only the morning and evening batches. Which rule list follows the allowlist advice of the OWASP input validation cheat sheet?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: required and Rule::in(['morning', 'evening'])

    An allowlist fails closed. Rule::in, Rule::enum, a date format and a maximum length all say what is allowed; not_in and a "not this" pattern say what is forbidden, and the list of what is forbidden is never complete.

  2. Question 2 of 2 Why does every text field in the lesson's rule list carry a max rule?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Because an unbounded string lets a caller send as much text as the server will read

    The cheat sheet's advice is a maximum length on every string, together with an allowlist of shapes. Both are rules here, so the limit is written down once and reported to the visitor instead of failing deeper in.

Practise

Work these on your own Pathshala project, with the rules in the controller for now; the next lesson moves them into a class of their own.

  1. Write the admission rule list: a name, a date of birth that must be before a date you choose, a guardian phone in +91 format, a six-digit PIN code, a batch from an enum, and an optional array of siblings with at most four rows. Post it with every field wrong at once and count the messages.
  2. Add an attribute name for the nested rows and compare the message with the one that names siblings.0.name.
  3. Add bail to the phone field, post a value that fails two of its rules, and decide whether you want it there.
  4. Publish the language files, add a hi folder with only the keys your form uses, and fail every rule in both locales. Note any sentence that comes back mixed.

Then work through the self-check.

Practice that adapts to you

Practise this lesson

16 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 16 What does $request->validate([...]) return when every rule passes?

    Choose one answer.

    Show the answer: Question 1 of 16

    Answer: An array of the fields that had rules, with the values the request carried

    The method either throws, so your next line never runs, or hands back the validated array. There is no "did it work" branch to forget.

  2. Question 2 of 16 A controller runs Admission::create($request->all()) after validating. The model has no fillable restrictions. A caller adds "fee_waived": true to the payload. What is the risk?

    Choose one answer.

    Show the answer: Question 2 of 16

    Answer: A field nobody validated is written to the row, because all() carries every key

    Use the return value of validate(), or safe()->only([...]), and keep the model's fillable list as a second guard. The next lesson's form requests make this the default, because validated() is the natural thing to pass.

  3. Question 3 of 16 You need only two of six validated fields for the first insert. Which reads them?

    Choose one answer.

    Show the answer: Question 3 of 16

    Answer: $validator->safe()->only(['name', 'batch'])

    safe() is validated() with the three operations you usually want next, and it reads as a sentence at the call site: take the validated data, keep these two fields.

  4. Question 4 of 16 born_on carries date_format:Y-m-d and a before: rule, and the form sent the date typed day-first with slashes. What did the recorded run report?

    Choose one answer.

    Show the answer: Question 4 of 16

    Answer: One message, that the field must match the format Y-m-d

    date_format checks the shape. before compares dates, and for text in another shape it falls back to PHP's own parser, which reads slashes month first. A form that posts a day-first value needs either a format rule that matches the field or an input that already arrives as Y-m-d.

  5. Question 5 of 16 An order may carry up to five lines, each with a whole quantity of at least one. Which rules say that?

    Choose one answer.

    Show the answer: Question 5 of 16

    Answer: items as sometimes, array, max:5 and items.*.qty as required, integer, min:1

    Nested data needs rules at both levels: the container, so the size is bounded, and the star path, so every row is checked. The star also covers the attribute name, so one entry renames the field in every message.

  6. Question 6 of 16 Password::min(8)->letters()->numbers() is applied to "abc". How many messages did the recorded run report for that field?

    Type a number.

    Show the answer: Question 6 of 16

    Answer: 2 messages

    The length and the missing digit each reported; letters() was satisfied by abc. The Password rule reports every requirement the value misses, which is what a reader needs to fix it in one attempt.

  7. Question 7 of 16 What does required_if:transport,1 say about the stop field?

    Choose one answer.

    Show the answer: Question 7 of 16

    Answer: The stop must be present when transport is 1, and may be absent otherwise

    required_if, required_unless, required_with and required_without read the rest of the payload, so one rule list can describe a form whose fields depend on each other, with no branch in the controller.

  8. Question 8 of 16 A morning bus does not pass one of the stops, which only a timetable knows. Where does that check belong, given what the rules can express?

    Choose one answer.

    Show the answer: Question 8 of 16

    Answer: In an after callback on the validator, which adds a message to the stop field

    Field rules describe one payload. A cross-field check that needs data of its own belongs in after(), which runs once the field rules have been applied and can add a message to any field. A form request has the same hook, as the next lesson shows.

  9. Question 9 of 16 A sign-up form has six rules on the password. What does adding bail to that field cost the visitor, and what does it save?

    Choose one answer.

    Show the answer: Question 9 of 16

    Answer: It saves a wall of messages, and costs a second attempt when two things are wrong

    Use bail where later rules only repeat the first failure, such as a format check behind a type check. For a password, where every requirement is a separate thing to fix, reporting all of them is kinder.

  10. Question 10 of 16 The rules for pin are digits:6 and numeric, with no bail, and the input is "abc". What is reported?

    Choose one answer.

    Show the answer: Question 10 of 16

    Answer: Two messages, one for each rule that failed

    A field collects one message per failing rule, in the order the rules are listed. That order is also the order the page shows, so put the rule whose message helps most first.

  11. Question 11 of 16 A field has the rules numeric, digits:6 and bail, written in that order. Which message does a value of "abc" produce?

    Choose one answer.

    Show the answer: Question 11 of 16

    Answer: The numeric message, because bail stops the field at its first failure

    Order the rules so the most useful message is the one that can fail first: a type or format check before a range check, so the first thing the visitor reads says what is wrong with the value.

  12. Question 12 of 16 Which key replaces the sentence for one rule of one field?

    Choose one answer.

    Show the answer: Question 12 of 16

    Answer: 'batch.in' in the messages array

    messages is field.rule to the whole sentence, attributes is field to the name used inside the sentences, and the locale file holds the defaults for every rule. The most specific one that exists wins.

  13. Question 13 of 16 What does php artisan lang:publish do in a fresh Laravel 13 project?

    Choose one answer.

    Show the answer: Question 13 of 16

    Answer: It writes the framework's language files into a lang directory you can edit

    A fresh project has no lang directory, because the defaults live in the framework. Publishing them gives you a copy to edit, and a second locale is a folder beside it with only the keys you need.

  14. Question 14 of 16 Which of these rules describe what is allowed, rather than what is forbidden?

    Choose every answer that is right.

    Show the answer: Question 14 of 16

    Answer:

    • Rule::in(['morning', 'evening'])
    • date_format:Y-m-d
    • max:120 on a text field

    An allowlist rule answers "what may this field be?". Each of the first three does; not_in answers the other question, and its list is never finished.

  15. Question 15 of 16 A status field must accept only the cases of a PHP enum, and the action wants the enum case rather than a string. What is the shortest rule list that does both?

    Choose one answer.

    Show the answer: Question 15 of 16

    Answer: required and Rule::enum(FeeStatus::class), then cast the validated value

    Rule::enum keeps one source of truth for what is allowed. The cast is separate work, in the action, in an Eloquent cast, or in the typed input readers of the requests lesson.

  16. Question 16 of 16 A teammate replaces the rule list with eight if statements in the controller that each return a message. Name the two things that are lost.

    Choose one answer.

    Show the answer: Question 16 of 16

    Answer: The 422 shape for JSON clients, and the flashed old input the form page needs

    The rule list is not only a list of checks. It is what produces a consistent 422 for API clients, a redirect with errors and old input for forms, translated messages, and a validated array that is safe to pass on.

Interview questions

Warm-up: why pass the return value of validate() to the model instead of $request->all()?

Because they are different sets of keys. validate() returns only the fields the rule list declared, holding the values that arrived, while all() returns everything the caller sent, including keys that were never in your form. A caller who adds fee_waived or role to the payload does not fail validation — an undeclared key is ignored by the rules, not rejected — so Model::create($request->all()) can write a column you never meant to expose. Passing the validated array makes the set of writable fields the same as the set of validated fields, and the model’s fillable list is then a second guard rather than the only one. When a subset is wanted, safe()->only([...]) narrows it at the call site. None of this casts anything: both arrays hold the text the request carried.

Three longer questions grow out of this lesson’s rule lists:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Fresher to mid

    A fee payment form validates four fields and then calls Payment::create($request->all()). A caller adds "waived": true to the payload and the row is written with it. Explain how, and say what you would change.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    Parents report that the admission form refuses their date of birth. The field has date_format:Y-m-d and a before: rule, and the form posts the date typed day-first with slashes. Say what the form shows and how you would fix it.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    With the locale set to Hindi, one message comes back as an English sentence with a Hindi field name inside it. Explain the lookup that produces that, and say what check you would add before a release.

    Model answer, Pro. First step and follow-ups

Flashcards · 6 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does $request->validate([...]) return, and why pass that on instead of $request->all()?

    If any rule fails it throws and your action never runs; otherwise it returns only the fields that had rules. all() carries every key the caller sent, so create() with it could write a column such as fee_waived that no form offered.

  2. Does a validation rule change the value it checks?

    No. A rule only decides whether a value is acceptable: seats arrived as the string "2" and stayed text after passing, so casting is separate work.

  3. How does the same failed validation answer a browser form and a JSON client?

    A browser form gets a 302 redirect back, with the errors and the old input flashed to the session. A JSON client gets a 422 with a message and an errors object.

  4. What do required_if, exclude_if, sometimes and after() each do?

    required_if switches a rule on because of another field's value; exclude_if removes a field so it cannot reach your action; sometimes applies rules only when the key is present; after() adds a check that rule strings cannot express.

  5. How many error messages can one field get, and what does bail change?

    One message per failing rule, in the order the rules are listed. bail stops that field at its first failure.

  6. How do you rename nested fields in error messages, and why might a message come back half translated?

    An attribute name written with a star, such as items.*.qty, renames that field in every row's message. A locale file with a missing key falls back one key at a time, so one sentence can mix languages.

Key takeaways

  • $request->validate([...]) either throws, so your action never runs, or returns the fields that had rules, which is the array to pass on; all() carries everything the caller sent.
  • A rule checks a value and never changes it: seats arrived as the string "2" and stayed text, so casting is separate work.
  • The same failures become a 302 with flashed errors and old input for a browser form, and a 422 with a message and an errors object for a JSON client.
  • required_if switches a rule on from another field, exclude_if removes a field so it cannot reach your action, sometimes applies rules only when the key is present, and after() adds a check the rule strings cannot express.
  • A field reports one message per failing rule in the order you listed them, and bail stops that field at the first failure.
  • An attribute name written with the star renames a nested field in every row’s message, and a locale file with a missing key falls back one key at a time, which is how a sentence comes back half translated.

Terms in this lesson

allowlist
A list of exactly what is accepted, so anything absent from it is refused. The opposite of a denylist, and the safer default for input.
attribute name
The reader-facing name of a field, used inside every message about it, so a message says "date of birth" rather than the column name.
conditional rule
A rule that reads another field before it applies, such as required_if, exclude_if or sometimes, so one rule list can describe a form whose fields depend on each other.
denylist
A list of what is refused, which accepts everything nobody thought to list. Useful as a second layer, unsafe as the only check on input.
error bag
The collection of validation messages for a request, keyed by field. For a browser form it is flashed into the session so the page can show it once.
locale file
A file of translated strings for one language, keyed by name. A key that is missing falls back to the application's fallback locale, one key at a time.
validated data
The array a validator hands back: only the fields that had rules, holding the values the request carried, with no key the rules did not declare.
validation rule
One named check applied to one field, such as digits:6 or a rule object. It reports a message and never changes the value it checked.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.