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

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.

  • Intermediate
  • 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

  • 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:

What Artisan writes for a request and for a rule Python · make_session.py
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:

app/Http/Requests/StoreAdmissionRequest.php PHP · request-probe/app/Http/Requests/StoreAdmissionRequest.php
<?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:

routes/web.php: three routes and one precognitive one PHP · request-probe/routes/web.php
<?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

  1. One question php artisan make:request StoreAdmissionRequest has just run. What does the generated authorize() return, and what does that mean for the first request you send?

    Choose one answer.

    Show the answer: One question

    Answer: false, so every request to the route answers 403 until you change it

    A fresh form request is closed. That is the safer way round, and it is also why a new route answers 403 before you have written anything wrong.

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:

Interactive · stage by stage The four hooks, in the order they run Step through one request and watch where each hook can stop it.
The four hooks of a form request, in the order they run, step 8 of 8. Showing The class is built, prepareForValidation(), authorize(), rules() (the prepared input), after(), Your action. In focus: Your action.403422 or redirectThe class is builtbefore your actionprepareForValidation()input is tidiedauthorize()may this caller sendrules()the prepared inputafter()cross-field checksYour actiongets validated()

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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.

A refused caller, a tidied phone number, and a check that needs a count Python · order_session.py
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

  1. One question A form request tidies input in prepareForValidation() and refuses non-staff callers in authorize(). A caller with no staff header sends a request. What ran?

    Choose one answer.

    Show the answer: One question

    Answer: prepareForValidation(), then authorize(), which answered 403 before any rule

    validateResolved() calls prepareForValidation(), then passesAuthorization(), then builds the validator, then the after hooks. Shaping input before the caller is checked is safe; a database write there would not be.

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:

app/Rules/Gstin.php PHP · request-probe/app/Rules/Gstin.php
<?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:

A made-up number, a mistyped one, two wrong shapes and lower case Python · gstin_session.py
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

  1. Question 1 of 2 What does a ValidationRule class do to report a problem?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It calls the $fail closure with a message, and may call it more than once

    One closure, called once per problem. That keeps a rule usable anywhere a rule list is, including in a precognitive request, where the same class answers the live check.

  2. Question 2 of 2 The GSTIN rule checks a shape and then a check character. The probe sent the well-formed number in lower case. Which message came back, and why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The shape message, because the pattern asks for capitals

    Order the checks inside a rule from cheap and structural to expensive and specific, and return after each failure. A reader then gets the message that describes what is actually wrong.

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:

One extra key, with and without the attribute Python · unknown_session.py
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

  1. One question The same payload with an extra is_admin key is posted to two routes whose form requests differ only in '#[FailOnUnknownFields]'. What did the recorded run print?

    Choose one answer.

    Show the answer: One question

    Answer: 422 "The is admin field is prohibited." with the attribute, and 200 without it

    Ignoring a key is right for an API that must accept what newer clients send. Refusing it is right for a form whose fields you control, because a key nobody declared is either a mistake or an attempt.

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:

Interactive · message by message Three precognitive requests, then the submit Step through the four requests the form sends and read each answer.
A precognitive request, then the submit itself, step 8 of 8. Messages so far: The form to The server: POST with Precognition; The server to The form: 422, one message; The form to The server: POST, same field; The server to The form: 204 Precognition-Success; The form to The server: POST, no header; The server to The store: append one line.The forma browserThe serverrules onlyThe storeone filePOST with Precognition422, one messagePOST, same field204 Precognition-SuccessPOST, no headerappend one line

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. The field, corrected. The parent types +919876543210 and the form asks again about the same field. Every rule that was kept passed.
  6. 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.
  7. 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.
  8. 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:

A live check that writes nothing, then a submit that writes one line Python · precognition_session.py
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

  1. Question 1 of 2 A precognitive request reaches a route that has the HandlePrecognitiveRequests middleware. What happens?

    Choose every answer that is right.

    Show the answer: Question 1 of 2

    Answer:

    • The form request is resolved, so its rules are applied
    • The route's middleware runs
    • The answer carries a Precognition header set to true

    Everything up to the action runs, and the action does not. That is what makes the answer trustworthy as a live check and harmless as a request.

  2. Question 2 of 2 The form asks about the phone field alone while the rest of the form is still empty. Which header makes the answer about that field only?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Precognition-Validate-Only, naming the field

    Without the header, every rule runs and an empty form reports every required field, which is not what a parent typing the first box should see.

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

  1. One question batch_id has exists:batches,id and passes. The batch belongs to another centre. Where does that belong, and why not in the rules?

    Choose one answer.

    Show the answer: One question

    Answer: In authorize() or a policy, because it is a question about this caller

    Validation answers "is this value acceptable?" and authorization answers "may this caller use it?". Mixing them gives a 422 where a 403 belongs, and the caller is told to change a payload that was never the problem.

Practise

