Skip to content
Kona

Validation

Validation checks incoming data before your handler trusts it. You describe each field with a list of rules — short strings such as required, email, or min:18. Kona checks every field, collects human-readable error messages, and either hands you the validated data or throws a ValidationException. The default exception handler turns that exception into either a 422 JSON envelope (when the client expects JSON) or a redirect back with flashed errors and old input (typical HTML form posts), so a typical handler needs no error-handling code at all.

Request.validate(rules) takes a Map<String, List<String>>: each field name maps to a list of rule strings.

router.post("/users") { request ->
val input = request.validate(
mapOf(
"email" to listOf("required", "email"),
"age" to listOf("nullable", "integer", "min:18"),
"password" to listOf("required", "string", "min:8", "confirmed"),
"role" to listOf("required", "in:admin,member"),
),
)
Response.json(users.create(input), 201)
}

Any list element may also contain several rules joined with |. Kona splits each element on | before running the rules, so these three declarations are identical:

"email" to listOf("required", "email")
"email" to listOf("required|email")
"email" to listOf("required|string", "email")

The one exception is regex:. An element that starts with regex: is never split, because | is meaningful inside a regular expression. Always give a regex rule its own list element: listOf("required", "regex:/^(draft|published)$/").

On success, validate returns a Map<String, Any?> containing only the fields that have rules and are present in the input. Extra fields the client sent are dropped, and optional fields that were absent are not included. Use this map instead of reading the request again, so unvalidated input never leaks into your logic.

On failure, it throws ValidationException and your handler stops at that line.

Request.validate validates request.data() — see Request & response:

  • For JSON requests (Content-Type containing application/json), values keep their JSON types: numbers, booleans, null, lists, and maps. Query-string values are included too, and body values win on conflict.
  • For form and other requests, every value is a String taken from the query string and the URL-encoded body.

This affects some rules. integer, numeric, and boolean accept suitable strings, so they work for form posts. array and list can only pass for JSON, because form values are always strings.

When the data does not come straight from a request — a decoded message, a config block, a CSV row — build a Validator directly. Validator.make accepts either a Map<String, Any?> or a Request, with the same rule format.

val validator = Validator.make(
mapOf("name" to "Ada", "tags" to listOf("math", "engines")),
mapOf(
"name" to listOf("required", "string", "max:50"),
"tags" to listOf("array", "max:5"),
),
)
if (validator.fails()) {
println(validator.errors()) // Map<String, List<String>>
} else {
val clean = validator.validate() // same result as Request.validate
}

passes() is the opposite of fails(). Each call re-runs the rules from scratch, so the result always reflects the current data. errors() returns the messages from the most recent run. validate() runs the rules and either returns the validated map or throws ValidationException.

For each field, Kona works through the rules in the order you wrote them:

  1. Missing or blank fields skip most rules. If the field is absent, or is a string containing only whitespace, every rule except required is skipped. So min:3 alone does not make a field mandatory — add required when it must be present.
  2. required stops at the first failure. If required fails, no other rules run for that field, so the client sees one clear message.
  3. nullable lets null through. A field that is present with the value null normally still runs its rules (and fails string, integer, and so on). With nullable, a null value skips them.
  4. All other failures accumulate. Without bail, every remaining rule runs and each failure adds a message. With bail anywhere in the field’s rules, the first failure ends that field.
  5. Unknown or malformed rules fail closed. A rule name Kona does not know adds Unknown validation rule [name]. A known rule with a bad parameter — min:abc, between:1, a regex with an invalid pattern or flag — adds Invalid validation rule [rule]. These errors appear even when the field is missing, so a typo cannot silently disable a check.

Field names in messages have underscores replaced by spaces: first_name appears as “first name”.

