Building Web Apps with Laravel Module 3 – Requests, routing and middleware
Requests, input and uploaded files
Read Laravel 13 input with typed helpers, see what trimming and null conversion change, negotiate JSON, trust proxies and hosts, and treat uploads as claims.
What you will learn
- Read query, form and JSON input with input, query, post and json, and say which reader sees which part
- Explain how TrimStrings and ConvertEmptyStringsToNull change input, and what integer, boolean, date and enum return for odd values
- Predict whether a request gets a JSON or an HTML answer from its Accept header and its path
- Configure trusted proxies and trusted hosts so the application reads the real caller and refuses a forged host
- Check the name and type of an uploaded file as claims and read what its bytes say
Before you start
What you’ll learn · Before you start
What you will learn
- Read query, form and JSON input with input, query, post and json, and say which reader sees which part
- Explain how TrimStrings and ConvertEmptyStringsToNull change input, and what integer, boolean, date and enum return for odd values
- Predict whether a request gets a JSON or an HTML answer from its Accept header and its path
- Configure trusted proxies and trusted hosts so the application reads the real caller and refuses a forged host
- Check the name and type of an uploaded file as claims and read what its bytes say
Before you start
On this page
A request for /fees?qty=12abc&status=refunded reaches a Laravel controller as a quantity of 12 and a status of null, and nothing complains. Both values passed through helpers that are polite about bad input, and a filter page that trusts them quietly shows the wrong list. Knowing what each reader returns for a value that is wrong tells you where a check must be added.
This lesson follows input from the request to your code. You will see which reader sees the query string, the form and a JSON body, what two global middleware do before your code starts, how a request chooses between an HTML and a JSON answer, how the application learns the caller’s real address when a load balancer sits in front, and why the name of an uploaded file proves nothing. Every example runs on Laravel 13.34.
Input has three doors and several readers
A request can carry data in the query string after the question mark, in a form body, or in a JSON body. The Request object has a reader for each, and one reader that merges them. Post a form with a query string, then a JSON body with the same query string, and ask every reader what it sees:
<?php
use App\Enums\FeeStatus;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
// The probes are scripts with no browser, so they have no forgery token. Real form routes keep PreventRequestForgery.
// Which door each helper reads: the query string, the form body, or a JSON body.
Route::post('/sources', fn (Request $request) => response()->json([
'input(name)' => $request->input('name'),
'input(batch)' => $request->input('batch'),
'query(name)' => $request->query('name'),
'query(batch)' => $request->query('batch'),
'post(name)' => $request->post('name'),
'post(batch)' => $request->post('batch'),
'json(qty)' => $request->json('qty'),
'input(qty)' => $request->input('qty'),
'only(name)' => $request->only('name'),
]))->withoutMiddleware(PreventRequestForgery::class);
// What the controller receives after TrimStrings and ConvertEmptyStringsToNull, read with the typed helpers.
Route::get('/filters', fn (Request $request) => response()->json([
'raw query' => $request->server->get('QUERY_STRING'),
'input(name)' => $request->input('name'),
'input(note)' => $request->input('note'),
'string(name)->upper()' => $request->string('name')->upper()->toString(),
'integer(qty)' => $request->integer('qty'),
'boolean(notify)' => $request->boolean('notify'),
'date(from)' => $request->date('from', '!Y-m-d', 'Asia/Kolkata')?->toIso8601String(),
'enum(status)' => $request->enum('status', FeeStatus::class)?->value,
])); from session import run
# One form post and one JSON post, each with a query string: which reader sees which part.
run(
"""
php sources.php
""",
app=True,
overlay=("input-probe",),
) Output
$ php sources.php
== form body "name=Meera" with ?batch=7
input(name) "Meera"
input(batch) "7"
query(name) null
query(batch) "7"
post(name) "Meera"
post(batch) null
json(qty) null
input(qty) null
only(name) {"name":"Meera"}
== JSON body {"qty":2,"name":"Meera"} with ?batch=7
input(name) "Meera"
input(batch) "7"
query(name) null
query(batch) "7"
post(name) null
post(batch) null
json(qty) 2
input(qty) 2
only(name) {"name":"Meera"}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sources_session.py
In the form post, input('name') and post('name') both return Meera, but query('name') is null, because the name was not in the query string. input('batch') returns 7 and post('batch') returns null, since the batch came from the query. input reads the query and the body together, while query and post read one door each.
The JSON post shows a difference. json('qty') reads the JSON body and input('qty') returns the same 2, because for a JSON request input includes the JSON. post('name') is null here, as the name is in a JSON body and not a form. That is why a controller that must serve both forms and API clients should use input, and one that wants only the query string should say query. only('name') returns an array with just the named keys.
Passing everything the request carries
Model::create($request->all()) hands every key a caller sends to the model, including keys you never put in a form. Use only([...]) for the fields you expect, or the validated data of a form request, and keep the model’s fillable list as a second guard.
Checkpoint
Check: Which reader sees the query, the form and the JSON
Two middleware clean input, and typed readers choose the type
Query and form values are text. Before your action runs, two global middleware from the first lesson of this module change that text: TrimStrings removes spaces from the ends of every string, and ConvertEmptyStringsToNull turns an empty string into null. Then a typed reader turns what is left into a type. Follow one query string through:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The text as it arrived. The visitor's browser sent name with two spaces before and one after Meera, and note with nothing after the equals sign. Nothing has changed it yet.
- Spaces are trimmed. TrimStrings removes spaces from the ends of every string in the query and the body. The name becomes Meera before any of your code reads it.
- Empty becomes null. ConvertEmptyStringsToNull turns each empty string into null, so the empty note arrives as null and not as an empty string. A rule or a column that expects text must allow null.
- A helper chooses the type. Query values are always text. integer('qty') gives the number 12, boolean('notify') reads on as true, date('from', '!Y-m-d', 'Asia/Kolkata') builds a date at midnight in India, and enum('status', FeeStatus::class) gives the enum case paid.
- Bad value, not always an error. An unknown status gives null from enum(), qty=12abc gives 12 from integer(), and a date in the wrong format throws and ends in a 500. Check the value before you trust a helper to reject it.
- The action gets the values. The action runs with trimmed text, nulls instead of empty strings and typed values. It still has to decide what a missing or odd value means.
Run it for three query strings, with a route that prints what the readers return:
from session import run
# Padded text, an empty field, odd numbers, flags, a date and an enum value, read with the typed helpers.
run(
"""
php typed.php 'name=%20%20Meera%20¬e=&qty=12¬ify=on&from=2026-01-05&status=paid' 'qty=12abc¬ify=off&from=2026-01-05&status=refunded' 'qty=-3¬ify=0&from=05/01/2026'
""",
app=True,
overlay=("input-probe",),
) Output
$ php typed.php 'name=%20%20Meera%20¬e=&qty=12¬ify=on&from=2026-01-05&status=paid' 'qty=12abc¬ify=off&from=2026-01-05&status=refunded' 'qty=-3¬ify=0&from=05/01/2026' == ?name=%20%20Meera%20¬e=&qty=12¬ify=on&from=2026-01-05&status=paid input(name) "Meera" input(note) null string(name)->upper() "MEERA" integer(qty) 12 boolean(notify) true date(from) "2026-01-05T00:00:00+05:30" enum(status) "paid" == ?qty=12abc¬ify=off&from=2026-01-05&status=refunded input(name) null input(note) null string(name)->upper() "" integer(qty) 12 boolean(notify) false date(from) "2026-01-05T00:00:00+05:30" enum(status) null == ?qty=-3¬ify=0&from=05/01/2026 the action threw, so the visitor got status 500
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typed_session.py
In the first request the name " Meera " arrived as Meera, the empty note as null, qty=12 as the number 12, notify=on as true, the date as midnight in India, and status=paid as the enum case paid. The second request is the interesting one. qty=12abc became 12, because integer casts the text the way PHP does and keeps the digits it finds. notify=off became false. status=refunded is not a case of the enum, so enum returned null and did not complain. The missing name came back as null and string('name') as an empty text.
The third request used from=05/01/2026 with the format !Y-m-d, and the action threw, so the visitor got a 500. date with a format parses strictly and throws for text that does not fit. The ! at the start of the format resets every field the text does not give to its starting value, which gives midnight. Without it, PHP fills the time of day from the clock, and two requests for the same date would get different times.
The typed readers are forgiving for numbers and booleans, strict for dates, and silent for enums. A filter that must reject an unknown status needs to notice the null, for example by checking has('status') and returning 422 when the enum is null. The module on validation, later in this track, covers rules that do this in one line.
Key idea
A typed reader never says that the input was wrong. It returns a value in its type, and that can be 12 for 12abc or null for an unknown enum case, so the check that decides is yours.
Checkpoint
Check: Trimming, null conversion and the typed readers
The Accept header and the path decide JSON or HTML
A browser asks for HTML. A mobile app asks for JSON. Laravel decides which answer to build from the request, in two methods. wantsJson() is true when the first type in the Accept header is JSON. expectsJson() is true in that case too, and also for requests marked as Ajax that accept anything. The skeleton’s bootstrap/app.php uses expectsJson() along with the path, in shouldRenderJsonWhen, so errors of an API come back as JSON:
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/page', fn (Request $request) => response()->json([
'path' => $request->path(),
'wantsJson' => $request->wantsJson(),
'expectsJson' => $request->expectsJson(),
'prefers' => $request->prefers(['text/html', 'application/json']),
])); from session import run
# The same unknown URL asked for as a browser and as a JSON client, then under /api.
run(
"""
export APP_DEBUG=false
php negotiate.php
""",
app=True,
overlay=("negotiation-probe",),
) Output
$ export APP_DEBUG=false
$ php negotiate.php
GET /page application/json 200 application/json {"path":"page","wantsJson":true,"expectsJson":true,"prefers":"application\/json"}
GET /page text/html 200 application/json {"path":"page","wantsJson":false,"expectsJson":false,"prefers":"text\/html"}
GET /nothing-here (no Accept) 404 text/html (an HTML error page)
GET /nothing-here application/json 404 application/json { "message": "The route nothing-here could not be found." }
GET /api/nothing-here (no Accept) 404 application/json { "message": "The route api/nothing-here could not be found." }
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 negotiation_session.py
The first two lines show a route asked for application/json and for text/html: wantsJson and expectsJson are true and false, and prefers picks the type the client listed first among the two the action offers. The third and fourth lines are a URL that no route handles. With no Accept header the 404 is an HTML error page, and with Accept: application/json it is JSON with a message. The last line asks for the same missing URL under /api with no Accept header, and the answer is JSON anyway, because the skeleton’s rule is $request->is('api/*') || $request->expectsJson().
Debug mode was off for this run. With it on, the JSON error would carry a stack trace, as the middleware lesson showed. For a mobile client, send the Accept: application/json header on every call, so that even an error from a route outside /api is not an HTML page that the app cannot parse.
Other readers of the request come from the same object. header('X-Request-Id') reads a header, bearerToken() reads the token of an Authorization: Bearer header, path() returns the path without the host, is('batches/*') matches it against a pattern, and userAgent() and ip() describe the caller. Treat all of them as input from outside: the documentation says IP addresses should be considered untrusted and used for information only.
Checkpoint
Check: JSON or HTML from Accept and the path
Behind a load balancer, the caller’s address is a claim
In production Pathshala’s application usually sits behind a load balancer or a reverse proxy, and the application sees every connection as coming from the balancer, say 10.0.0.5. The balancer passes the visitor’s address in an X-Forwarded-For header and the scheme it terminated in X-Forwarded-Proto. The application believes those headers only for proxies it has been told to trust. This is the trusted proxy: A load balancer or reverse proxy whose forwarded headers, such as X-Forwarded-For, the application is told to believe. Headers from any other address are ignored. setting, and it is why the rate limiters of the middleware lesson need it. The harness asks one request through a balancer and one from a caller who writes a header of their own, each under three settings:
from session import run
# What the application believes about the caller under three trust settings.
run(
"""
php proxy_requests.php
""",
app=True,
overlay=("proxy-probe",),
) Output
$ php proxy_requests.php == through the balancer trust nobody (the default) ip 10.0.0.5 http trust only the balancer, '10.0.0.5' ip 203.0.113.9 https trust everybody, '*' ip 203.0.113.9 https == straight from a liar trust nobody (the default) ip 198.51.100.7 http trust only the balancer, '10.0.0.5' ip 198.51.100.7 http trust everybody, '*' ip 192.0.2.1 http
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 proxy_session.py
Through the balancer, with the default of trusting nobody, ip() is 10.0.0.5 for everyone and the request counts as plain http. Trusting only 10.0.0.5 gives the visitor’s address 203.0.113.9 and https. Trusting everybody gives the same, which looks right until you read the second block. A caller at 198.51.100.7 who connects directly and writes X-Forwarded-For: 192.0.2.1 is believed only when every caller is trusted. Then ip() returns the made-up address, and a rate limit keyed by address, a log line or a block list all see the lie.
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Through the balancer. The visitor at 203.0.113.9 connects to a load balancer at 10.0.0.5, which opens its own connection to the application and adds X-Forwarded-For with the visitor's address.
- No trust, ip is proxy. With no trusted proxy the application ignores the header, so ip() returns 10.0.0.5 for every visitor and isSecure() is false.
- Trust the balancer. With trustProxies set to 10.0.0.5, the application believes the header from that address. ip() returns 203.0.113.9 and isSecure() is true.
- A liar connects directly. A caller at 198.51.100.7 skips the balancer and writes X-Forwarded-For with somebody else's address 192.0.2.1.
- Liar not trusted. Because 198.51.100.7 is not a trusted proxy, the header is ignored and ip() stays 198.51.100.7.
- Trust star believes lies. With trustProxies set to the star, every caller is a proxy. The same forged request gives ip() as 192.0.2.1.
You set the proxies in bootstrap/app.php, with $middleware->trustProxies(at: ['10.0.0.5']) for one balancer or a list of addresses or ranges. Use the star only when the application is reachable through the balancer alone, for example when a firewall blocks every other path to it. A balancer that runs on a platform with addresses that change is the usual reason for the star, and then the firewall carries the responsibility.
Checkpoint
Check: Trusted proxies and the caller's address
A forged Host header poisons the links the application builds
The Host header names the site the caller wanted, and Laravel uses it to build absolute URLs. An attacker who can send a request with Host: evil.example to a password-reset or receipt form may get the application to put that host into the link in the email. The defence is a list of hosts the application answers to. The trusted host: A host name the application agrees to answer to. A request with any other Host header is refused with a 400, so links are never built from a forged name. middleware is off until you switch it on, and it also does nothing in the local environment, so the session sets the environment to production:
from session import run
# A production server, with no trusted hosts configured: the link is built from whatever Host the caller sends.
run(
"""
export APP_ENV=production
export APP_DEBUG=false
php host_requests.php
""",
app=True,
overlay=("hosts-probe",),
) Output
$ export APP_ENV=production $ export APP_DEBUG=false $ php host_requests.php Host: pathshala.example 200 http://pathshala.example/receipts/1042 Host: evil.example 200 http://evil.example/receipts/1042
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hosts_before_session.py
Without a list, the request for evil.example answered 200 and the link in the body begins http://evil.example/. Now register the hosts in bootstrap/app.php:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
// Only these hosts, and subdomains of APP_URL's host, may reach the application.
$middleware->trustHosts(at: ['pathshala.example']);
})
->withExceptions(function (Exceptions $exceptions): void {
//
})->create(); from session import run
# The same two requests once the application lists the hosts it answers to.
run(
"""
export APP_ENV=production
export APP_DEBUG=false
php host_requests.php
""",
app=True,
overlay=("hosts-probe", "hosts-fix"),
) Output
$ export APP_ENV=production $ export APP_DEBUG=false $ php host_requests.php Host: pathshala.example 200 http://pathshala.example/receipts/1042 Host: evil.example 400
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hosts_after_session.py
The real host still answers 200 with its own link, and evil.example answers 400 Bad Request. trustHosts(at: [...]) takes names or patterns, and by default it also trusts subdomains of the host in APP_URL; pass subdomains: false to turn that off. If the list must come from configuration or the database, at accepts a closure.
Defensive setting, tested with a request
Check this on the server you own: send a request with a made-up Host header and expect a 400. A test like this one fails when the list is missing, and it passes after you add it.
Checkpoint
Check: Trusted hosts
An uploaded file’s name and type are claims by the sender
A file arrives as an UploadedFile. The browser fills in its name and its type, and a caller who is not a browser can write anything there. The probe makes a file whose bytes are PHP source, with a client name receipt.png and a client type image/png, and asks the object both ways:
from session import run
# What a file's own claims say, and what its bytes say.
run(
"""
php upload_facts.php
""",
app=True,
overlay=("upload-probe",),
) Output
$ php upload_facts.php getClientOriginalName() receipt.png getClientOriginalExtension() png getClientMimeType() image/png getMimeType() text/x-php extension() getSize() 28 bytes
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 upload_session.py
getClientOriginalName(), getClientOriginalExtension() and getClientMimeType() repeat the claim: a PNG called receipt.png. getMimeType() reads the file’s content and returns text/x-php, and extension(), which comes from that content, has nothing for a PHP source. The two answers disagree, and the second one is the better evidence. Storing the file under its client name, or deciding that it is safe because of its client type, is how an upload becomes a script on the server.
For now the rule is short: never build a path from the client’s name, never trust the client’s type, and read the type from the content. The later module on validation and uploads covers allowed types, size limits and where to store a file.
Checkpoint
Check: Uploaded file names and types are claims
Practise
Check these on your own Pathshala project:
- Write a students index that reads
batch,status(an enum),joined_from(a date, inAsia/Kolkata) andq. Sendstatus=refundedand decide what the page does whenenumreturnsnullwhile the key was present. - Add a feature test that posts the same value as a form and as JSON and asserts that
inputreturns it both times andpostonly for the form. - Set
trustProxiesto the address of your own balancer, write a test that sendsX-Forwarded-Forfrom an address that is not the balancer, and assert thatip()ignores it.
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.
Interview questions
Warm-up: what do TrimStrings and ConvertEmptyStringsToNull do to input, and what does that mean for validation?
TrimStrings removes spaces from the start and end of every string in the query and the request body, and ConvertEmptyStringsToNull turns every empty string into null. Both are global middleware, so they run on every request before a controller or a form request sees the data. The practical effects are that a name typed with spaces arrives clean, a text box left empty arrives as null and not as an empty string, and a rule or a column that expects a string must allow null for an optional field, otherwise a blank box fails validation. A field where spaces matter can be listed as an exception with trimStrings(except: [...]) in the middleware configuration.
Three longer questions come from these readers:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A staff member opens /students?status=refunded&joined_from=05/01/2026 and sees either the full student list or a server error, depending on the day. Explain what each typed reader does with these values and design the filter so that bad input gets a clear answer.
-
Fresher to mid
After the move to a load balancer, the access log shows one address for all visitors and the login limiter locks out whole classrooms. A colleague proposes trusting every proxy to fix it. Evaluate that and propose what you would set.
-
Mid to senior
A receipt upload checks that getClientMimeType() starts with image/ and stores the file under its original name. A colleague rejects it. Say what is wrong, what you would read instead, and how you would store the file.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do input(), query(), post() and json() differ on a Laravel request?
input() reads the query string and the body together, query() only the query string, post() only a form body, and json() only a JSON body. Choose the reader by where the value is allowed to come from.
-
What do the TrimStrings and ConvertEmptyStringsToNull middleware do to input?
Before any controller runs, the first trims spaces from both ends of every string and the second turns every empty string into null. So an empty optional field arrives as null, and its rule and column must allow null.
-
Why is $request->integer('seats') no substitute for validation?
Typed readers return a value of their type instead of reporting bad input: integer() keeps the leading digits of 12abc, enum() gives null for an unknown case. Only a date in the wrong format throws. Rejecting a value is your code's decision.
-
When does a Laravel 13 app send an error, such as a 404, as JSON rather than an HTML page?
When the request accepts JSON or its path starts with api/, the skeleton's rule. A mobile client should therefore send Accept: application/json on every call.
-
When should the app believe the client address in X-Forwarded-For?
Only when the request came through a proxy you trust, such as your own load balancer. Name it rather than trusting every address, because a caller who reaches the app directly can write the header themselves.
-
Why list the hosts an app answers to?
The app builds links and redirects from the Host header, so a forged one plants its domain in them. With trusted hosts set, a request for any other host gets a 400.
-
What does an uploaded file's original name and type tell you?
Only what the sender claims; both can be anything. The type read from the file's own content is the better evidence.
Key takeaways
inputreads the query and the body together,queryonly the query string,postonly a form body, andjsonthe JSON body, so choose the reader by where the value is allowed to come from.- TrimStrings and ConvertEmptyStringsToNull change every string before your code starts, so an empty field arrives as
nulland an optional rule has to allow it. - Typed readers are lenient:
integerkeeps the digits of12abc,enumreturnsnullfor an unknown case, and a date in the wrong format throws, so the decision to reject a value stays in your code. - A request is answered in JSON when it accepts JSON or its path starts with
api/, which is the skeleton’s rule, and a mobile client should sendAccept: application/jsonon every call. - The application believes
X-Forwarded-Foronly from trusted proxies, so name your balancer and not the star where a caller can reach the application directly, and list the hosts you answer to so a forged Host header gets a 400. - The name and the type that come with an uploaded file are claims by the sender, and the type read from the file’s content is the better evidence.
Terms in this lesson
- trusted host
- A host name the application agrees to answer to. A request with any other Host header is refused with a 400, so links are never built from a forged name.
- trusted proxy
- A load balancer or reverse proxy whose forwarded headers, such as X-Forwarded-For, the application is told to believe. Headers from any other address are ignored.
References
- Requests (Laravel (laravel.com))
- Middleware (Laravel (laravel.com))
- Validation (Laravel (laravel.com))
- Input Validation Cheat Sheet (OWASP)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress