Skip to content

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.

regex and not_regex keep 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:

Terminal window
maat make:request StorePostRequest

Custom 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, explicitly

The 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,1 compares the string '1' against the id column. 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 comparing integer to text — that path has not been verified against a live server. Until it is, treat unique with 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.