Manual contentsThe BasicsBrowse 126 chapters
Manual 27 min read

Validation

Suprnova validates request input on two complementary tracks:

  1. Derive validation - #[validate(...)] attributes on a FormRequest struct, run automatically by extract(). This is the everyday path and is covered in Requests. It handles per-field rules (email, length, range, …) declaratively.
  2. Rule objects + the validate! macro - plain values implementing Rule / 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 suprnova::{Rule, rules::Email};

Email.passes("user@example.com")?; // Ok(())

Note: Numeric accepts 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 suprnova::{Rule, rules::Url};

// Laravel's `url:http,https`
Url::protocols(&["https"]).passes("https://example.com")?;   // Ok
Url::protocols(&["https"]).passes("http://example.com");     // Err

// The same thing, under a name
use suprnova::rules::HttpUrl;
HttpUrl.passes("https://example.com")?;

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 suprnova::{Password, Rule};

let rule = Password::min(8).letters().mixed_case().numbers().symbols();
Rule::passes(&rule, "Str0ng! Pass")?; // Ok(())
Rule::passes(&rule, "weak");          // 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 suprnova::{AsyncRule, FormRequest, Password, ValidationErrors, async_trait};
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
pub struct Register {
    pub password: String,
}

#[async_trait]
impl FormRequest for Register {
    async fn after_validation_async(&self) -> Result<(), ValidationErrors> {
        let mut errs = ValidationErrors::new();
        Password::defaults()
            .uncompromised()
            .check_async(&self.password, &mut errs, "password")
            .await;
        errs.into_result()
    }
}

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 Password collects every failed strength check into one array. Suprnova's Rule contract returns a single ValidationMessage, so Rule::passes reports 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's Rule::passes is 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's Rule::passes returns a loud, developer-facing error naming after_validation_async as the fix.
  • Password::defaults_with takes a plain fn pointer, not a closure, so the configured default stays Copy and needs no heap allocation - a deliberate narrowing from Laravel's Closure.

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 suprnova::{Rule, ValidationMessage};

