Suprnova handlers receive a Request - the wire-level HTTP request - or
a typed form-request struct that parses, validates, and authorizes the
body before your code runs. Both paths live on the same #[handler]
macro; you pick the shape per route. This chapter covers both, plus the
multipart upload extractor and the raw accessors you reach for in
middleware.
Typed form requests
The #[request] attribute marks a struct as a FormRequest. The macro
adds serde::Deserialize and validator::Validate derives and emits an
impl FormRequest so the #[handler] macro knows to extract and
validate it on the way in:
use request;
A handler that names this type as its parameter is handed an already-validated value:
use ;
use crateCreateUserRequest;
pub async
A handler that names Request instead gets the raw request through
unchanged:
use ;
pub async
Both are extractors - the #[handler] macro looks up
FromRequest::from_request for every parameter type, and any struct
that implements FormRequest gets a blanket FromRequest impl for
free.
Validation rules
Validation runs through the validator crate. Common rules:
String validations
use LazyLock;
use Regex;
// validator 0.20 implements `AsRegex` for `std::sync::LazyLock<Regex>`
// but not for `once_cell::sync::Lazy<Regex>` - use the std type so the
// derive's `#[validate(regex(path = "..."))]` expansion typechecks.
static PHONE_REGEX: =
new;
Numeric validations
Nested and collection validations
use Deserialize;
Common validation attributes
| Attribute | Description | Example |
|---|---|---|
email |
Valid email format | #[validate(email)] |
url |
Valid URL format | #[validate(url)] |
length |
String/collection length | #[validate(length(min = 1, max = 100))] |
range |
Numeric range | #[validate(range(min = 0, max = 100))] |
regex |
Regex pattern match | #[validate(regex(path = "PATTERN"))] |
contains |
String contains substring | #[validate(contains(pattern = "@"))] |
does_not_contain |
String doesn't contain | #[validate(does_not_contain(pattern = "admin"))] |
nested |
Validate nested struct | #[validate(nested)] |
Validation error responses
When validation fails, Suprnova returns a 422 response with the Laravel / Inertia-compatible error bag:
HTTP 422 Unprocessable Entity
The errors shape matches what @inertiajs/* clients read from
usePage().props.errors directly.
The message summarises the first error and the count of the rest.
See Precognition for the same body during live validation.
Nested fields
A #[validate(nested)] failure is reported under a dotted key naming the
full path, the same notation Laravel uses. A nested struct contributes
parent.field; an element of a validated Vec<T> contributes
parent.<index>.field:
Index 1 is the second element - the first element passed and is absent
from the bag. Bind the key straight through on the client:
form.errors['items.1.name']. A form body or a multipart body sends the
same fields under bracketed names, shipping_address[street] and
items[1][name], which read into the same structs and fail under the same
dotted keys; see nested names and lists.
Renamed fields
The error keys are the names the client sent. When serde renames a field
(#[serde(rename_all = "camelCase")] on the struct, or rename on a
field), a failure on unit_price is reported as unitPrice. The same names
drive Precognition-Validate-Only, where a Rust name matches nothing, and a
localized message reads the name snake-cased, "unit price", as Laravel's
does. A hook names fields by their Rust names, as validate! does, and its
errors are keyed by the input names too.
A nested object is renamed at its own level when its type registers its
names. A form request does. A plain struct nested in one derives
suprnova::InputNames for it:
An error on the second item's price is then keyed lineItems.1.unitPrice.
Without the derive the nested part keeps the Rust name,
lineItems.1.unit_price. A generic nested type, and a field serde
flattens, keep Rust names too, as do the serde attributes the derives do
not read (from, try_from, transparent).
Complete example
A user registration endpoint, end to end.
Define the request:
// src/requests/create_user.rs
use request;
Create the controller:
// src/controllers/user.rs
use ;
use crateCreateUserRequest;
pub async
pub async
Register the routes:
// src/routes.rs
use ;
use cratecontrollers;
routes!
Authorization and cross-field hooks
You use four lifecycle hooks on FormRequest: prepare_for_validation,
authorize, after_validation, and after_validation_async. Both the #[request]
attribute and the #[derive(FormRequestDerive)] form emit a default
impl FormRequest for you. To override any hook, add the
#[form_request(custom_hooks)] opt-out to suppress the default impl,
then write your own. (This mirrors the #[multipart(custom_hooks)]
pattern.)
use ;
use Deserialize;
use Validate;
The opt-out also works under the #[request] attribute form - useful
when you want the attribute's auto-derives but need to override hooks:
use ;
When authorize returns false, extraction returns
FrameworkError::Unauthorized and renders:
HTTP 403 Forbidden
after_validation is the synchronous cross-field hook - use it for
rules like "password and confirmation must match". after_validation_async
is the asynchronous counterpart and is where database-backed rules
(e.g. the built-in Unique) participate in automatic validation. Both
run after the derived rules on a real request, even when those rules report
errors. You get one merged error bag from the rules and both hooks. An
empty error bag counts as success. For marked Precognition requests, you
keep the stage rules described in Precognition.
You prepare input before authorization with prepare_for_validation:
You register a transformation without reading the body. The parser applies it after authorization, before validation. Your JSON names use dotted paths; your form and multipart names use the submitted field name. You return an error to stop preparation before authorization or a body read.
For a classic form whose Accept prefers text/html without X-Inertia,
you get a 302 redirect back with errors.default and old input flashed
to the session. You read old text with session.get_old_input(key) on the
next request. You keep 422 errors for JSON and the Inertia middleware's
303 redirect for Inertia visits.
use ;
use Deserialize;
use Validate;
Body size caps
The per-struct #[form_request(max_body_bytes = N)] attribute
overrides the process-global 8 MiB cap on a single FormRequest:
use FormRequestDerive;
use Deserialize;
use Validate;
// 64 MiB
Content-Length is parsed up front and the request is rejected with
HTTP 413 before a body byte is read when the declared size exceeds
the cap; clients that lie about Content-Length still trip the
streaming byte counter during read.
Body read deadlines
The server bounds how long a request body takes to arrive, as it bounds
how long the request head takes. Every read of the body follows two
deadlines: body_bytes, json, form, input, the form request and
multipart extractors, the multipart parsers, the middleware that read a
body before the handler, such as CSRF, and a handler that reads the raw
stream itself.
- The per-frame deadline,
SERVER_BODY_READ_TIMEOUT(default 30 seconds), is the longest the server waits for the next piece of the body. - The total deadline,
SERVER_BODY_TOTAL_TIMEOUT(default 3600 seconds, one hour), is the longest the whole body may take, counted from when the server read the request head. It is a backstop that ends a body that keeps arriving a byte at a time inside every per-frame wait, and is long enough for a large upload over a slow link.
A body that misses either deadline is abandoned: the read fails with an
HTTP 408 Request Timeout error, which the request answers like any other
error, and the server closes the connection after the response. A 408
response always carries Connection: close. Zero or a value that doesn't
parse falls back to the default, so neither deadline can be turned off.
The deadlines bind the body, not the method that reads it. A handler that
takes the body with Request::into_parts gets a
BodyState::Streaming(TimedBody), and each frame it awaits waits under the
same deadlines and fails with the same 408 when it misses one. The
error of a TimedBody frame is a FrameworkError, so ? returns it.
http_body_util::BodyExt reads the frames:
use BodyExt;
use BodyState;
use ;
pub async
A body nobody reads is abandoned too. If a handler keeps the request and never reads the body, the server closes the connection at the total deadline, without a response. A handler that does other work before it reads the body must finish reading it within the total deadline, or the connection closes the same way.
A streamed upload stays within the per-frame deadline as long as the client keeps sending; a client that sends nothing for 30 seconds is cut off. To accept uploads from clients that pause longer, or uploads that take more than an hour, raise the deadlines in the environment:
SERVER_BODY_READ_TIMEOUT=60
SERVER_BODY_TOTAL_TIMEOUT=7200
Or set them on the server configuration, which is how a test sets short ones:
use Duration;
use ;
register;
A global TimeoutMiddleware also bounds a body read inside
the middleware chain, since the read is part of the handler's work. For a
route that takes long uploads, raise that timeout as well as the body
deadlines.
Content type detection
FormRequest::extract looks only at the Content-Type header:
application/x-www-form-urlencoded→ parsed as a form, as described in empty values, repeated names, and fields that don't parsemultipart/form-data→ the parts parsed as a form by the same rules, each file part filling anUploadedFilefield, as described in files in a form requestapplication/jsonor anyapplication/*+jsonsuffix → parsed viaserde_json- Anything else (including a missing header) → rejected with HTTP 415 Unsupported Media Type, before the body is read
For an upload that checks each file while the body streams in, see file uploads below.
Empty values, repeated names, and fields that don't parse
A form can't send null. An HTML form sends an empty input as name=, and
Inertia sends a null value as an empty field when it posts FormData.
Laravel's default ConvertEmptyStringsToNull middleware reads the empty
value as null. A form-urlencoded FormRequest and a MultipartRequest
read it the same way:
- An
Optionfield isNone, anOption<String>included. - A required field is missing, a
Stringincluded, so the request fails with a422. - A list keeps the
nullin its place. An element of aVec<Option<T>>isNone. An element of aVec<T>, which has no place fornull, is missing, and is reported under its index, such asids.1.
The name itself stays, holding null, so a cleared field is told apart from
one never sent. Read into a serde_json::Value or a map, name=Ada&bio=
gives {"name": "Ada", "bio": null}, and a #[serde(flatten)] map sees
bio too. A #[serde(default)] field that arrives empty is null, not its
default: the default is for a field the form left out.
A bool field reads what forms send, in a url-encoded body, a query
string and a multipart body alike: 1 and 0, as Inertia sends them,
true and false, and on and off, as an HTML checkbox sends them, the
words in any case.
A name sent more than once keeps its last value, as PHP does. The body
title=&title=Holiday gives Holiday, and title=Holiday&title= gives
null. A name with brackets is nested data, as the next section describes:
tags[]=rust&tags[]=web fills a tags: Vec<String> field. req.form(),
the form-urlencoded branch of req.input(), and req.query_into() read by
the same rules.
A field that is missing, or whose value doesn't parse as its type, answers
the way a failing rule does: a 422 whose errors names every such field
under its input name, with the catalog message for its type. An Inertia
form gets the usual redirect back with those errors in props.errors. A
JSON body reads the same way, nested fields included, and a JSON null
where a value is required counts as missing.
| Failure | Catalog key |
|---|---|
A required field missing, empty, or JSON null |
validation-required |
A value that isn't an integer, a number, or a bool for such a field |
validation-integer, validation-numeric, validation-boolean |
A JSON value that isn't a string for a String field, or a list where one value belongs |
validation-string |
| Any other value that doesn't fit, such as an unknown enum variant | validation-format |
title=&count=abc posted to a struct with title: String and count: u32
answers with both:
A body that isn't JSON at all, or a field a struct denies with
#[serde(deny_unknown_fields)], is no field's failure: it answers 422
with a message that words it.
use ;
pub async
Why Suprnova diverges
- A JSON body keeps
""as an empty string. JSON has its ownnull, and Inertia sends anullvalue as one, so an empty string in JSON is text the client chose. To require text there, validatelength(min = 1). Laravel converts a JSON""tonulltoo. - Text isn't trimmed. Laravel's
TrimStringsmiddleware runs beforeConvertEmptyStringsToNull, so a value of spaces isnullin Laravel and text in Suprnova. - A JSON body reads a
boolas JSONtrueorfalseonly. Laravel'sbooleanrule also takes1,0,"1"and"0"there. A JSON client sends a JSON boolean, which aboolfield reads as it is. - A
MultipartRequestfield that holds one file takes the first file part of its name, where PHP keeps the last. The extractor checks a file while the body streams, before it knows whether a later part of the same name follows, so it decides on the first one. - On a real request, the
#[validate(...)]rules run only once every field parses, so a request with a field that doesn't parse hears about the parse failures alone. Laravel checks every rule at once. A struct can't be built while a field has no value of its type, and the rules run on the struct. - A JSON object nested in the body, or a nested object in a form
(
shipping_address[street]), reports its first missing field, and a field after that object is checked once the object reads. Missing fields at the top of the body are all reported at once. - Precognition parses and validates only the listed fields, through every validation stage. An unlisted field that does not parse does not block the listed fields' answer. The typed struct is built only for the real request.
Nested names and lists
A form sends nested data under names with brackets, and Laravel reads them
as nested arrays. The Inertia client writes such names when it puts a GET
visit's data in the query string and when it sends a form that holds a file
as multipart/form-data, and an HTML form can name its inputs the same way.
A form request, req.input(), req.form() and req.query_into() read them
as PHP's parse_str does, in a url-encoded body, a multipart body and a
query string alike:
user[name]=Adais the membernameofuser. It fills a nested struct field, and aserde_json::Valuereads it as{"user": {"name": "Ada"}}.tags[]=a&tags[]=bis a list, in the order sent.photos[1]=b&photos[0]=ais a list in index order,["a", "b"]. A gap leaves no hole:ids[5]=x&ids[1]=ygives["y", "x"].- A key that isn't an integer makes an object, so
filters[status]=xandfilters[sort]=namefill afiltersstruct. - Names nest to any depth up to 64 levels:
items[0][name]is thenameof the first item. A name nested deeper is dropped, with what was read under its first part, as PHP drops it. - An empty value is
nullat every depth, as at the top.
The OrderRequest above reads this body, and a filter page reads its query
the same way:
shipping_address[street]=1+Main+St&shipping_address[city]=Springfield&items[]=a&items[]=b
use Deserialize;
use ;
pub async
A field that fails is named by its path with dots, as Laravel names it:
items[3]=x read into items: Vec<u32> fails as items.3, and a missing
shipping_address[city] as shipping_address.city. The client binds these
keys as they are: form.errors['items.3'].
req.query_params() and req.query_param(...) read the query's pairs as
sent, so a bracketed name is one key there: req.query_param("filters[status]").
Reading the body directly
For one-off endpoints or middleware that doesn't want a full
FormRequest, the Request type itself reads the body in three flavors -
each consumes self because the body can be read at most once:
use Deserialize;
use ;
pub async
pub async
pub async
For raw access, req.body_bytes().await returns the buffered Bytes
plus the RequestParts metadata (route params and content type). Use
body_bytes_with_cap(n) to override the global 8 MiB cap on a
case-by-case basis.
Resolving services alongside the form
Validated form requests compose with the service container.
Use App::resolve::<T>() (or App::get::<T>()) inside the handler:
use ;
use crateCreateUserRequest;
use crateUserService;
pub async
Files in a form request
The Inertia client sends a form that holds a file as multipart/form-data:
a list of files under photos[0], photos[1] (or photos[] when the
application asks for brackets), and nested fields under names such as
user[name]. A form request reads that body. Text parts read as a
url-encoded body reads, and a part with a file name fills an
UploadedFile<V> field, where V is a validator, or a tuple of them, from
suprnova::http::upload::validators:
use UploadedFile;
use ;
use ;
pub async
- An empty part, which is how Inertia sends a
nullfile, and an empty file input both leave anOption<UploadedFile>Noneand a requiredUploadedFilemissing. - A file its validators refuse fails under its input name,
photos.1for the second photo, with the validator's message. Text where a file belongs fails withvalidation-file, and a file where text belongs withvalidation-string. req.input()reads a multipart body the same way. Aserde_json::Valueholdsnullwhere a file was sent, as Laravel'sinput()reads a file field.- An
UploadedFilereads only from a multipart body. In a JSON body the field fails with a422.
The body is capped at the form request's max_body_bytes, as every body it
reads is, and the parts take the part ceiling and the in-memory limit of
the multipart settings below: a file part above the limit goes to a temp
file, and a text part above it answers 413. The validators run once the
whole body is read. #[derive(MultipartRequest)], below, checks each file
while it streams in, and stops reading at the byte that breaks a MaxSize.
File uploads (MultipartRequest)
multipart/form-data is its own extractor - #[derive(MultipartRequest)]
streams the body part by part, spilling large file parts to a temp file
above the configured threshold so a 200 MiB upload never sits fully in
RAM. Each field carries a #[field("name")] annotation that names the
wire field; file fields use UploadedFile<V> where V is a validator
(or a tuple of validators) from suprnova::http::upload::validators.
use ;
use UploadedFile;
use ;
pub async
Field shapes:
| Declaration | Wire shape |
|---|---|
UploadedFile<V> |
required file |
Option<UploadedFile<V>> |
optional file |
Vec<UploadedFile<V>> |
array uploads (photos[]) |
String / u32 / any FromStr |
text field (required) |
Option<String> / Option<T: FromStr> |
optional text field |
Vec<String> / Vec<T: FromStr> |
repeated text fields |
Vec<Option<String>> / Vec<Option<T: FromStr>> |
repeated text fields, an empty one as None |
A part reaches a field by its name, read as a form request reads
nested names and lists. The Inertia client sends
a list of files with indexes, photos[0] and photos[1], and a nested
field with brackets, user[name]:
photos[0]andphotos[]fill theVecfield declared asphotos[](orphotos), in index order.[]appends after the highest index the list holds, and a later part at an index replaces an earlier one, as PHP keeps it.user[name]fills the field declared asuser.name, the name a form request's rules use.- A part sent under the exact name in
#[field(...)]reaches that field, whatever the name holds. - Parts are read in the order they were sent, as PHP reads them: a later
part sent under a parent name, such as
useror a barephotos, replaces whatuser.nameorphotos[]held, and a lateruser[name][first]orphotos[0][caption]makesuser.nameor that element an array, which fails as the wrong kind of value.
use MultipartRequest;
use UploadedFile;
A text field is read through its type's FromStr, except a bool, which
takes what forms send: 1 and 0, as Inertia sends them, true and
false, and on and off, as an HTML checkbox sends them, the words in any
case. An unchecked checkbox sends nothing, so declare it Option<bool> and
read a missing value as false with unwrap_or(false).
An empty text part is null, as
empty values, repeated names, and fields that don't parse
describes: an Option field is None, a required field reports
validation-required, and an empty element is None in a
Vec<Option<T>> and reports validation-required under its index in a
Vec<T>. A String field is no exception. A Vec<UploadedFile<V>> field
still leaves out an empty file input or a null file, which is how a
client leaves a file out.
A text field that holds one value, rather than a Vec, takes the last part
sent for it. Only that part is parsed, so an earlier part that doesn't parse
reports nothing, and a last part that doesn't parse reports one error.
Built-in validators in suprnova::http::upload::validators:
MaxSize<N>- stops reading the body at the chunk that takes the file pastNbytes.ImageFile- accepts the image types Laravel'simagerule accepts, detected from the file's magic bytes: JPEG, PNG, GIF, BMP, WebP, AVIF, HEIC and HEIF. Every other type is refused, other images included: TIFF, PSD, ICO and JPEG XL. SVG is refused too, because it is markup that can carry script; to accept it on purpose, use aMimeType<L>allowlist that namesimage/svg+xml, as Laravel accepts it only throughimage:allow_svg. (Named after Laravel's own rule; the plainImagename belongs to the image-manipulation pipeline - see Images.)Dimensions<D>- checks an image's width and height against Laravel'sdimensionsconstraints, taken from your ownDimensionLimitstype:min_width,max_width,min_height,max_height,width,heightandratio. Each is an associated function that returnsNoneunless you override it. The size is read from the image header, for the typesImageFileaccepts, without decoding the image. The header can sit anywhere in the file: the extractor reads it as the file streams in, and passes over a JPEG's metadata (a camera's EXIF block and thumbnail, for example) and an AVIF or HEIC file's leading boxes without keeping them. A file whose size cannot be read is refused too: another type, an SVG, a corrupt header, or a file that ends before its header does.MimeType<L>- accepts a fixed allowlist provided by your ownMimeAllowlisttype. An entry such asimage/*admits every subtype of its type exceptimage/svg+xml: an SVG passes only when your allowlist namesimage/svg+xml, alone or besideimage/*. The type is detected from the file's magic bytes. The client'sContent-Typecounts only for bytes that carry no magic (text/csv,application/json), never for a type that has some: bytes that aren't a PNG don't pass asimage/pngwhatever the header claims. Markup and script text is refused before the header is read. SVG is the exception, being markup: an allowlist that namesimage/svg+xmlaccepts a file whose root element is<svg>(after an optional XML declaration, comments and doctype), and refuses any other text declared as SVG. An SVG can carry script, so serve uploaded ones as attachments or from another origin.()- no-op;UploadedFile<()>accepts any bytes.
Validators compose as tuples: (ImageFile, MaxSize<5_242_880>) runs both,
short-circuiting on the first failure. Put ImageFile before
Dimensions<D>, so a file that is not an image reports validation-image:
use ;
/// A square avatar, at most 1000 pixels wide.
;
ratio is the width divided by the height, so write Some(3.0 / 2.0)
for Laravel's ratio=3/2. An image passes when its own ratio is within
Laravel's tolerance of about one pixel.
Validation errors
A failure that belongs to one field answers 422 with Laravel's
{ "message", "errors" } body, the message under the field's form input
name. The name is the one in #[field(...)], with a trailing []
replaced by the element's index: the one its name gives, or its place
among the parts appended with [], counted from zero. A PDF sent as
photos[3] to #[field("photos[]")] reports under photos.3, and one
sent as the second photos[] under photos.1. For an Inertia form,
InertiaValidationRedirectMiddleware turns that 422 into a redirect back
with the errors, and the form shows each one under its field.
Every failing field is reported at once. Each message comes from the validation catalog by its key:
| Failure | Catalog key |
|---|---|
| A required field is missing | validation-required |
Text that doesn't parse as an integer, a float or a bool field |
validation-integer, validation-numeric, validation-boolean |
| Text that doesn't parse as any other type | validation-format |
Bytes that aren't UTF-8 for a String field |
validation-string |
| A text part where a file belongs | validation-file |
| A file part where text belongs | validation-string |
A file over MaxSize<N>, the limit in kilobytes as Laravel words it |
validation-max-file |
A file ImageFile refuses |
validation-image |
A file Dimensions<D> refuses, or whose size it cannot read |
validation-dimensions |
A file MimeType<L> refuses, with the allowed types |
validation-mimetypes |
A text part whose bytes aren't UTF-8, as a page served in a legacy
encoding can send, doesn't parse as any type: a String field reports
validation-string, and any other field the key its type reports for text
that doesn't parse.
To change a message, define its key in your own catalog:
# lang/en/validation.ftl
validation-image = Choose a picture for { $field }.
A file input left empty arrives as a file part with no file name and no
bytes, and Inertia sends a null file as an empty text part. Both count as
a missing file: an optional field is None, and a required one reports
validation-required. A field that holds one file takes the first part
sent for it that isn't one of these, and ignores every later part for it
without checking it. Text where a file belongs is never checked as a file:
it reports validation-file, however long it is.
A file that fails while the body streams, as one over MaxSize<N> does,
stops the read after the chunk that crossed the limit. The hooks and the
handler don't run, and every temp file the request wrote is removed.
Custom validators
Implement UploadValidator to check a file yourself. Return
FrameworkError::invalid_upload with a keyed message for a file that is
invalid input, so it reports under the field's name like the built-ins.
Return any other error for a failure to check the file; that error keeps
its own status.
use UploadValidator;
use ;
;
validate_final sees the first 16 KiB of the file in sniff. The
extractor calls validate_received, whose default runs validate_final.
Override validate_received instead when you need what the extractor read
from the whole file: its ReceivedPart carries the sniff, the size,
the declared content_type, and the image_size that Dimensions<D>
checks.
Per-field caps and array bounds
The byte cap on the total body is global (25 MiB by default for
multipart, configurable via
suprnova::http::upload::set_global_max_multipart_body_bytes). Per-field
caps prevent abuse where a body of many small parts grows
Vec<UploadedFile<_>> unbounded within the byte budget:
The (max_count + 1)-th part for that field returns HTTP 413 before
allocating, so the extra part never reaches Vec growth. Every part the
field takes counts, whatever name it was sent under: photos[], photos[3]
and a repeated index alike. The four limits
on the whole request - the body's byte cap, the part ceiling, a field's
max_count, and the in-memory limit on a text part - all answer 413
without reading the body further. Form text must fit in memory, so a text
part longer than the spill threshold (2 MiB by default, set with
suprnova::http::upload::set_global_upload_spill_threshold) bounds the
request the way the byte cap does, as PHP's post_max_size does for
Laravel. When one chunk crosses the byte cap and a file's MaxSize
together, the 413 wins.
Why Suprnova diverges
Laravel checks an array's size with array|max:N, a validation rule that
runs after PHP has buffered the whole request, so too many files is a 422.
max_count bounds the request while it streams, before the extra part is
read, so it refuses the request the way the byte cap does.
Laravel's mimetypes:image/* rule admits a file detected as
image/svg+xml, because the wildcard matches any image/ type. MimeType
leaves SVG out of image/*, as Laravel's image rule leaves it out unless
you pass allow_svg. An SVG is markup that can run script, so you name
image/svg+xml to accept one.
Laravel's dimensions passes an SVG without checking it. Dimensions<D>
refuses one, because an SVG has no pixel size to check. The limits are
functions of a type rather than a rule string, because a validator is
built with Default inside the derive and takes no arguments.
Authorize and after-validation hooks
MultipartRequest mirrors FormRequest's hooks via the
MultipartRequestHooks trait. By default the derive emits an empty
impl; opt in to your own with #[multipart(custom_hooks)]:
use ;
use ;
You run these stages in order:
prepare_for_validation, where you registerRequest::transform_inputchanges before authorization.authorize, before any byte of the body is read.falseanswers403.- Extraction and field validation.
after_validation.after_validation_async, where you use database checks such asUnique.- The handler, only when the merged error bag is empty.
You run both hooks for a real request with a constructed value, even when field validation reports errors. You stop before hooks on a streaming failure or when missing or malformed scalar input prevents construction. A marked request keeps the Precognition stage rules. You get a redirect with flashed errors and old text input for a classic HTML form, as with a form request.
A hook's non-empty ValidationErrors answers 422 like a field failure;
an empty set counts as success. Hook errors use the same input names: an
error under a Rust field name, such as photos.1 on the photos field,
reports under that field's #[field(...)] name. The impl needs
#[async_trait] only when it overrides after_validation_async.
Streaming to storage
UploadedFile::store_as writes the part to a registered storage disk.
For disk-backed parts the path is fully streaming (64 KiB chunks via
opendal::Operator::writer); in-memory parts use a single write call.
Use the content-derived extension when the storage path is
content-addressed - the filename header is untrusted:
use Storage;
let disk = disk?;
let path = format!;
form.avatar.store_as.await?;
See Filesystem for the storage disk registry.
File organization
The standard structure for requests:
src/
├── requests/
│ ├── mod.rs # Re-exports all requests
│ ├── create_user.rs # CreateUserRequest
│ ├── update_user.rs # UpdateUserRequest
│ └── create_post.rs # CreatePostRequest
├── controllers/
│ └── user.rs # Uses CreateUserRequest
└── routes.rs
src/requests/mod.rs:
pub use CreateUserRequest;
pub use UpdateUserRequest;
End-to-end type safety with Inertia
Requests can also derive InertiaProps to generate TypeScript types, enabling end-to-end type safety from your Rust backend to your React frontend.
Generating TypeScript types for requests
Add InertiaProps derive alongside #[request]:
use ;
Run type generation:
This generates TypeScript types in frontend/src/types/inertia-props.ts:
export interface CreateTodoRequest {
title: string
description: string | null
}
Type-safe forms with Inertia
Use Inertia's <Form> component for the cleanest form handling:
import { Form, usePage } from '@inertiajs/react'
export default function CreateTodo() {
const { errors } = usePage().props
return (
<Form action="/todos" method="post">
<input
type="text"
name="title"
placeholder="Todo title"
/>
{errors?.title && <span className="error">{errors.title}</span>}
<textarea
name="description"
placeholder="Description (optional)"
/>
<button type="submit">Create Todo</button>
</Form>
)
}
For more control, combine <Form> with the useForm hook and your generated types:
import { Form, useForm } from '@inertiajs/react'
import type { CreateTodoRequest } from '../types/inertia-props'
export default function CreateTodo() {
const { data, setData, errors, processing } = useForm<CreateTodoRequest>({
title: '',
description: null,
})
return (
<Form action="/todos" method="post">
{({ processing }) => (
<>
<input
type="text"
name="title"
value={data.title}
onChange={(e) => setData('title', e.target.value)}
placeholder="Todo title"
/>
{errors.title && <span className="error">{errors.title}</span>}
<textarea
name="description"
value={data.description || ''}
onChange={(e) => setData('description', e.target.value || null)}
placeholder="Description (optional)"
/>
<button type="submit" disabled={processing}>
Create Todo
</button>
</>
)}
</Form>
)
}
What the derive buys you
- TypeScript catches field-name typos and type mismatches at compile time.
- IDE autocomplete reads the generated
.tsdirectly. - Rename a field in Rust, rerun
suprnova generate-types, and the TypeScript surface follows.
See TypeScript types for the full generation pipeline.
Request accessors
Beyond the validated-form pattern above, the Request type carries Laravel-style accessors for inspecting the wire-level request - URL, headers, query string, content negotiation, route metadata, and client IP. These are useful in middleware, in handlers that want raw access alongside a FormRequest, and in any place where validated parsing isn't the right tool.
URL and path
| Method | Returns | Notes |
|---|---|---|
req.path() |
&str |
Raw URI path. |
req.decoded_path() |
String |
Path with percent-escapes resolved. |
req.segments() |
Vec<String> |
Path split on /, empty segments dropped. |
req.segment(index, default) |
Option<String> |
1-based segment access. |
req.url() |
String |
Scheme + host + path (no query string). |
req.full_url() |
String |
URL + query string, its pairs sorted by key and re-encoded as Laravel's fullUrl() writes them. |
req.full_url_with_query(&[("k","v")]) |
String |
Append or override query keys. |
req.full_url_without_query(&["k"]) |
String |
Strip query keys. |
use ;
pub async
Host, scheme, IP
| Method | Returns | Source order |
|---|---|---|
req.host() |
Option<String> |
X-Forwarded-Host → Host → URI authority. Lowercased, numeric port stripped, None when invalid. |
req.http_host() |
Option<String> |
Host plus port when non-default. |
req.scheme_and_http_host() |
Option<String> |
scheme://host:port. |
req.scheme() |
&'static str |
"https" when [secure] is true, else "http". |
req.secure() |
bool |
URI scheme → X-Forwarded-Proto → X-Forwarded-Ssl: on. |
req.ip() |
Option<String> |
From a trusted proxy: X-Forwarded-For, read from the right, then X-Real-IP when there is no X-Forwarded-For. Otherwise the peer address. |
req.ips() |
Vec<String> |
Every address the request names: X-Forwarded-For left to right, X-Real-IP, then the peer address. For logs only. |
req.user_agent() |
Option<&str> |
User-Agent header. |
req.port() |
Option<u16> |
Host header port → X-Forwarded-Port → URI port. |
req.host() reads the host as Symfony's getHost does. It lowercases the
host and strips a port only when the port is all digits:
Host: Example.COM:8080 gives example.com, and Host: example.com:abc
gives example.com:abc. A host that holds anything but letters, digits,
-, _, ., : and the brackets of an IPv6 literal gives None, so
Host: bad<host> never reaches http_host(), scheme_and_http_host(),
url(), a redirect, or a cache key. Laravel throws a
SuspiciousOperationException for such a host; req.host() returns
None, and TrustHosts answers the request 400.
req.ip() is the address to key a limit on or to check against an
allowlist. It reads the proxy headers only when the TCP peer is a trusted
proxy, and it returns the peer address in every other case:
- When the peer is not in the trusted proxies,
req.ip()is the peer address. The headers are ignored, because any client can send them. - When the peer is a trusted proxy and the request has an
X-Forwarded-Forheader,req.ip()reads the list from the right. It skips every entry that is a trusted proxy and returns the first entry that is not. A proxy adds the address it saw to the right end of the header, so the left end is what the client wrote and the client chooses it. The header may arrive as several lines. They count as one list, in order. - When the peer is a trusted proxy and the request has no
X-Forwarded-For,req.ip()readsX-Real-IP. With both headers present,X-Real-IPis not read. - An entry that is not an address ends the walk, and
req.ip()returns the proxy that wrote that entry. An entry with a port, such as203.0.113.5:54321, is read as its address.
Three rules keep this safe:
- List every proxy of the chain. A proxy that is not in the trusted proxies is taken for the client, and all of its clients share its address.
- List proxies and nothing else.
APP_TRUSTED_PROXIEStakes addresses and CIDR ranges, such as10.0.0.5,173.245.48.0/20,2400:cb00::/32. A client that connects from a listed range is believed like a proxy and chooses its own address. A range of/0, which is every address, is refused, and boot fails. - Let the proxy write the header. A trusted proxy has to add to
X-Forwarded-Foror replace it. A proxy that passes the client's header on unchanged lets the client write all of it. A proxy that setsX-Real-IPhas to remove anyX-Forwarded-Forfrom the request.
The Forwarded header of RFC 7239 is not read. See
Rate Limiting for
what this means for per-address limits and
Environment Variables
for the variable.
Trusted hosts
The Host header is written by the client. When your application builds
an absolute URL from it, such as a password reset link, a forged host
sends that link to the attacker's site. TrustHosts is a middleware that
answers 400 with the message Bad request. to any request whose host
you do not serve. It mirrors Laravel's TrustHosts.
TrustHosts::new() trusts the host of APP_URL and its subdomains. With
APP_URL=https://example.com, a request for example.com or
api.example.com passes, and a request for evil.test gets 400:
use ;
pub async
TrustHosts::at(patterns, subdomains) trusts the hosts that the regular
expressions in patterns match. When subdomains is true, it also
trusts the APP_URL host and its subdomains:
use ;
pub async
The middleware follows these rules:
- A pattern matches without regard to case, anywhere in the host. Anchor
it with
^and$to match the whole host. - A request whose
req.host()isNonegets400. - With no pattern at all, every valid host passes, as in Laravel. That
happens with
TrustHosts::atgiven an empty list andfalse, or with anAPP_URLthat has no host. - In the local environment (
APP_ENV=local, the default whenAPP_ENVis unset), every host passes, as Laravel'sshouldSpecifyTrustedHostsallows. SetAPP_ENVon every other deployment. - Behind a trusted proxy the host comes from
X-Forwarded-Host, so name the public host the browser asked for.
The middleware reads APP_URL and APP_ENV on each request, from the
registered AppConfig or from the environment.
Why Suprnova diverges
Laravel's TrustHosts::at stores the patterns and compiles them on each
request. TrustHosts::at compiles them once and returns a Result, so a
pattern that is not a valid regular expression fails at boot instead of
refusing every request.
Headers and method
req.is_precognitive() is true after the Precognitive middleware marks
the request. req.is_attempting_precognition() reads the header alone,
so it can be true on a route without the middleware. Use the marked state
to skip side effects or change live-validation rules. See
Precognition for route opt-in and narrowed validation.
| Method | Returns |
|---|---|
req.has_header("X-Foo") |
bool |
req.bearer_token() |
Option<String> (last Bearer substring, comma-trimmed) |
req.is_method("POST") |
bool (case-insensitive) |
req.ajax() |
X-Requested-With: XMLHttpRequest, compared exactly as Symfony's isXmlHttpRequest compares it |
req.pjax() |
Truthy X-PJAX header |
req.prefetch() |
X-Moz, Purpose, or Sec-Purpose = prefetch |
Content negotiation
if req.is_json
if req.expects_json
if req.wants_json
if req.accepts_html
let preferred = req.prefers;
let acceptable = req.acceptable_content_types;
accepts(&[ty]) matches both bare types and application/<vendor>+json-style suffixes. accepts_any_content_type() returns true when there is no Accept header or the top preference is */*.
A type the header weights q=0 is refused, as RFC 9110 defines it: acceptable_content_types() leaves it out, and accepts, prefers, wants_json and expects_json treat it as unwanted. The most specific matching range decides, so Accept: */*, application/json;q=0 accepts HTML and refuses JSON. Laravel lists a q=0 type as acceptable; Suprnova follows the RFC instead.
Query string
let id: = req.query_param;
let present: bool = req.has_query;
let map = req.query_params; // HashMap<String, String>
// Typed query parse via serde
let q: SearchQuery = req.query_into?;
query_into reads the query as a form body reads: ?page= leaves page
None, ?q=a&q=b gives b, ?tags[]=a&tags[]=b fills tags, and
?filters[status]=x fills a nested filters struct, as
nested names and lists describes. A field
that is missing or doesn't parse answers as a form request's does: a 422
whose errors names each such field with its catalog message, so an
Inertia visit is redirected back with them. A query that fails for another
reason answers 422 with a message.
Route metadata
After the router dispatches a request, the matched pattern is recorded on the request:
if req.route_is
let pattern = req.route_pattern; // Some("/users/{id}")
let name = req.route_name; // Some("users.show")
route_is(&[...]) accepts * wildcards (Laravel's Str::is semantics).
Aborting early
For early-exit error handling without the full Response envelope, the abort_with / abort_if / abort_unless helpers return a FrameworkError that renders through the standard From<FrameworkError> for HttpResponse pipeline. They compose with ? directly:
use ;
pub async
abort_if / abort_unless return Ok(()) when the condition is false, so the ? continues normally.
Why Suprnova diverges
Laravel exposes a synchronous, merged input bag - $req->input('field'),
$req->all(), $req->only(['a','b']), $req->boolean('flag') - pulled
from the query string and the parsed body together. Suprnova does not
ship that surface. The reason:
- Suprnova's body is consume-once and async. A synchronous
all()would require buffering every body up front to satisfy a method that most handlers never call - the memory and DoS surface differs from PHP's per-request-process lifecycle. - The typed alternative (
#[request]+FormRequest) gives compile-time field names, validation, and content-type-aware parsing - exactly the safety net the untyped bag lacks.
For query / header / route inspection, reach for query_param,
query_into, has_query, bearer_token, and the header readers
above. For body-side access, define a #[request] struct or a
#[derive(MultipartRequest)] extractor.
Next
- Validation - the rule library behind
#[validate(...)]and the shape of the 422 error bag - Responses - building
HttpResponsevalues back from your handler, including streaming and redirects - Errors - handler patterns built on top of
ResponsebeingResult<HttpResponse, HttpResponse> - Routing - registering routes and the
{id}parametersreq.param("id")reads - Authentication -
Auth::user_as,Auth::attempt, and the guards that resolve the current user from the request - Filesystem - registering the storage disks that
UploadedFile::store_aswrites to