Build this on your own Pathshala project, starting from the rule list you wrote in the previous lesson.

  1. Run make:request StoreAdmissionRequest, move the rules in, and change the action to take the class and call validated(). Send a request before you touch authorize() and confirm the 403.
  2. Add prepareForValidation() that upper-cases the tax identifier and strips spaces from the phone number, then print what rules() sees to satisfy yourself about the order.
  3. Run make:rule Ifsc and 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.
  4. Add #[FailOnUnknownFields], post a payload with one extra key and read the message. Decide whether you want it on your public API routes too.
  5. Add HandlePrecognitiveRequests to the route and send a precognitive request with Precognition-Validate-Only naming 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.

  1. Question 1 of 13 What moves into a form request class when a controller's rule list moves out of it?

    Choose every answer that is right.

    Show the answer: Question 1 of 13

    Answer:

    • Who may send the request
    • The rule list
    • The messages and the attribute names for its fields

    A form request is everything about accepting one request: who may send it, what shape it must have, and what to call the fields when something is wrong. What to do with the data stays in the action.

  2. Question 2 of 13 The action's signature is (StoreAdmissionRequest $request). How does it read the validated data?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: $request->validated(), or safe() to narrow it

    This is the quiet benefit: the natural thing to write in the action is also the safe thing, because validated() is on the object the signature already names.

  3. Question 3 of 13 The admission form is also submitted by a staff import screen, which may send three extra fields. What keeps both honest with the least duplication?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: A second form request that extends the first and adds the three fields to rules()

    Form requests are ordinary classes, so inheritance, traits and a shared rules array all work. Keep the sharing to the rules, and let each class keep its own authorize().

  4. Question 4 of 13 A teammate puts "increment a counter for this user" in prepareForValidation(). What goes wrong, given the recorded order?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: The counter rises for callers that authorize() then refuses with a 403

    Keep prepareForValidation() to shaping input: merge, trim, split, normalise. Anything that writes, charges, sends or counts belongs after authorization, in the action or a listener.

  5. Question 5 of 13 Which check belongs in after() rather than in a field's rule list?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: Three seats asked for when the morning batch has room for two, which only a count can answer

    Field rules describe one payload. after() is for a check that needs another field, a count, a timetable or a row, and it can attach its message to whichever field the reader should look at.

  6. Question 6 of 13 php artisan make:rule Gstin generated a class. Which interface does it implement, and what is its method?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: ValidationRule, with validate(string $attribute, mixed $value, Closure $fail)

    One method with three arguments. Because the value arrives as mixed, a rule for text should check the type before it checks the shape, as the GSTIN rule does.

  7. Question 7 of 13 The GSTIN rule accepts a number whose check character matches. A colleague says the form can now trust the number. What is the honest claim?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: The number is well formed, which is all a checksum can show

    A checksum rule catches typing mistakes cheaply and locally. Anything stronger needs a lookup against the issuer, which is a network call with its own failure modes and belongs behind a queue, not in a rule.

  8. Question 8 of 13 What does '#[StopOnFirstFailure]' on a form request do?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: The request reports the first failing field only

    The five attributes of a Laravel 13 form request are ErrorBag, FailOnUnknownFields, RedirectTo, RedirectToRoute and StopOnFirstFailure. Each replaces a property or a method people used to write by hand.

  9. Question 9 of 13 A public JSON API is versioned, and older clients send fields newer ones dropped. Should its form request carry '#[FailOnUnknownFields]'?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: No: an old client's extra field would start failing, although it is harmless

    Use it where you own both ends, such as a form in your own Blade page or an internal screen. Leave it off where clients age in the field, and keep the quiet filtering of validated() as the guard instead.

  10. Question 10 of 13 What problem does Precognition solve for a form with live validation?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: The rules are not duplicated in the front end

    Two copies of a rule list drift apart. Precognition keeps one copy on the server and asks it, which is why a custom rule class and an after() check are live too.

  11. Question 11 of 13 A middleware on the route counts each interaction with the application. The form sends a precognitive request after every keystroke. What should the middleware do?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Skip the counting when $request->isPrecognitive() is true

    Everything up to the action runs during a precognitive request, so every side effect in that path must decide whether it belongs there. Counters, rate limits and logs usually do not.

  12. Question 12 of 13 Which of these are authorization questions rather than validation questions?

    Choose every answer that is right.

    Show the answer: Question 12 of 13

    Answer:

    • Does this centre belong to the caller's school?
    • May this teacher edit this batch?

    A simple test: if the answer would change for a different caller with the same payload, it is authorization. Put it in authorize() for the whole request, or in a policy when a row decides.

  13. Question 13 of 13 A form request's authorize() returns false. What does the caller receive?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: 403, with the message that the action is unauthorized

    Three answers, three meanings: 401 you are not signed in, 403 you are not allowed, 422 what you sent is not acceptable. Keeping them apart is why authorize() and rules() are separate methods.

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.

  1. 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.

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

  2. 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.

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

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

  1. 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().

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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:request generates authorize() returning false, 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

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.