pub struct StartsWith(pub &'static str);

impl Rule for StartsWith {
    fn passes(&self, value: &str) -> Result<(), ValidationMessage> {
        if value.starts_with(self.0) {
            Ok(())
        } else {
            Err(format!("must start with {}", self.0).into())
        }
    }
}

// Now usable everywhere:
StartsWith("acct_").passes("acct_1234")?;
// 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 suprnova::{ValueRule, rules::{ArrayKeys, Distinct}};

// 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(&["name", "email"]).passes(&serde_json::json!({"name": "Ada"}))?;

// Laravel's distinct / distinct:ignore_case / distinct:strict.
Distinct { ignore_case: false, strict: false }
    .passes(&serde_json::json!(["a", "b", "c"]))?;

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 suprnova::{Rule, ValueRule, rules::{Contains, DoesntContain, InArray}};

// 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(&form.allowed_roles).passes(&form.role)?;

// Laravel's contains:rust,web - the array must hold every listed value.
Contains(&["rust", "web"]).passes(&form.tags)?;

// Laravel's doesnt_contain:banned - the array must hold none of them.
DoesntContain(&["banned"]).passes(&form.tags)?;

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 suprnova::{ContextualRule, FormContext, rules::{CompareWith, Gt, Lte}};

let mut ctx = FormContext::new();
ctx.insert("max_price".to_string(), form.max_price.clone());

// Laravel's gt:0 - a literal operand, compared numerically.
Gt(CompareWith::Number(0.0)).passes(&form.price, &ctx)?;

// Laravel's lte:max_price - a sibling field, compared numerically.
Lte(CompareWith::NumericField("max_price")).passes(&form.price, &ctx)?;

// Laravel's gt:summary on two string fields - compared by character count.
Gt(CompareWith::LengthField("summary")).passes(&form.body, &ctx)?;

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 suprnova::{Rule, rules::Digits};

Digits(4).passes("0042")?; // 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 suprnova::{validate, FormContext, ValidationErrors};
use suprnova::rules::{After, AfterOrEqual, DateBound, DateFormat};

fn booking_rules(form: &Booking) -> Result<(), ValidationErrors> {
    let ctx: FormContext = [("starts_on".to_string(), form.starts_on.clone())].into();
    validate! { form =>
        // Laravel's date_format:Y-m-d|after_or_equal:today
        starts_on => DateFormat(&["%Y-%m-%d"]), AfterOrEqual::new(DateBound::Today) => with ctx;
        // Laravel's date_format:d/m/Y|after:starts_on
        ends_on => DateFormat(&["%d/%m/%Y"]),
            After::new(DateBound::Field("starts_on")).format("%d/%m/%Y") => with ctx;
    }
}

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's accepted: yes, on, 1 or true. It also takes a bool field (true passes), an Option<bool> (an absent toggle fails) and a JSON true or 1, so a terms toggle can stay typed on a Data Object.
  • Prohibited - Laravel's prohibited: the field is empty. Blank text passes, and so do a JSON null, empty array and empty object.
  • Missing - Laravel's missing: the field was not sent. A typed request has every field it declares, so "not sent" is an Option field that is None. Put Missing on a ?: row: the row skips an absent field, and the rule fails anything that arrived, even an empty value.
validate! { self =>
    terms => Accepted;          // terms: bool
    honeypot => Prohibited;     // honeypot: String
    referral_code ?: Missing;   // referral_code: Option<String>
}

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 suprnova::{validate, ValidationErrors, rules::RequiredIf};

fn after_validation(&self) -> Result<(), ValidationErrors> {
    let business = self.account_type == "business";
    validate! { self =>
        company_name ?=> RequiredIf::when(business);
        vat_number ?=> RequiredIf::when(|| business && self.country != "US");
    }
}

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 = [("payment".to_string(), self.payment.clone())].into();
validate! { self =>
    card_number ?=> ExcludeUnless { other: "payment", value: "card" } => with ctx,
        Required, Digits(16);
}

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 suprnova::{validate, ValidationErrors, rules::{Required, Email, Min, Max, RequiredIf}};

fn after_validation(&self) -> Result<(), ValidationErrors> {
    // Contextual rules read sibling values from a `FormContext` you build
    // - a map of field name to its string value.
    let mut ctx = std::collections::HashMap::new();
    ctx.insert("billing_type".to_string(), self.billing_type.clone());
    validate! { self =>
        email       => Required, Email;          // required-shape row
        bio         ?: Min(10), Max(500);        // optional: validate only if Some
        card_number ?=> RequiredIf {             // conditional-presence (see below)
            other: "billing_type",
            value: "card",
        } => with ctx;
    }
}

Each row is one of three shapes:

  • field => Rule1, Rule2; - required-shape. Rules run on &self.field directly (for String, i64, or anything that derefs to the rule's expected borrow) - or, for a ValueRule, on a serde_json::Value field directly. Which trait each rule uses is inferred automatically.
  • field ?: Rule1, Rule2; - optional. The field is Option<T>; rules run only when it is Some, and are skipped entirely on None. This is Laravel's "if present, validate" (sometimes) semantics.
  • field ?=> Rule1, Rule2; - conditional-presence. Also for an Option<String> field, but rules run even when None (absence is treated as the empty string). This is the row for presence-conditional rules like RequiredIf that must be able to fail an absent field - the case ?: cannot express because it skips on None.

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, None skips all rules, so RequiredIf can 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 suprnova::{FormRequest, ValidationErrors};
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
pub struct UpdatePassword {
    #[validate(length(min = 8))]
    pub new_password: String,
    pub confirmation: String,
}

impl FormRequest for UpdatePassword {
    fn after_validation(&self) -> Result<(), ValidationErrors> {
        let mut errs = ValidationErrors::new();
        if self.new_password != self.confirmation {
            errs.add("confirmation", "passwords do not match");
        }
        errs.into_result()
    }
}

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 suprnova::{FormRequest, ValidationErrors, Unique, async_trait};
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
pub struct CreateUser {
    #[validate(email)]
    pub email: String,
}

#[async_trait]
impl FormRequest for CreateUser {
    async fn after_validation_async(&self) -> Result<(), ValidationErrors> {
        let mut errs = ValidationErrors::new();
        Unique::new("users", "email")
            .check_async(&self.email, &mut errs, "email")
            .await;
        errs.into_result()
    }
}

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 suprnova::Unique;

// email must be unique, ignoring the row currently being edited
Unique::new("users", "email").ignore(current_user_id)

// email unique *per tenant*, compared case-insensitively
Unique::new("users", "email")
    .where_eq("tenant_id", tenant_id)
    .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:

  1. The advisory rule - a fast, friendly "that email is taken" message before submit (and so Precognition can validate the field).
  2. The UNIQUE constraint - the authoritative guard against the race.
  3. 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 suprnova::FrameworkError;

// `users.email` has a UNIQUE constraint in the migration.
let user = new_user
    .insert(db)
    .await
    .map_err(|e| FrameworkError::from_unique_violation(
        "email",
        "That email address is already registered.",
        e,
    ))?;

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 suprnova::{AsyncRule, Exists, ValidationErrors};

let mut errs = ValidationErrors::new();

// A text column, through the AsyncRule path.
Exists::new("countries", "code")
    .check_async(&self.country, &mut errs, "country")
    .await;

// An integer column: check the typed value.
Exists::new("teams", "id")
    .check_value(self.team_id, &mut errs, "team_id")
    .await;

// Laravel's 'tag_ids.*' => 'exists:tags,id', scoped to the team.
// Each failure lands under tag_ids.<index>.
Exists::new("tags", "id")
    .where_eq("team_id", self.team_id)
    .check_each(&self.tag_ids, &mut errs, "tag_ids")
    .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(), is async, and short-circuits by returning Err(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_async in 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 be Option<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-min plus 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 in lang/<locale>/validation.ftl - no rule wrapping. See Localization.
  • Derive messages. A #[validate(...)] failure reads the catalog id validation-<code>, where <code> is the validator crate's code with _ as -. #[validate(ip)], ip(v4) and ip(v6) all report ip, so they read The address field must be a valid IP address., as Laravel's ip does. range with only a max reads The quantity field must not be greater than 100., Laravel's numeric max message; a string's length(max = ...) keeps must be at most 100 characters.
  • Min / Max / Between are 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 ValidationErrors becomes the 422 JSON body, alongside every other error path
  • Localization - translating rule messages, the field-<name> convention, and keyed ValidationMessages
  • 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