Validation
Validation is the boundary between untrusted input and application logic. Put the rules beside the action for a small request:
final data = await request.validate({ 'title': 'required|string|max:120', 'body': 'required|string',});From this point forward, use data rather than request.all(): it contains
only keys named by the rule set.
Validating Requests
The fastest way to validate is on the request itself:
Route.post('/posts', (Request request) async { final data = await request.validate({ 'title': 'required|string|max:120', 'body': 'required|string', 'tags': 'array', });
return Response.json(data, status: 201);});If validation passes, you get back a map containing only the validated keys — never the unvalidated remainder of the request. If it fails, a ValidationException is thrown and the exception handler renders a 422:
{ "message": "The given data was invalid.", "errors": { "title": ["The title field is required."] }}You never write that response yourself.
The Validator
Use the validator directly when the data did not come from a request:
final validator = Validator.make(data, { 'email': 'required|email', 'age': 'required|integer|min:18',});
if (validator.fails()) { print(validator.errors); // {'email': ['The email field must be a valid email address.']}}
final clean = validator.validated();Call validateAsync() instead of validate() when a rule performs I/O. The
request helper always uses the asynchronous path, so one call works for both
ordinary and database-backed rules.
| Member | Description |
|---|---|
passes() |
True when there are no errors. |
fails() |
The inverse of passes(). |
errors |
Map<String, List<String>>, unmodifiable. |
validated() |
The validated keys only. |
validate() |
Returns validated(), or throws ValidationException. |
validateAsync() |
The asynchronous equivalent, required by async rules. |
Available Rules
Presence and control
| Rule | Description |
|---|---|
required |
Present and not empty. |
present |
Present, but may be empty. |
nullable |
Allow null — other rules are skipped when the value is null. |
sometimes |
Only validate when the key is present. |
bail |
Stop validating this field after its first failure. |
Types
string, integer, numeric, boolean, array
Size — the meaning follows the type: characters for strings, value for numbers, length for arrays.
| Rule | Description |
|---|---|
min:5 |
At least 5. |
max:120 |
At most 120. |
between:1,10 |
Inclusive range. |
size:8 |
Exactly 8. |
digits:4 |
Exactly 4 digits. |
digits_between:2,5 |
Between 2 and 5 digits. |
Format
email, url, uuid, date, alpha, alpha_num, alpha_dash, regex:<pattern>, not_regex:<pattern>
Comparison and membership
| Rule | Description |
|---|---|
in:a,b,c |
The value is one of the list. |
not_in:a,b,c |
The value is none of the list. |
same:other |
Matches another field. |
different:other |
Differs from another field. |
confirmed |
A matching <field>_confirmation exists. |
starts_with:a,b |
Begins with one of the values. |
ends_with:a,b |
Ends with one of the values. |
after:2024-01-01 |
A date after the given date, or after another field. |
before:2030-01-01 |
A date before the given date, or before another field. |
Conditional presence
| Rule | Required when |
|---|---|
required_if:type,paid |
type equals paid. |
required_unless:type,free |
type is anything but free. |
required_with:a,b |
Any of the listed fields are present. |
required_without:a,b |
Any of the listed fields are absent. |
regexandnot_regexkeep everything after the first:as a single pattern, so a pattern containing commas works:regex:^[a-z]{2,8}$.
Rules as a List
Where a rule contains a |, pass a list instead of a string:
Validator.make(data, { 'slug': ['required', r'regex:^[a-z|-]+$'],});Validating Arrays
Use * to validate every element of a list:
Validator.make(data, { 'items': 'required|array', 'items.*.name': 'required|string', 'items.*.qty': 'required|integer|min:1',});Errors come back keyed by the concrete index — items.0.name — so a client can point at the row that failed.
Custom Messages and Attribute Names
Validator.make(data, rules, messages: { 'title.required': 'A post needs a title.', 'required': 'This field is mandatory.',}, attributes: { 'title': 'post title',});A message keyed field.rule applies to that pairing; one keyed rule applies to that rule everywhere. attributes replaces the field name inside default messages, so first_name reads as “first name” — which is also the default humanisation, applied to camelCase and snake_case alike.
The same two options are available on the request:
await request.validate(rules, messages: {...}, attributes: {...});Form Requests
When rules grow, move them out of the route. A form request keeps validation and authorization together:
class StorePostRequest extends FormRequest { @override Map<String, Object> rules() => { 'title': 'required|string|max:120', 'body': 'required|string', };
@override Map<String, String> messages() => {'title.required': 'A post needs a title.'};
@override Map<String, String> attributes() => {'body': 'post body'};
@override bool authorize(Request request) => request.bearerToken() != null;}Then:
final data = await request.validateWith(StorePostRequest());Authorization runs before validation, so an unauthorized caller learns nothing about your rules — a failed authorize throws ForbiddenHttpException('This action is unauthorized.') and renders as a 403.
Generate one with:
maat make:request StorePostRequestCustom Rules
Register a rule once, use it everywhere:
Validator.extend('uppercase', (ctx) => ctx.value == ctx.value.toString().toUpperCase(), message: 'The :attribute must be uppercase.');
Validator.make(data, {'code': 'required|uppercase'});The context gives you everything the check needs:
| Member | Description |
|---|---|
attribute |
The field name being validated. |
value |
The value under test. |
params |
Anything after the : in the rule, split on commas. |
data |
Every value being validated. |
present |
Whether the key was present at all. |
Pass implicit: true when your rule must run even for absent or null values — that is what makes required-style rules work. A custom rule overrides a built-in of the same name.
Database Rules
Registering DatabaseServiceProvider adds two rules that query the database:
await request.validate({ 'email': 'required|email|unique:users,email', 'team_id': 'required|exists:teams,id',});unique:<table>,<column>[,<ignoreValue>[,<ignoreColumn>]]exists:<table>,<column>The column is optional and defaults to the attribute’s own name, so 'email' => 'unique:users' checks users.email. Both rules skip an absent or null value, and both run against DB’s default connection at validation time — the connection has to exist when a request is validated, not when the rules are registered.
unique takes an id to ignore, which is what makes an update request work — the row being edited must not collide with itself:
'email': 'required|email|unique:users,email,${user.id}','email': 'required|email|unique:users,email,NULL,id', // ignore nothing, explicitlyThe literal NULL is Laravel’s spelling of “no id to ignore”, and is treated the same as an empty third parameter. A fourth parameter names the column the ignore value is compared against; it defaults to id.
Rule parameters are always strings.
unique:users,email,1compares the string'1'against theidcolumn. SQLite’s type affinity absorbs that, and the test suite passes on SQLite because of it. On PostgreSQL this is expected to fail with a type error comparingintegertotext— that path has not been verified against a live server. Until it is, treatuniquewith an ignore id as unproven on PostgreSQL.
Asynchronous Rules
unique and exists are built on Validator.extendAsync, which registers a rule whose check returns a Future<bool>:
Validator.extendAsync('active_team', (ctx) async { return DB.table('teams').where('id', ctx.value).where('active', true).exists();}, message: 'The selected :attribute is not an active team.');The context is the same RuleContext a synchronous rule receives.
Because the check has to be awaited, an attribute carrying an async rule can only be validated through the asynchronous entry points, passesAsync() and validateAsync(). The synchronous ones — passes(), fails(), validate() and the errors getter — throw a StateError naming the offending rule:
The "unique" rule runs asynchronously and cannot be validated synchronously.Use await validator.passesAsync() or await validator.validateAsync()(request.validate() already does).request.validate() and form requests already take the async path, so this only bites hand-built validators. passesAsync() works for ordinary rule sets too — when in doubt, use it.
A name may be registered in one registry or the other, never both. Validator.extend refuses a name already registered with extendAsync, and extendAsync refuses a name that is already a synchronous, implicit or control rule. The two are gated differently, so a shared name would validate inconsistently depending on which path ran; the registration throws ArgumentError instead.
Validator.resetExtensions() forgets every rule added with extend or extendAsync. The registries are static, so a test that registers a rule leaks it into every test that follows — call this in a tearDown.