Rule Passes when Error message
required The field is present and not null, not a blank string, and not an empty list or map The email field is required.
nullable Always; lets null skip the other rules —
bail Always; stops the field at its first failure —
string The value is a string The name field must be a string.
integer A whole number, or a string of digits with an optional sign and no leading zeros ("42", "-7"), within the 64-bit range The age field must be an integer.
numeric A number, or a numeric string ("3.14", "1e3", " 12 ") The price field must be a number.
boolean One of true, false, 1, 0, "1", "0", "true", "false" The active field must be true or false.
email A string shaped local@domain.tld with no spaces The email field must be a valid email address.
url A string that parses as a URI with both a scheme and a host (https://example.com) The website field must be a valid URL.
array A list or a map (JSON array or object) The tags field must be an array.
list A list (JSON array) The tags field must be a list.
min:n Size is at least n …must be at least 18. / …must be at least 8 characters. / …must have at least 1 items.
max:n Size is at most n …must not be greater than 50 characters.
size:n Size is exactly n …must be 4 characters.
between:a,b Size is from a to b, inclusive …must be between 1 and 10.
in:a,b,c The value (as text) is one of the listed options; for a list, every element must be The selected role is invalid.
not_in:a,b The value is not one of the listed options The selected role is invalid.
confirmed Equals the field named <field>_confirmation The password field confirmation does not match.
confirmed:other Equals the field named other same as above
same:other Equals the field named other The email field must match email again. (for same:email_again)
different:other Differs from the field named other The email field must be different from email again.
regex:/pattern/flags The string contains a match for the pattern The code field format is invalid.
not_regex:/pattern/flags The string does not contain a match The code field format is invalid.
file Value is a valid UploadedFile The photo field must be a file.
image Valid upload with an image MIME type or image extension The photo field must be an image.
mimes:png,jpg Valid upload whose extension is listed The photo field must be a file of type: png, jpg.
mimetypes:image/png Valid upload whose Content-Type is listed The photo field must be a file of type: image/png.
filled If the field is present, it must not be empty The name field must have a value.
present The field key must exist (even if empty) The name field must be present.
sometimes Skip the whole field when it is absent —
accepted yes, on, 1, or true (case-insensitive) The terms field must be accepted.
declined no, off, 0, or false The marketing field must be declined.
accepted_if:field,value Must be accepted when another field equals value …must be accepted when role is admin.
declined_if:field,value Must be declined when another field equals value …must be declined when role is admin.
prohibited Field must be missing or empty …is prohibited.
prohibited_if:field,value Prohibited when another field equals value …is prohibited when role is admin.
prohibited_unless:field,value Prohibited unless another field equals value …is prohibited unless role is admin.
prohibits:a,b When this field is present, listed fields must be empty …prohibits a / b from being present.
exclude Drop the field from validated output (always) —
exclude_if / exclude_unless / exclude_with / exclude_without Conditionally omit the field from validated output —
current_password Matches the authenticated user’s password via Validator.setCurrentPasswordChecker …is incorrect.
alpha / alpha_num / alpha_dash Letters only / letters+digits / letters+digits+-+_ …must only contain letters.
ascii ASCII characters only …must only contain ASCII characters.
uuid A UUID string …must be a valid UUID.
ulid A 26-character ULID string …must be a valid ULID.
mac_address MAC (aa:bb:…, aa-bb-…, or aabb.ccdd.eeff) …must be a valid MAC address.
json A JSON object or array string …must be a valid JSON string.
ip / ipv4 / ipv6 IP address (IPv4 uses a dotted-quad check; IPv6 via the JDK) …must be a valid IP address.
timezone A JDK zone id such as UTC or America/New_York …must be a valid timezone.
date Parses as an ISO local date / date-time …must be a valid date.
date_format:Y-m-d Matches the format (PHP-style Y m d H i s or a Java DateTimeFormatter pattern) …must match the format Y-m-d.
after:… / before:… / after_or_equal:… / before_or_equal:… Date comparison against another field or a date string …must be after start.
starts_with:a,b / ends_with:a,b String prefix / suffix …must start with one of: a, b.
digits:n / digits_between:a,b Digit string of exact / ranged length …must be 4 digits.
lowercase / uppercase Entire string is lower / upper case …must be lowercase.
distinct Array / list with no duplicate values …has duplicate values.
required_if:field,value Required when another field equals value …is required when role is admin.
required_unless:field,value Required unless another field equals value …is required unless role is admin.
required_with:a,b Required when any listed field is non-empty …is required when name is present.
required_without:a,b Required when any listed field is empty / missing …is required when name is not present.
required_with_all:a,b Required when all listed fields are non-empty …is required when a / b are present.
required_without_all:a,b Required when all listed fields are empty …is required when none of a / b are present.
exists:table,column Row exists (needs an ExistenceChecker) …is invalid.
unique:table,column[,exceptId] No conflicting row (needs an ExistenceChecker) …has already been taken.

For UploadedFile values, min / max / size / between measure kilobytes (Laravel-compatible), not characters.

These rules need a database probe. Register one with Validator.setExistenceChecker(checker) — typically from an Cadence-backed provider. Without a checker, the rule fails closed with a message that an ExistenceChecker is required, so a missing wire-up cannot silently pass.

min, max, size, and between decide what “size” means from the value itself:

  • Numbers (JSON numbers) are compared by value: min:18 means 18 or more.
  • Lists and maps are compared by element count: max:5 means at most five items.
  • Strings are compared by character count — unless the field also has integer or numeric and the string looks like a number, in which case it is compared by value. This is why "age" to listOf("integer", "min:18") works for a form field age=21.

Both compare values with ordinary equality, so types matter: the JSON number 1 does not equal the string "1". For a password field, send password and password_confirmation with the same value.

Write the pattern between two identical delimiter characters (any non-alphanumeric character, usually /), followed by optional flags: i (ignore case), m (multiline), s (dot matches newlines), x (allow whitespace and comments), u (accepted, no effect). The rule passes when the pattern is found anywhere in the string, so anchor it with ^…$ to match the whole value. Non-string values fail.

"slug" to listOf("required", "regex:/^[a-z0-9-]+$/")
"code" to listOf("required", "regex:/^[A-Z]{3}$/i")

ValidationException exposes errors (Java: getErrors()), a map from field name to a list of messages. Fields appear in the iteration order of your rules map: declaration order for Kotlin mapOf or a Java LinkedHashMap, unspecified for Java Map.of. Its message summarizes them: the first message, plus (and N more error) or (and N more errors) when there are others.

When the exception escapes your handler, the default exception handler chooses a response from request.expectsJson():

Status 422 with:

{
"message": "The email field is required. (and 1 more error)",
"errors": {
"email": ["The email field is required."],
"age": ["The age field must be at least 18."]
}
}

expectsJson() is true when Accept prefers application/json without text/html, or when Content-Type is JSON.

A 302 redirect to the previous URL (session _previous.url, else Referer, else /) with:

  • flashed errors (the validation map)
  • flashed _old_input from request.all()

StartSession applies those pending flash bags when it saves. On the next GET, read them with request.old("email") / session.old("email") and session.get("errors"). See Session and Request & response.

This response passes through global middleware like any other. If you need a different shape, catch ValidationException in the handler, or install a custom ExceptionHandler — see Exceptions.

Controllers can call validate(request, rules) or validate(request, formRequest). A FormRequest owns rules() and optional authorize(request) — see Controllers → Form requests. Unauthorized form requests throw HttpException(403) before rules run.

You can also call request.validate(rules) or Validator.make(request, rules).validate() directly from a handler.

Why didn’t min:3 make the field required?
Absent/blank fields skip most rules. Add required.

Why does array fail on a form post?
Form values are strings. Send JSON for list/map rules.

Why did an unknown rule fail a missing field?
Unknown/malformed rules fail closed even when the field is absent, so typos cannot disable checks.

Do I need a database for exists / unique?
Yes — register Validator.setExistenceChecker(...). Without it, those rules fail closed.

  • Nested dot.notation rule expansion for deep JSON objects as a first-class DSL beyond flat keys
  • Custom rule classes registered globally (use imperative checks in the handler for exotic cases)
  • Automatic FormRequest injection from method signatures