Building Web Apps with Laravel Module 7 – Validation, form requests and file uploads
Form requests, custom rules and Precognition
Move Laravel 13 validation into a form request, write a rule class for an identifier of your own, and answer a form live with the same rules.
What you will learn
- Move a rule list, its messages and its authorization into a form request class
- Write a custom rule class that checks the shape and checksum of an identifier
- Apply the Laravel 13 request attributes StopOnFirstFailure and FailOnUnknownFields
- Explain what a precognitive request runs, answers and leaves behind
Before you start
What you’ll learn · Before you start
What you will learn
- Move a rule list, its messages and its authorization into a form request class
- Write a custom rule class that checks the shape and checksum of an identifier
- Apply the Laravel 13 request attributes StopOnFirstFailure and FailOnUnknownFields
- Explain what a precognitive request runs, answers and leaves behind
Before you start
On this page
A controller with a rule list, two custom messages, an ownership check and a phone number to tidy runs to about thirty lines before it saves anything. Move all of that into one class and the action becomes two lines, while the rules gain something they could not have in a controller: a browser can ask them about one field, after every keystroke, without saving a row.
This lesson builds that class for Pathshala’s admission form. You will see what Artisan generates and in which order its hooks run — the recorded answer is not the one most articles give — how a rule class checks an identifier that no built-in rule knows, which Laravel 13 attributes change a request’s behaviour, how a precognitive request answers a half-filled form, and where a check stops being validation and becomes authorization. Every run was recorded on Laravel 13.34.
What a form request moves out of the controller
php artisan make:request writes a class with two methods, and php artisan make:rule writes a rule with one. Both are nearly empty, and one of the two defaults is worth noticing:
from session import run
# What Artisan writes for a form request and for a rule, in a project that has neither yet.
run(
"""
php artisan make:request StoreAdmissionRequest
cat app/Http/Requests/StoreAdmissionRequest.php
php artisan make:rule Gstin
cat app/Rules/Gstin.php
""",
app=True,
) Output
$ php artisan make:request StoreAdmissionRequest
INFO Request [app/Http/Requests/StoreAdmissionRequest.php] created successfully.
$ cat app/Http/Requests/StoreAdmissionRequest.php
<?php
namespace App\Http\Requests;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class StoreAdmissionRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
$ php artisan make:rule Gstin
INFO Rule [app/Rules/Gstin.php] created successfully.
$ cat app/Rules/Gstin.php
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class Gstin implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
//
}
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 make_session.py
The generated authorize() returns false. A new form request: A class holding everything about accepting one request: who may send it, the rules, the messages and the hooks. The action takes it as a parameter. refuses every caller until you decide who may send it, so the first request to a route you have just wired answers 403 rather than accepting anything. The generated rules() returns an empty array, which accepts anything, so write it before you open the class.
Filled in, the class holds everything about accepting one request: who may send it, what shape it must have, what to call its fields in a message, and the checks the rule strings cannot express:
<?php
namespace App\Http\Requests;
use App\Rules\Gstin;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreAdmissionRequest extends FormRequest
{
/** Which hooks ran, in order, so the probe can print the order instead of describing it. */
public static array $trace = [];
/**
* Who may send this request at all. Returning false ends it with a 403, before any rule runs.
*/
public function authorize(): bool
{
self::$trace[] = 'authorize';
return $this->header('X-Staff') === 'yes';
}
/**
* Tidy the input before the rules read it. A parent may type a phone number with spaces.
*/
protected function prepareForValidation(): void
{
self::$trace[] = 'prepareForValidation';
$this->merge([
'guardian_phone' => preg_replace('/\s+/', '', (string) $this->input('guardian_phone')),
]);
}
/**
* The rule list, exactly as the controller held it before.
*/
public function rules(): array
{
self::$trace[] = 'rules';
return [
'name' => ['required', 'string', 'max:120'],
'born_on' => ['required', 'date_format:Y-m-d', 'before:2020-01-01'],
'batch' => ['required', Rule::in(['morning', 'evening'])],
'seats' => ['required', 'integer', 'between:1,3'],
'guardian_phone' => ['required', 'regex:/^\+91[6-9]\d{9}$/'],
'gstin' => ['nullable', new Gstin()],
];
}
/**
* Checks the rule strings cannot express, run after the field rules.
*/
public function after(): array
{
self::$trace[] = 'after';
return [
function (Validator $validator) {
if ($this->input('batch') === 'morning' && $this->integer('seats') === 3) {
$validator->errors()->add('seats', 'The morning batch has two seats left.');
}
},
];
}
/**
* Names a reader understands, used inside every message of these fields.
*/
public function attributes(): array
{
return [
'born_on' => 'date of birth',
'guardian_phone' => "guardian's phone number",
];
}
/**
* One sentence of our own, for the rule whose default message says nothing useful.
*/
public function messages(): array
{
return [
'guardian_phone.regex' => "The guardian's phone number must look like +919876543210.",
];
}
} The route is then a signature and two lines. Naming the class in the action is what runs the validation, because the container builds the class before the action is called:
<?php
use App\Http\Requests\StoreAdmissionRequest;
use App\Http\Requests\StoreInvoiceRequest;
use App\Http\Requests\StoreLooseInvoiceRequest;
use Illuminate\Foundation\Http\Middleware\HandlePrecognitiveRequests;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
// The probes are scripts with no browser, so they carry no forgery token; a real form route keeps PreventRequestForgery.
// The admission form, with its rules, its authorization and its hooks in a class of its own. The action is two
// lines, and the only data it can see is what the rules declared. Saving is a line in a file here, so that a
// precognitive request can be shown to leave nothing behind.
Route::post('/admissions', function (StoreAdmissionRequest $request) {
Storage::disk('local')->append('admissions.log', json_encode($request->validated()));
return response()->json(['saved' => $request->validated()], 201);
})->middleware(HandlePrecognitiveRequests::class)->withoutMiddleware(PreventRequestForgery::class);
// The same rules with and without #[FailOnUnknownFields].
Route::post('/invoices/strict', fn (StoreInvoiceRequest $request) => response()->json(['saved' => $request->validated()]))
->withoutMiddleware(PreventRequestForgery::class);
Route::post('/invoices/loose', fn (StoreLooseInvoiceRequest $request) => response()->json(['saved' => $request->validated()]))
->withoutMiddleware(PreventRequestForgery::class); Key idea
The action’s parameter is the whole wiring. By the time its first line runs, the caller has been authorized, the input has been shaped, every rule has passed, and validated() holds only the declared fields.
That is also why the safe thing is the natural thing to write. In a controller the easy call is $request->all(); with a form request the easy call is $request->validated(), because it is a method on the object the signature already names.
Checkpoint
Check: What a form request holds
The hooks run in an order worth knowing
Four hooks carry the work before your action: prepareForValidation(), authorize(), rules() and after(). The order matters, because one of them runs for callers who are refused. The recorded probe sends three requests and prints which hooks ran for each:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Asked for by the action. The action's signature names StoreAdmissionRequest, so the container builds it before the action is called. Building it is what runs the validation, which is why a failure never reaches your first line.
- The input is tidied first. prepareForValidation() runs before anything else. Here it strips the spaces from a phone number typed as "+91 98765 43210", so the regular expression sees +919876543210.
- Then the caller is checked. authorize() runs second, not first: the recorded run printed "prepareForValidation then authorize". A request with no staff header answered 403 with "This action is unauthorized." and no rule ran.
- Nothing costly goes first. Because tidying happens first, prepareForValidation() runs for callers who are refused. Keep it to shaping input, and leave lookups, writes and anything costly to the action.
- Rules read prepared input. rules() returns the same list the controller held before. For a staff caller with a tidied phone number every rule passed, and the answer was 201 with the validated data.
- The last check sees every field. after() runs once the field rules have been applied, and can add a message to any field. A request for three seats when the morning batch has room for two answered 422, with the message attached to the seats field.
- One failure, the usual answer. A failure from rules() or after() ends the request the same way: a 422 for a JSON client, a redirect back with the errors and the old input for a browser form.
- The action receives data only. The action runs with validated() and nothing else to decide. Its two lines save the admission and answer, which is what makes a form request worth the file.
from session import run
run(
"""
php order.php
""",
app=True,
overlay=("request-probe",),
) Output
$ php order.php
== a caller with no staff header
status 403
hooks prepareForValidation then authorize
message This action is unauthorized.
== staff, with a phone typed with spaces
status 201
hooks prepareForValidation then authorize then rules then after
saved {"name":"Meera Nair","born_on":"2016-01-05","batch":"evening","seats":"2","guardian_phone":"+919876543210"}
== staff, three seats in the morning batch
status 422
hooks prepareForValidation then authorize then rules then after
message seats: The morning batch has two seats left.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 order_session.py
Read the hooks line of the first block: prepareForValidation then authorize. Tidying runs before the caller is checked, and the request then answered 403 with This action is unauthorized. without running a single rule. The framework’s validateResolved() does them in that order, and the recorded run agrees with it.
Work with consequences in prepareForValidation
A counter, a log line or a row written in prepareForValidation() happens for callers that authorize() is about to refuse. Keep that hook to shaping input — merge, trim, split, normalise — and leave anything that writes, charges or counts to the action.
The second block is a staff caller whose phone number was typed as +91 98765 43210. prepareForValidation() merged the stripped value back into the request, so the regular expression saw +919876543210 and the answer was 201 with that number in the validated data. The third block shows after(): three seats in the morning batch passed every field rule and still failed, with The morning batch has two seats left. attached to the seats field, where the reader will look for it.
after() is the right home for a check that needs something outside the payload: another field, a count, a timetable, a row. It returns an array of callables, each receiving the validator, and it runs once the field rules have been applied.
Two more hooks sit inside that order. withValidator() is called as the validator is built, before any rule is applied, which is where the next lesson adds a pixel rule for pictures only; passedValidation() runs after every rule has passed, immediately before your action. Neither changes the four above, and both are optional methods you add when you need them.
Checkpoint
Check: The order the hooks run in
A custom rule class for an identifier of your own
India’s tax identifiers, bank codes and PIN codes have shapes no framework ships a rule for. A custom rule class: A class with one validate method that receives a field, its value and a fail closure, so a check the built-in rules do not cover can be reused anywhere. is one method: it receives the field name, the value and a $fail closure, and reports by calling the closure.
The identifier this section checks
The number is a GSTIN, the fifteen-character tax identifier an Indian business prints on its invoices. Its shape is India-specific; the rule’s structure — a pattern check first, then a check character computed from the rest — fits any identifier that carries a checksum.
The number is fifteen characters, as rule 10 of the CGST Rules lays them down: two for the state code, ten for the permanent account number, two for the entity code and one checksum: A character or number computed from the rest of a value, so a single mistyped character makes the whole value inconsistent and can be caught offline. character. The class below computes that last character over the first fourteen and expects an entity number and the letter Z in the two entity places. It checks the shape with a pattern, then the checksum, and returns after each failure so a reader gets the message that fits:
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* A GSTIN is 15 characters: a two-digit state code, a ten-character PAN, an entity number, a fixed Z and a check
* character (rule 10 of the CGST Rules). Those rules say that the last character is a checksum, not how it is
* computed. This rule computes it as a Luhn mod 36 character over the first fourteen, which catches a single
* mistyped character, and the probe builds its own numbers with the same function; before a live form refuses a
* number somebody pasted from an invoice, check the computation against the issuer's published algorithm.
* Either way the rule says nothing about whether the number was issued, which only the GST portal can answer.
*/
class Gstin implements ValidationRule
{
private const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! preg_match('/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][0-9A-Z]Z[0-9A-Z]$/', $value)) {
$fail('The :attribute field must be 15 characters in the shape 22AAAAA0000A1Z5.');
return;
}
if ($value[14] !== self::checkCharacter(substr($value, 0, 14))) {
$fail('The :attribute field has a check character that does not match the rest of the number.');
return;
}
}
/**
* The check character of the first fourteen characters: Luhn mod 36 over the base-36 values.
*/
public static function checkCharacter(string $first14): string
{
$sum = 0;
$factor = 2;
for ($i = 13; $i >= 0; $i--) {
$addend = $factor * strpos(self::ALPHABET, $first14[$i]);
$factor = $factor === 2 ? 1 : 2;
$sum += intdiv($addend, 36) + $addend % 36;
}
return self::ALPHABET[(36 - $sum % 36) % 36];
}
} The probe builds its own well-formed number — a made-up PAN, with the check character computed by the same function — and sends five candidates through the rule:
from session import run
run(
"""
php gstin.php
""",
app=True,
overlay=("request-probe",),
) Output
$ php gstin.php the check character of 29ABCDE1234F1Z is W, so 29ABCDE1234F1ZW is well formed == 29ABCDE1234F1ZW accepted == 29ABCDE1234F1Z0 The gstin field has a check character that does not match the rest of the number. == 29ABCDE1234F1 The gstin field must be 15 characters in the shape 22AAAAA0000A1Z5. == GSTIN-29-ABCDE The gstin field must be 15 characters in the shape 22AAAAA0000A1Z5. == 29abcde1234f1zw The gstin field must be 15 characters in the shape 22AAAAA0000A1Z5.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 gstin_session.py
The check character of 29ABCDE1234F1Z is W, so 29ABCDE1234F1ZW is accepted. Change the last character to 0 and the answer is the checksum message. Shorten the number, or send something that is not a tax identifier at all, and the shape message comes back instead. Lower case fails the shape, because the pattern asks for capitals — if your form should accept what a parent pastes from an invoice, upper-case the value in prepareForValidation() rather than loosening the pattern.
What a checksum can and cannot show
A matching check character means the fifteen characters are consistent, which catches a mistyped digit cheaply and offline. It does not mean the number was issued, is current, or belongs to the business filling the form. Only the issuing portal can answer those, and that is a network call with its own failures, not a validation rule. The rules that lay down those fifteen characters say that the last one is a checksum and not how it is computed, so check your computation against the issuer’s published algorithm before a form refuses what somebody pasted from an invoice.
Every number in this lesson is synthetic, built by the function on the page. Use the same habit in tests and fixtures: generate the check character rather than pasting a real identifier into a repository.
Checkpoint
Check: A rule class of your own
Attributes that change how a request behaves
Laravel 13 ships five PHP attribute: Metadata written in square brackets above a class, method or property, which the framework reads to change behaviour without a method or a property being written. for form requests, in Illuminate\Foundation\Http\Attributes: ErrorBag, FailOnUnknownFields, RedirectTo, RedirectToRoute and StopOnFirstFailure. Each replaces a property or an override people used to write by hand.
| Attribute | What it changes |
|---|---|
#[StopOnFirstFailure] |
the whole validator stops at the first failing field, not the first rule of a field |
#[FailOnUnknownFields] |
a payload key with no declared rule is refused instead of ignored |
#[ErrorBag('admission')] |
the messages are flashed under a named bag, for a page with two forms |
#[RedirectTo('/admissions')] |
a failed browser request goes to that path instead of back |
#[RedirectToRoute('admissions.create')] |
the same, named by route |
FailOnUnknownFields is the one that changes a security property, so the probe posts the same invoice payload — with one extra is_admin key — to two routes whose form requests differ only in that line:
from session import run
run(
"""
php unknown.php
""",
app=True,
overlay=("request-probe",),
) Output
$ php unknown.php
== with #[FailOnUnknownFields]
status 422
body {"message":"The is admin field is prohibited.","errors":{"is_admin":["The is admin field is prohibited."]}}
== without the attribute
status 200
body {"saved":{"gstin":"29ABCDE1234F1ZW","amount":"2500"}}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unknown_session.py
With the attribute the answer is 422 and The is admin field is prohibited. Without it the answer is 200, and the saved data is {"gstin":"29ABCDE1234F1ZW","amount":"2500"} — the extra key was ignored by the rules and dropped by validated(), exactly as the previous lesson showed.
Both behaviours are useful, for different callers. For a form whose fields you own, a key nobody declared is either a mistake in your own front end or somebody trying something, and refusing it turns a silent oddity into a message. For a public API whose clients age in the field, refusing unknown keys makes every client change a breaking change on the server, so the quiet filtering is the better default.
Checkpoint
Check: Form request attributes
The same rules, before the parent presses submit
Live validation usually means two copies of the rules, one on the server and one in JavaScript, and two copies drift. precognition: Asking a route what it would answer: the middleware and the rules run and the controller does not, so a form can validate a field live against the server's own rules. keeps one copy. Its documentation draws the line at the action: the route’s middleware runs and the action’s dependencies are resolved, which is the step that validates a form request, but the action itself is never called.
Add HandlePrecognitiveRequests to the route, and a request that carries the header Precognition: true is answered by the rules alone:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The parent leaves a field. The phone box has "98765" in it and the parent moves on. The form sends the payload with two headers: Precognition true, and Precognition-Validate-Only naming the phone field.
- Only that field is checked. filterPrecognitiveRules keeps the rules of the named field and drops the rest, so the answer is about the phone number alone and not about boxes the parent has not reached yet.
- 422, with that field's message. The answer is 422 with the form request's own sentence: the guardian's phone number must look like +919876543210. The response carries Precognition true and Vary Precognition.
- Nothing was written. The probe asked the store after every request. There is no admissions.log yet, because the route's closure was never called.
- The field, corrected. The parent types +919876543210 and the form asks again about the same field. Every rule that was kept passed.
- 204, and a header that says so. The answer is 204 with Precognition-Success true: the after hook aborts as soon as the named fields pass, so the form can mark the field as good.
- The submit itself. The parent presses submit, and the same URL is asked without the Precognition header. This time the dispatcher is the ordinary one: the rules pass and the closure runs.
- 201, and one line written. The answer is 201 with the validated data, and admissions.log now has one line. One rule list served both the live check and the submit, which is the point of precognition.
The probe sends the parent’s half-filled form, then the corrected field, then the whole form, then the submit. After each one it asks the storage disk whether the action’s file exists:
from session import run
# One form, three precognitive requests and the submit itself, with the file the action writes watched after each.
run(
"""
php precognition.php
""",
app=True,
overlay=("request-probe",),
) Output
$ php precognition.php
== a precognitive request, one field, bad
status 422
Precognition true
Vary Precognition
body {"message":"The guardian's phone number must look like +919876543210.","errors":{"guardian_phone":["The guardian's phone number must look like +919876543210."]}}
log file no admissions.log yet
== a precognitive request, one field, good
status 204
Precognition true
Precognition-Success true
Vary Precognition
log file no admissions.log yet
== a precognitive request, whole form
status 204
Precognition true
Precognition-Success true
Vary Precognition
log file no admissions.log yet
== the submit itself
status 201
Vary Precognition
body {"saved":{"name":"Meera Nair","born_on":"2016-01-05","batch":"evening","seats":"2","guardian_phone":"+919876543210"}}
log file admissions.log has 1 line(s)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 precognition_session.py
The first request names one field in a Precognition-Validate-Only header and gets 422 with that field’s message, the form request’s own sentence about the +91 shape. filterPrecognitiveRules keeps the rules whose field matches the names and drops the rest, so an empty form does not report every required box while the parent is still typing the first one. The corrected field answers 204 with Precognition-Success: true. Every precognitive answer carries Precognition: true, and every answer from the route carries Vary: Precognition, which keeps a cache from serving a precognitive answer to a real request.
The line that matters is the last one of each block. no admissions.log yet after all three precognitive requests, and admissions.log has 1 line(s) after the submit. The action never ran, and there are two places it was stopped. With Precognition-Validate-Only, an after-validation hook stops the request with 204 as soon as the named fields pass. Without that header, as in the third request, the precognition dispatcher resolves the route’s parameters — which is what validates the form request — and then stops with 204 instead of calling the closure.
Check your middleware for side effects
Everything up to the action runs on every keystroke. A middleware that counts interactions, logs activity or consumes a rate limit should ask $request->isPrecognitive() first, as the documentation’s own example does, or the count will measure typing.
Checkpoint
Check: Precognitive requests
Valid is not the same as allowed
A rule answers one question: is this value acceptable? A batch_id with exists:batches,id can be a real batch that belongs to another centre, another school, another city. The rule passes, and the row must still not be touched.
The test is simple. If the answer would change for a different caller sending the same payload, the question is authorization, and its answer is 403, not 422. Two of this lesson’s recorded runs were refused, one for each reason:
| Compared on | Validation | Authorization |
|---|---|---|
| The question it answers | Is this payload acceptable? | May this caller send it? |
| Where it lives | rules(), rule classes, after() |
authorize(), a policy |
| Same payload, another caller | the same answer | the answer may change |
| The recorded refusal | 422, The is admin field is prohibited. |
403, This action is unauthorized. |
Put the check in authorize() when it is about the whole request, as the staff header is here, and in a policy when a particular row decides. Keeping the two apart also keeps each answer useful: a 422 tells the caller what to change, and a 403 tells them that no change to the payload will help.
Key idea
Validation describes the payload; authorization describes the caller. One class holds both methods, and they answer with different status codes on purpose.
A later module of this track builds gates and policies properly, with the ownership checks a real school needs. Until then, authorize() with a direct check is enough, and it is already in the right place.
Checkpoint
Check: Valid against allowed
Practise
Build this on your own Pathshala project, starting from the rule list you wrote in the previous lesson.
- Run
make:request StoreAdmissionRequest, move the rules in, and change the action to take the class and callvalidated(). Send a request before you touchauthorize()and confirm the 403. - Add
prepareForValidation()that upper-cases the tax identifier and strips spaces from the phone number, then print whatrules()sees to satisfy yourself about the order. - Run
make:rule Ifscand write the eleven-character format check for a bank code: four letters, a zero, then six characters. Add a test with a made-up code for each branch of the rule. - Add
#[FailOnUnknownFields], post a payload with one extra key and read the message. Decide whether you want it on your public API routes too. - Add
HandlePrecognitiveRequeststo the route and send a precognitive request withPrecognition-Validate-Onlynaming one field. Confirm with your own counter that the action did not run.
The self-check below asks about every section of this page.
Practice that adapts to you
Practise this lesson
13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does a form request give you that a rule list in the controller does not?
Four things, and only the first is about tidiness. It gives one place for everything about accepting that request: the rules, the messages, the attribute names, and authorize(), which answers with a 403 before any rule runs. It gives the action a parameter whose easiest method is the safe one, so validated() replaces $request->all() without anybody having to remember. It gives hooks a rule list cannot have: prepareForValidation() to shape input before the rules read it, and after() for a check that needs a count or another row. And it can be asked by a browser: with the precognition middleware on the route, the same class answers a live check field by field, so the front end never holds a second copy of the rules. The cost is a file and the discipline of writing authorize(), which starts as false for exactly that reason.
The questions below take this lesson’s class further:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A form request increments a per-user counter in prepareForValidation() and refuses non-staff callers in authorize(). The counter rises for people who only ever get a 403. Explain the order, and say where that work goes.
-
Mid to senior
A rule class checks a tax identifier's shape and check character, and a colleague now wants to skip the verification step because "the number is validated". Say what the rule proves, and design the rest.
-
Mid to senior
After live validation is added to a form, a rate limiter starts refusing parents halfway through filling it in, and an activity log is full of entries nobody made. Explain both, and fix them.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What does a form request give an action that a rule list in the controller does not?
One class for everything about accepting the request (rules, messages, attribute names and authorize(), which answers 403 before any rule runs), a parameter whose validated() is the easy method, and the hooks prepareForValidation() and after().
-
What does a form request generated by make:request do before you edit it?
Its authorize() returns false, so it refuses every caller until you decide who may send it.
-
In which order do a form request's hooks run?
prepareForValidation(), then authorize(), then rules(), then after(). Input is therefore shaped even for a caller who is then refused with a 403.
-
How does a custom rule class report a failure, and why check shape before checksum?
It has one method and reports through the $fail callback. Checking the shape first means the reader gets the message that fits what is actually wrong.
-
An identifier passes its checksum. What does that prove?
Only that it is well formed. It does not prove the identifier was ever issued, or that it belongs to the caller.
-
What does the FailOnUnknownFields attribute change on a form request?
An extra key that would otherwise be ignored now fails with a 422. It suits a form you own, not a public API whose older clients send fields you no longer read.
-
What does a precognitive request run, and what does it answer?
The middleware and the rules, but not the action, so nothing is written. The recorded run answered 422 for a bad field and then 204 with Precognition-Success: true once the fields were valid.
Key takeaways
make:requestgeneratesauthorize()returningfalse, so a new form request refuses every caller until you decide who may send it.- The hooks run as
prepareForValidation(),authorize(),rules(),after(), so input is shaped even for callers who are then refused with a 403. - A custom rule class has one method and reports through
$fail; checking shape before checksum gives the reader the message that fits what is wrong. - A checksum shows that an identifier is well formed, not that it was issued or that it belongs to the caller.
#[FailOnUnknownFields]turns an ignored extra key into a 422, which suits a form you own and not a public API with older clients.- A precognitive request runs the middleware and the rules but not the action: the recorded run answered 422 and then 204 with
Precognition-Success: true, and wrote nothing until the real submit.
Terms in this lesson
- checksum
- A character or number computed from the rest of a value, so a single mistyped character makes the whole value inconsistent and can be caught offline.
- custom rule class
- A class with one validate method that receives a field, its value and a fail closure, so a check the built-in rules do not cover can be reused anywhere.
- form request
- A class holding everything about accepting one request: who may send it, the rules, the messages and the hooks. The action takes it as a parameter.
- PHP attribute
- Metadata written in square brackets above a class, method or property, which the framework reads to change behaviour without a method or a property being written.
- precognition
- Asking a route what it would answer: the middleware and the rules run and the controller does not, so a form can validate a field live against the server's own rules.
References
- Validation: form request validation (Laravel (laravel.com))
- Validation: custom validation rules (Laravel (laravel.com))
- Precognition (Laravel (laravel.com))
- Authorization: via middleware and policies (Laravel (laravel.com))
- Central Goods and Services Tax Rules, rule 10: issue of registration certificate (Central Board of Indirect Taxes and Customs (taxinformation.cbic.gov.in))
- Central Goods and Services Tax Rules, rule 46: tax invoice (Central Board of Indirect Taxes and Customs (taxinformation.cbic.gov.in))
- Laravel framework v13.34.0 source, ValidatesWhenResolvedTrait.php (Laravel (GitHub))
- Laravel framework v13.34.0 source, CanBePrecognitive.php (Laravel (GitHub))
- Laravel framework v13.34.0 source, Precognition.php (Laravel (GitHub))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress