Suprnova validates request input on two complementary tracks:
- Derive validation -
#[validate(...)]attributes on aFormRequeststruct, run automatically byextract(). This is the everyday path and is covered in Requests. It handles per-field rules (email,length,range, …) declaratively. - Rule objects + the
validate!macro - plain values implementingRule/ContextualRule/AsyncRule, composed imperatively. Reach for these when you need cross-field logic, rules that touch the database, or rules you want to store and pass around.
The two tracks accumulate into the same
ValidationErrors bag and render the same
Laravel/Inertia { "message", "errors": { field: [...] } } shape (HTTP
422).
Rule objects
A rule is a value implementing one of four traits:
| Trait | Shape | Use |
|---|---|---|
Rule |
passes(&self, value: &str) |
pure check on one value |
ValueRule |
passes(&self, value: &serde_json::Value) |
check on a JSON-shaped value (array/object) |
ContextualRule |
passes(&self, value, ctx) |
check that reads sibling fields |
AsyncRule |
async passes(&self, value) |
check that .awaits (DB, HTTP) |
Built-in Rules: Required, Email, Min, Max, Between, In,
NotIn, InArray, Integer, Numeric, Boolean, Alpha, AlphaNum,
AlphaDash, Url, UrlProtocols, HttpUrl, Uuid, Digits,
DateFormat, Accepted, Prohibited, Missing,
RequiredIf::when,
Password (strength checks only). Built-in
ValueRules: ArrayKeys, Distinct, Contains, DoesntContain, and
Accepted, Prohibited and Missing again for JSON fields.
Built-in ContextualRules: RequiredIf, RequiredWith,
RequiredUnless, Same, Different, Confirmed, Gt, Gte, Lt,
Lte, the date rules After, AfterOrEqual, Before and
BeforeOrEqual, and ExcludeIf and ExcludeUnless.
Built-in AsyncRules: Unique,
Exists and Password (strength
plus its uncompromised() HIBP check - the one built-in rule implementing
both Rule and AsyncRule).
use ;
Email.passes?; // Ok(())
Note:
Numericaccepts a finite number -NaN,inf, and magnitudes that overflow to infinity are rejected, even though Rust's parser would accept the strings.
URL schemes
Url accepts a value that parses as a URL, whose scheme is on Laravel's
allowlist - the same list Illuminate\Support\Str::isUrl uses - is
followed by ://, and is followed in turn by a non-empty host,
matching Laravel's ^(PROTOCOLS)://HOST pattern in shape (Laravel's
host group has no ? - an absent or empty host never matches). The
scheme list and the ://-plus-host requirement are Laravel's verbatim;
the host is parsed by the url crate rather than Laravel's regex, so an
out-of-range port is rejected here that Laravel would accept. All three
have to hold: mailto:, tel:, and data: are on the allowlist by name
but carry no authority component at all, so Url rejects them; and
file:///etc/passwd fails for the third reason - it has ://, but
nothing sits between the third and fourth /, and nothing isn't a host.
javascript: and vbscript: are rejected outright; they aren't on the
allowlist at all.
ftp://host/x and ssh://host - real hosts, just not web schemes - still
pass, so Url is not a "this is a web page" check, and it says nothing
about where the URL resolves. Rejecting javascript: makes a validated
value safe to put in an href, not safe to fetch. A webhook or callback
target still needs HttpUrl (or your own scheme + SSRF checks); Url
alone doesn't cover that.
For a narrower set, name the schemes you want:
use ;
// Laravel's `url:http,https`
protocols.passes?; // Ok
protocols.passes; // Err
// The same thing, under a name
use HttpUrl;
HttpUrl.passes?;
Url::protocols(...) replaces the allowlist rather than narrowing it,
so an app can accept its own deep-link scheme (myapp://…) without the
framework having an opinion about it - the ://-plus-host requirement
still applies to that custom scheme too. Use HttpUrl (or
Url::protocols(&["https"])) for callback, webhook, and avatar inputs -
a webhook target that resolves to ftp://internal-host/ still parses as
a Url, and an ftp: target is not a webhook target.
Password strength
Password checks length and character-class strength, plus an optional
Have I Been Pwned uncompromised() check - Laravel's Password rule
object, ported. Build it with Password::min(n) and chain the strength
builders:
use ;
let rule = min.letters.mixed_case.numbers.symbols;
passes?; // Ok(())
passes; // Err - too short, no digit, no symbol
| Builder | Requires | Laravel regex |
|---|---|---|
.min(n) (via Password::min) |
at least n characters (floors at 1) |
length check |
.max(n) |
at most n characters |
length check |
.letters() |
at least one Unicode letter | /\pL/u |
.mixed_case() |
an uppercase and a lowercase letter, either order | /(\p{Ll}+.*\p{Lu})|(\p{Lu}+.*\p{Ll})/u |
.numbers() |
at least one Unicode digit | /\pN/u |
.symbols() |
at least one separator, symbol, or punctuation character - a plain space counts | /\p{Z}|\p{S}|\p{P}/u |
Password::defaults_with(|| Password::min(12).letters().mixed_case().numbers()),
called once from bootstrap::register(), sets the process-wide default
Password::defaults() returns everywhere else - Laravel's
Password::defaults(fn () => ...). A second call is ignored (with a
tracing::warn!) rather than silently replacing the first app's chosen
policy.
uncompromised() - because strength alone isn't enough
.uncompromised() (or .uncompromised_with_threshold(n)) adds a check
against the Have I Been Pwned breach corpus, using its k-anonymity range
API: only the first 5 characters of the password's uppercase SHA-1
hash ever leave the process - GET https://api.pwnedpasswords.com/range/{prefix} - and the match against
the full hash happens locally, against the SUFFIX:COUNT lines the API
returns for that prefix. The service never sees the password, or even
its full hash. The threshold comparison is strict (count > threshold),
so the default uncompromised() (threshold 0) fails on any appearance
at all, and a network failure, timeout, or non-2xx response fails
open - the password is treated as clean rather than blocking every
signup during a Have I Been Pwned outage. This matches Laravel's
NotPwnedVerifier exactly.
Because that check is an HTTP round trip, uncompromised() needs
AsyncRule, not the sync Rule the strength checks alone can use. Wire
it through after_validation_async, the same recipe Unique
uses:
use ;
use Deserialize;
use Validate;
Calling the sync Rule::passes on a Password that has uncompromised()
set is a loud error, not a silent skip - a security check that
quietly does nothing is worse than one that never existed. The error
message names after_validation_async as the fix.
HIBP_TIMEOUT_SECS (default 30) controls the request timeout - see
Environment Variables.
A custom verifier that returns Err is a different case from a failed check:
its error text is logged at error level and never reaches the client, and the
response carries the validation-password-unverifiable catalog key ("The
{ $field } could not be checked against known data leaks. Please try again.")
instead. Add that key if you ship your own validation catalog.
Why Suprnova diverges: Password
- Laravel's
Passwordcollects every failed strength check into one array. Suprnova'sRulecontract returns a singleValidationMessage, soRule::passesreports the FIRST failing check, in the order min, max, mixed case, letters, symbols, numbers - fix one at a time rather than seeing the whole list up front. - Laravel's sync validator can call
uncompromised()directly; a PHP request is already inside an event loop that tolerates a blocking HTTP call. Suprnova'sRule::passesis synchronous by contract, so there is no safe place to run the HIBP request from it. Rather than silently skip the check - the one unacceptable outcome for a security-relevant rule - Suprnova'sRule::passesreturns a loud, developer-facing error namingafter_validation_asyncas the fix. Password::defaults_withtakes a plainfnpointer, not a closure, so the configured default staysCopyand needs no heap allocation - a deliberate narrowing from Laravel'sClosure.
Writing your own rule
A custom rule is a unit (or data-carrying) struct with one impl. The
trait gives you check() for free - it pushes any failure message onto
a ValidationErrors bag under the named field - so the rule plugs
into validate! and the after_validation hooks unchanged:
use ;
;
// Now usable everywhere:
StartsWith.passes?;
// or, in a validate! row:
// stripe_id => Required, StartsWith("acct_");
A String converts into a ValidationMessage that renders verbatim,
which is all a single-language app needs. To have the message translated
per locale, return a keyed message instead -
ValidationMessage::keyed("validation-starts-with").arg("prefix", self.0).fallback(…) -
and define the id in lang/<locale>/validation.ftl. See
Localization, which also covers overriding the
built-in rules' messages and the field-<name> naming convention.
For cross-field logic, implement [ContextualRule] instead - the
passes method gets a &FormContext (a HashMap<String, String> of
sibling field values) alongside the value under test. For
database-backed checks, implement [AsyncRule] and use it from
after_validation_async.
Value-shaped rules
Rule only ever sees &str. Two built-ins need more structure than a
string carries, so they implement ValueRule instead, over
&serde_json::Value:
use ;
// Laravel's array:keys - reject keys outside the allowed set. Listed
// keys need not all be present; an empty allowed list is a programming
// error, reported as a keyless message.
ArrayKeys.passes?;
// Laravel's distinct / distinct:ignore_case / distinct:strict.
Distinct
.passes?;
A field validated by a ValueRule must hold serde_json::Value itself
(or Option<serde_json::Value> for a ?:/?=> row) - typically a
request field pulled straight from the JSON body. validate! rows
accept Rules and ValueRules in the same field list; which trait runs
is resolved by which one the rule's type implements, not by anything
you write in the row.
Membership rules
Three rules answer "is this value in that list?", each over the shape it needs:
use ;
// Laravel's in_array:allowed_roles.* - the value must appear in another
// field's list. Pass the list itself: a Vec<String> field and a &[&str]
// literal both work.
InArray.passes?;
// Laravel's contains:rust,web - the array must hold every listed value.
Contains.passes?;
// Laravel's doesnt_contain:banned - the array must hold none of them.
DoesntContain.passes?;
Every comparison is exact. InArray compares strings with ==, and
Contains and DoesntContain match a parameter against a JSON string
element only, so ["1"] contains "1" and [1] does not. A value that
is not an array fails Contains and DoesntContain outright.
Contains and DoesntContain reject an empty parameter list as a keyless
construction error, the same way ArrayKeys does - a list with nothing in
it constrains nothing. An empty InArray haystack is different: a sibling
field can legitimately be empty at run time, so the value simply fails.
InArray's failure message names no values, because its list comes out of
the request and a validation message is rendered into a response body.
Comparison rules
Gt, Gte, Lt, and Lte compare a field against a number or against
another field. CompareWith names the operand and the measure together:
use ;
let mut ctx = new;
ctx.insert;
// Laravel's gt:0 - a literal operand, compared numerically.
Gt.passes?;
// Laravel's lte:max_price - a sibling field, compared numerically.
Lte.passes?;
// Laravel's gt:summary on two string fields - compared by character count.
Gt.passes?;
All four read sibling fields, so they are ContextualRules and every
validate! row carries => with ctx - including a row whose only operand
is a literal, where the context goes unread. Pass an empty FormContext
there.
Anything the rule cannot measure fails the field: a value that is not a
finite number under a numeric comparison, a sibling the form never sent, a
sibling that is not a number, or a non-finite literal such as f64::NAN.
None of those panics, and none of them passes.
Digits
Digits(n) is Laravel's digits:n: exactly n ASCII digits. It is a
string rule on purpose - a PIN or a card number keeps its leading zeros
only as text.
use ;
Digits.passes?; // Ok(())
Date rules
DateFormat is Laravel's date_format. It takes a list of formats in
chrono's strftime syntax and passes a value that matches one of them.
Like Laravel's, the check formats the parsed date back and compares it with
the input, so 2026-9-30 does not match %Y-%m-%d and
2026-02-31 matches nothing. A partial format such as %Y-%m or %H:%M
works too; the parts it leaves out come from 1970-01-01 at midnight, as in
PHP, so 02-29 does not match %m-%d.
After, AfterOrEqual, Before and BeforeOrEqual compare a date with a
DateBound: a fixed Date or DateTime, Now, Today, Tomorrow,
Yesterday, or another field. Build them with new, and add
.format(...) when the field is not ISO 8601 - Laravel's
date_format:d/m/Y|after:today pairing, which reads the value and a
sibling bound with that format.
use ;
use ;
Without a format, a value is read as RFC 3339, as YYYY-MM-DD HH:MM[:SS]
with a space or a T (what a datetime-local input sends), or as a bare
YYYY-MM-DD, which stands for its midnight. An offset is converted to UTC;
a value without one is taken as UTC. Both sides are compared to the second,
as Laravel compares timestamps. A value, or a sibling, that is not a date
fails the field; a sibling the form did not send passes it, as in Laravel.
The relative bounds read the framework clock, so a test moves them with
TestClock.
Accepted, prohibited, and missing
Accepted- Laravel'saccepted:yes,on,1ortrue. It also takes aboolfield (truepasses), anOption<bool>(an absent toggle fails) and a JSONtrueor1, so a terms toggle can stay typed on a Data Object.Prohibited- Laravel'sprohibited: the field is empty. Blank text passes, and so do a JSONnull, empty array and empty object.Missing- Laravel'smissing: the field was not sent. A typed request has every field it declares, so "not sent" is anOptionfield that isNone. PutMissingon a?:row: the row skips an absent field, and the rule fails anything that arrived, even an empty value.
validate!
Requiring a field on a condition
RequiredIf::when(condition) is Laravel's Rule::requiredIf. The
condition is a bool, or a closure that returns one each time the rule
checks a value. While the condition holds, the rule is Required, with the
validation-required message. When it does not hold, the rule passes every
value. The rule reads no other field, so it is a plain Rule and takes no
=> with ctx:
use ;
Put the rule on a ?=> row when the field is an Option, so it can fail a
field the client did not send. To require a field when another field has a
given value, use RequiredIf { other, value } instead.
Excluding a field
ExcludeIf { other, value } and ExcludeUnless { other, value } are
Laravel's exclude_if and exclude_unless. A validate! row runs its
rules in order, and while the exclusion holds, the rules after it do not
run - an error from a rule before it stands, as in Laravel. Put the
exclusion first to skip the whole row.
let ctx: FormContext = .into;
validate!
Why Suprnova diverges
Laravel's date_format takes PHP formats (Y-m-d); Suprnova's takes
chrono's (%Y-%m-%d), the syntax every other date API in a Rust
application already speaks. Laravel reads after:today in the
application's time zone; Suprnova has no application time zone, so
DateBound::Today starts at midnight UTC.
Laravel also drops an excluded field from validated(), the array a
controller hands to Model::create. A typed request has no such bag: the
struct still holds whatever the client sent. When a handler must ignore an
excluded field, it asks the same rule with
ExcludeIf { .. }.excludes(&ctx).
Laravel's missing tests whether a key is in the input array. A typed
request cannot see keys it does not declare - and a Data Object refuses an
undeclared key outright - so Missing works on a declared Option field
through the ?: row.
Laravel's distinct:strict leans on PHP's coercing ==. JSON values are
already typed, so Suprnova's strict only changes whether two numbers
with different internal representations (1 vs 1.0) count as equal -
it never makes a string and a number "the same," in either mode.
Laravel writes the other field into a rule string - in_array:allowed_roles.* -
and the validator globs it out of the request data at run time. Suprnova
has no rule-string parser: you hand InArray the list directly, and the
compiler checks the field exists.
Laravel 13.27 tightened in, in_array, and doesnt_contain to strict
comparison because PHP's == turned "1abc", true, and "0x1" into
matches. Suprnova never had that hole - In and NotIn compare &str
with == - and the new rules match JSON values variant by variant. Laravel's
contains stayed loose; Suprnova's does not. The cost is that these rules
cannot check a numeric array: Contains(&["1"]) does not match [1].
Laravel's gt family picks its measure at run time: the number itself for
numerics, count() for arrays, kilobytes for files, and character length
for everything else, with the numeric branch gated on whether the field
also carries numeric or integer. Suprnova writes the measure into the
rule instead, because a rule here cannot see the other rules on its field
and sniffing the value's shape is the coercion habit these rules exist to
avoid. Two of Laravel's four measures have no counterpart at all: a rule
only ever receives a string, so an array-valued sibling cannot be read,
and uploads never reach the rule surface - the multipart parser caps their
size before a handler sees them.
The validate! macro
validate! runs a chain of rules over the fields of a struct, accumulating
every failure into one ValidationErrors. It's the idiomatic home for the
synchronous cross-field hook, after_validation.
use ;
Each row is one of three shapes:
field => Rule1, Rule2;- required-shape. Rules run on&self.fielddirectly (forString,i64, or anything that derefs to the rule's expected borrow) - or, for aValueRule, on aserde_json::Valuefield directly. Which trait each rule uses is inferred automatically.field ?: Rule1, Rule2;- optional. The field isOption<T>; rules run only when it isSome, and are skipped entirely onNone. This is Laravel's "if present, validate" (sometimes) semantics.field ?=> Rule1, Rule2;- conditional-presence. Also for anOption<String>field, but rules run even whenNone(absence is treated as the empty string). This is the row for presence-conditional rules likeRequiredIfthat must be able to fail an absent field - the case?:cannot express because it skips onNone.
A contextual rule is followed by => with $ctx (an
&HashMap<String, String> of sibling values). The macro is synchronous -
for async rules use the hook below.
Warning: A common trap: writing
card_number ?: RequiredIf {...} => with ctx;. On a?:row,Noneskips all rules, soRequiredIfcan never fail an absent field. Use?=>for any rule that must fire on absence.
Cross-field hooks
FormRequest runs two cross-field hooks after the derived per-field rules.
On a real request, extract() runs derived validate(), then
after_validation, then after_validation_async, and bails at the first
failing stage. On a marked request, Precognition narrows
the rules before every stage. An unlisted field's failure does not block
a listed field's asynchronous check. Errors a hook adds are never filtered.
use ;
use Deserialize;
use Validate;
Note: Override hooks need a hand-written
impl FormRequest- the#[request]attribute and#[derive(FormRequest)]generate their own (empty) impl, so they're for the common no-override case only.
Async rules in requests
The validate! macro can't weave in .await, so database-backed rules run
in after_validation_async - the final validation stage, which extract()
calls automatically. This is where Unique and any
custom AsyncRule participate in automatic request validation; no
per-handler plumbing required.
use ;
use Deserialize;
use Validate;
On a real request, the async stage runs only after the synchronous stages
pass, so a syntactically invalid email never reaches the database Unique
query. On a marked request, an unlisted malformed field does not prevent
the listed fields' asynchronous rules from running.
A Data Object's derive writes its FormRequest impl, so it names its
hooks instead of overriding them: #[data(after_validation = "fn")] and
#[data(after_validation_async = "fn")]. See
Data Objects.
The Unique rule
Unique checks that a value does not already exist in a table. Build it
with Unique::new(table, column) and refine with the fluent API:
use Unique;
// email must be unique, ignoring the row currently being edited
new.ignore
// email unique *per tenant*, compared case-insensitively
new
.where_eq
.case_insensitive
| Builder method | Effect |
|---|---|
.ignore(id) |
exclude the row whose id equals id (edit-self case) |
.ignore_with_column(col, id) |
exclude on a non-id key column |
.where_eq(col, value) |
scope the check to rows where col = value; multiple calls AND together |
.case_insensitive() |
compare with LOWER(col) = LOWER(?) |
Table, column, the exclusion key, and every where_eq column are validated
against an identifier allowlist before they reach the SQL string; the value
under test and all scope values are bound parameters.
Unique and Exists read the primary connection. Inside a
DB::transaction they read through that transaction, so they see the rows
it has written and not yet committed, as every other query in the closure
does.
Unique is advisory - the database constraint is the guarantee
Unique runs a SELECT COUNT(*) before the write, so it carries an
unavoidable time-of-check/time-of-use race: two concurrent requests can
both pass the check and then both insert. Laravel's unique rule has the
identical property. The only real guarantee is a UNIQUE constraint
(or unique index) on the column in your migration.
Use the three together:
- The advisory rule - a fast, friendly "that email is taken" message before submit (and so Precognition can validate the field).
- The
UNIQUEconstraint - the authoritative guard against the race. FrameworkError::from_unique_violation- at the write site, map the constraint violation the loser of a race receives back to the same clean 422, instead of leaking a 500:
use FrameworkError;
// `users.email` has a UNIQUE constraint in the migration.
let user = new_user
.insert
.await
.map_err?;
from_unique_violation returns a 422 Validation error when the database
error is a unique-constraint violation, and passes any other error through
unchanged (MySQL, Postgres, and SQLite are all recognized).
The Exists rule
Exists is Laravel's exists: the value must name a row. It shares
Unique's identifier allowlist and binds every value.
use ;
let mut errs = new;
// A text column, through the AsyncRule path.
new
.check_async
.await;
// An integer column: check the typed value.
new
.check_value
.await;
// Laravel's 'tag_ids.*' => 'exists:tags,id', scoped to the team.
// Each failure lands under tag_ids.<index>.
new
.where_eq
.check_each
.await;
| Method | Effect |
|---|---|
.where_eq(col, value) |
only rows where col = value count; multiple calls AND together |
.check_async(&str, errs, field) |
check a text value (the AsyncRule path) |
.check_value(value, errs, field) |
check a typed value, bound as itself |
.check_each(&[T], errs, field) |
check every element, one query per distinct value, errors under field.<index> |
Check an integer id with check_value, not check_async: Postgres does
not compare a text parameter with an integer column, so the &str path
fails there. Bound an array's length with a synchronous rule
(#[validate(length(max = 100))]), which runs first, so a huge array never
reaches the database.
When a database rule cannot run - a dead connection, an identifier the
allowlist refuses, a type the database will not compare - the field fails
with validation-unchecked ("could not be checked"), and the cause goes to
the log under the suprnova::validation target. Unique does the same. A
validation message is rendered into the response, so it never carries the
database's own words. Like Unique, Exists reads before the
write; a foreign key constraint is the guarantee that the row is still
there when you insert.
Async authorization
FormRequest::authorize(&Request) -> bool runs before the body is
parsed, so it can reject unauthorized requests without reading the payload.
It is synchronous by design: at that point the request still holds the
streaming body, so the hook cannot .await. Authorization that needs to
hit the database or an async policy belongs in one of these places, not in
authorize:
- Middleware - runs before
extract(), isasync, and short-circuits by returningErr(response)(see Middleware). The right place for "is this user allowed to reach this route at all". - The Gate - call
Gate::allows_async/Gate::authorize_asyncin the handler once you have the authenticated user and the resource (see Authorization). after_validation_async- for an authorization check that depends on the parsed request body, run it in the async hook alongside your other async rules.
Inertia form submissions
A validation failure answers two audiences differently. A REST client
gets the 422 with { message, errors }. An Inertia visit gets a 303
back to the form page with the errors flashed into the session, because
the Inertia client shows an error modal for any response it does not
recognise as an Inertia response - a 422 would never populate
form.errors.
Nothing in the handler changes. On the destination page each field carries its first message as a string:
{#if errors?.email}
<p class="text-red-600">{errors.email}</p>
{/if}
See Inertia responses
for error bags, with_all_errors, and where the redirect points.
Design notes
- Partial validation. A real request builds the typed
FormRequest, so a field that may be absent on submit must beOption<T>. Precognition parses and validates only the listed fields before every stage. An unlisted malformed field does not block the answer. The struct is built only for the real request. - Rule messages. Built-in rules return keyed messages
(
validation-minplus its arguments and an English fallback), resolved through the catalog at the serialization boundary. Translate or reword any of them by defining the same id inlang/<locale>/validation.ftl- no rule wrapping. See Localization. - Derive messages. A
#[validate(...)]failure reads the catalog idvalidation-<code>, where<code>is the validator crate's code with_as-.#[validate(ip)],ip(v4)andip(v6)all reportip, so they readThe address field must be a valid IP address., as Laravel'sipdoes.rangewith only amaxreadsThe quantity field must not be greater than 100., Laravel's numericmaxmessage; a string'slength(max = ...)keepsmust be at most 100 characters. Min/Max/Betweenare string-length rules (counted in Unicode scalar values). For numeric bounds, validate with#[validate(range(...))]on the derive or a custom rule - the length rules are not value comparisons.
Summary
| Task | API |
|---|---|
| Per-field rules | #[validate(...)] on the FormRequest (see Requests) |
| Composed / cross-field rules | validate! { self => ... } |
| JSON-shaped rule (array/object) | field => ArrayKeys(&[...]); / field => Distinct { .. }; |
| Optional "if present" | field ?: Rule; |
| Conditionally-required optional | field ?=> Rule => with ctx; |
| Required while a condition holds | field ?=> RequiredIf::when(condition); |
| Async / DB-backed rule | after_validation_async + AsyncRule::check_async |
| Uniqueness | Unique::new(t, c) + UNIQUE constraint + from_unique_violation |
| Existence | Exists::new(t, c) + a foreign key constraint |
| Hooks on a Data Object | #[data(after_validation = "fn")], #[data(after_validation_async = "fn")] |
| Async authorization | middleware / Gate::*_async / after_validation_async |
Next
- Requests - the
#[request]/#[derive(FormRequest)]surface, the everyday derived-validation path - Data Objects -
#[derive(Data, Validate)]for one struct that's both an inbound request and an outbound DTO - Error Model - how
ValidationErrorsbecomes the 422 JSON body, alongside every other error path - Localization - translating rule messages, the
field-<name>convention, and keyedValidationMessages - Authorization -
Gate,Policy, and where authorization belongs relative to validation - Middleware - the right place for "is this request
even allowed through" checks that need
.await
