Validation
Magic provides a client-side validation system that integrates with Flutter forms, resolves error messages through the Lang facade, and extends to async rules for uniqueness or remote existence checks.
On this page
Introduction#
Magic provides a validation system that integrates with Flutter forms. Define your validation rules in the View using the rules() helper, and your Controller receives only clean, pre-validated data.
Key Features#
| Feature | Description |
|---|---|
| MagicForm Widget | Automatic form state management and error handling |
| rules() Helper | Concise rule definition with auto-injected controller |
| Server-Side Errors | Automatic display of API 422 validation errors |
| Error Styling | Wind UI's error: state prefix for styling |
| Localization | Error messages resolved via trans() helper |
Quick Start#
Use MagicFormData for Laravel-style form handling:
class RegisterView extends MagicStatefulView<AuthController> {
const RegisterView({super.key});
@override
State<RegisterView> createState() => _RegisterViewState();
}
class _RegisterViewState extends MagicStatefulViewState<AuthController, RegisterView> {
// Define form fields - types inferred from initial values
late final form = MagicFormData({
'name': '',
'email': '',
'password': '',
'password_confirmation': '',
'accept_terms': false,
}, controller: controller);
@override
void onClose() => form.dispose();
@override
Widget build(BuildContext context) {
return MagicForm(
formData: form,
child: WDiv(
className: 'flex flex-col gap-4',
children: [
WFormInput(
controller: form['name'],
label: trans('attributes.name'),
validator: rules([Required(), Min(2)], field: 'name'),
),
WFormInput(
controller: form['email'],
label: trans('attributes.email'),
type: InputType.email,
validator: rules([Required(), Email()], field: 'email'),
),
WFormInput(
controller: form['password'],
label: trans('attributes.password'),
type: InputType.password,
validator: rules([Required(), Min(8)], field: 'password'),
),
WFormInput(
controller: form['password_confirmation'],
label: trans('attributes.password_confirmation'),
type: InputType.password,
validator: rules([
Required(),
Same('password', valueGetter: () => form['password'].text),
], field: 'password_confirmation'),
),
WFormCheckbox(
value: form.value<bool>('accept_terms'),
onChanged: (v) => form.setValue('accept_terms', v),
label: WText(trans('auth.accept_terms')),
validator: rules([Accepted()], field: 'accept_terms'),
),
WButton(
isLoading: controller.isLoading,
onTap: () {
final data = form.validated();
if (data.isNotEmpty) {
controller.register(data);
}
},
className: 'w-full bg-primary p-4 rounded-lg',
child: WText(trans('auth.register'), className: 'text-white text-center'),
),
],
),
);
}
}
Defining Validation Rules#
The rules() Helper#
The rules() helper is available in all MagicStatefulViewState subclasses:
// Full syntax
validator: FormValidator.rules([Required()], field: 'email', controller: controller)
// Shorthand (controller auto-injected)
validator: rules([Required(), Email()], field: 'email')
Multiple Rules#
Combine multiple rules in an array:
validator: rules([
Required(),
Email(),
Max(255),
], field: 'email')
Rules are evaluated in order. If any rule fails, validation stops and the error is displayed.
Available Rules#
| Rule | Description | Example |
|---|---|---|
Required() |
Field must not be empty | [Required()] |
Email() |
Valid email format | [Email()] |
Url() |
Valid http(s) address with a host | [Url()], [Url(schemes: ['https'])] |
Min(n) |
Minimum length/value | [Min(8)] |
Max(n) |
Maximum length/value | [Max(255)] |
Confirmed() |
Must match {field}_confirmation |
[Confirmed()] |
Same('field') |
Must match another field | [Same('password', valueGetter: ...)] |
Accepted() |
Must be true/1/"yes"/"on" | [Accepted()] |
In<T>(values) |
Value must appear in whitelist | [In<String>(['public', 'private'])] |
InList<T extends Enum>(values) |
Value must match an enum (name or instance) | [InList(Severity.values)] |
Uuid() |
Valid RFC 4122 UUID, any version | [Uuid()] |
Boolean() |
true/false/0/1/'0'/'1' |
[Boolean()] |
Numeric() |
A num or a numeric string |
[Numeric()] |
Integer() |
An integer, an integer-looking string, or a whole-number double | [Integer()] |
Gt(n) / Gte(n) / Lt(n) / Lte(n) |
Size strictly greater/at least/strictly less/at most n (numeric, string length, or list length) |
[Gt(0)] |
Between(min, max) |
Size within an inclusive range | [Between(8, 64)] |
Regex(pattern) |
Matches a plain Dart RegExp pattern (no delimiters) |
[Regex(r'^[A-Z]{3}-\d{4}$')] |
Date() |
A DateTime or a string DateTime.tryParse accepts |
[Date()] |
Nullable() |
Marker rule; always passes | [Nullable(), Numeric()] |
RequiredIf(other, value) |
Required only when other equals value |
[RequiredIf('type', 'company')] |
ArrayRule() |
Value must be a List |
[ArrayRule()] |
Sizing a Numeric String#
Min, Max, Between, Gt, Gte, Lt and Lte measure a number by its value, a list by its item count, and a string by its length, except that a string is read as a number when the same field also carries Numeric() or Integer(). That is Laravel's getSize, and it matters because form input is always a string:
'quantity': [Required(), Numeric(), Between(1, 100)], // "500" fails: 500 > 100
'password': [Required(), Between(8, 64)], // "12345678" passes: 8 characters
The rules cannot see each other, so Validator and FormValidator pass the answer in through SizeRule.passesSized. A custom rule that sizes a value extends SizeRule to be read the same way; calling passes() on one directly, with no rule list around it, always sizes a string by its length.
The Url Rule#
Url() accepts an address a request could actually be built from: a scheme from its allowlist and a non-empty host.
'website': [Required(), Url()], // http or https
'webhook': [Required(), Url(schemes: ['https'])], // https only
'socket': [Url(schemes: ['ws', 'wss'])],
It is not an RFC 3986 parser and does not try to be. It reaches no network, resolves no host and takes no view on whether the path exists, because a rule answering a form field synchronously cannot know any of that.
The scheme allowlist is the part that matters. Uri.parse accepts javascript:alert(1) and file:///etc/passwd without complaint; both have a scheme and parse cleanly. Nothing but the allowlist keeps them out of a field whose value becomes a request or a link. Narrow it to ['https'] for anything carrying a credential.
Whitespace is rejected before parsing, because Uri does not treat it as an error: Uri.tryParse('http://exa mple.com') succeeds and percent-encodes the space into the host as exa%20mple.com, which no DNS lookup can resolve. Laravel's url rejects it too.
Custom Messages#
A rule's message comes from its own catalogue key, which is generic by design: validation.required is The :attribute field is required. for every field in the app. When one screen wants its own wording, pass messages, keyed by rule name:
WFormInput(
validator: FormValidator.rules(
[Required(), Url()],
field: 'address',
messages: {
'required': 'provider.error.address_required',
'url': 'provider.error.address_scheme',
},
),
)
Pass attribute to name the field in its messages, the field-level twin of FormRequest.attributes(). It is used as written, ahead of the global attributes.<key>, so the field reads the same while typing as after a FormRequest submit:
FormValidator.rules([Required()], field: 'url', attribute: 'Server address')
// "The Server address field is required."
This is Laravel's third Validator::make argument. Without it, a screen wanting specific copy had to abandon the rules and hand-roll a closure, which is exactly what the rules exist to prevent.
The value is a key, not a finished sentence. An override taking a sentence would make every consumer using it monolingual. A key with no sentence renders as itself, which is trans's own contract, and the rule's parameters still reach it, so :attribute and :schemes work in an override too.
The map key is Rule.name, derived from the rule's message key (validation.required gives required) rather than from runtimeType, which is not a dependable identifier in a release build: dart2js minifies class names, and Flutter's own objectRuntimeType declines to call toString on a runtime type outside asserts.
Whitelist Rules (In / InList)#
In<T> validates against a primitive whitelist. The generic T is the element type, so type mismatches fail explicitly rather than silently coercing:
'visibility': [In<String>(['public', 'private'])],
'priority': [In<int>([1, 2, 3, 5, 8])],
InList<T extends Enum> is the enum-aware variant. By default it compares against Enum.name and also accepts the enum instance itself. Use caseInsensitive: true for loose matching, or wire: to map enums onto a custom wire representation (snake_case, kebab-case, or bespoke codes):
enum Severity { low, medium, high, critical }
InList(Severity.values); // matches 'low', 'medium', ...
InList(Severity.values, caseInsensitive: true); // also 'HIGH', 'Critical'
enum ThresholdDirection { highBad, lowBad }
InList<ThresholdDirection>(
ThresholdDirection.values,
wire: (d) => switch (d) {
ThresholdDirection.highBad => 'high_bad',
ThresholdDirection.lowBad => 'low_bad',
},
);
Both rules pass on null so you pair them with Required() when presence matters. The failure message resolves validation.in with a comma-joined :values placeholder ("The severity must be one of low, medium, high, critical.").
Same Rule with ValueGetter#
For password confirmation, use the valueGetter parameter:
WFormInput(
controller: form['password_confirmation'],
validator: rules([
Required(),
Same('password', valueGetter: () => form['password'].text),
], field: 'password_confirmation'),
)
Form Requests#
A FormRequest collapses "authorize, normalise, validate" into a single class so controllers stop hand-rolling that ceremony. Subclass FormRequest, implement rules(), optionally override authorize() and prepared(), then call .validate(rawInput):
class StoreMonitorRequest extends FormRequest {
const StoreMonitorRequest();
@override
bool authorize() => Gate.allows('monitor.create');
@override
Map<String, dynamic> prepared(Map<String, dynamic> data) => {
...data,
'slug': (data['name'] as String? ?? '')
.trim()
.toLowerCase()
.replaceAll(RegExp(r'\s+'), '-'),
};
@override
Map<String, List<Rule>> rules() => {
'name': [Required(), Max(120)],
'slug': [Required()],
};
}
// In a controller with the ValidatesRequests mixin:
try {
final payload = const StoreMonitorRequest().validate(form.data);
await Http.post('/monitors', data: payload);
} on AuthorizationException {
Magic.error('Error', 'You do not have permission to create monitors.');
} on ValidationException catch (e) {
// e.errors maps each failing field to its message; put them on the form
setErrorsFromMap(
e.errors.map((field, message) => MapEntry(field, [message])),
);
}
authorize()runs first and throwsAuthorizationExceptiononfalse.Gate.allows('monitor.create')answerstrueonly once the app has defined the ability, typically in a provider'sboot()withGate.define('monitor.create', (User user, [dynamic _]) => user.isAdmin)(see Authorization). Until then every request is denied, unless aGate.beforecallback allows it; a signed-out user is always denied.prepared()normalises the payload before rules see it (trim, slugify, merge defaults).validate()returns the prepared payload filtered to the keys declared inrules(), the same contract asValidator.validate.attributes()names fields for:attributein this request's messages (see below). Default is an empty map.
Naming Fields Per Request#
By default :attribute resolves to the catalogue's global attributes.<key>, then to the humanized key. Override attributes() when one request calls a field something else; an entry wins over the global name, and a field it omits keeps the global lookup:
class UpdatePanelRequest extends FormRequest {
@override
Map<String, List<Rule>> rules() => {
'url': [Required()],
};
@override
Map<String, String> attributes() => {'url': 'Server address'};
}
// "The Server address field is required."
The value is used as written, not looked up as a translation key. Validator.make(data, rules, attributes: {...}) takes the same map for ad hoc checks. ValidatesRequests.validate takes the same map, and validateRequest and validateRequestAsync pass the request's attributes() on, so a controller with the mixin and MagicFormObject.submit honour it.
Pairs cleanly with Model.fill(payload, strict: true) so mass-assignment catches schema drift at the boundary.
Validating a Form Request Through a Controller#
FormRequest.validate() filters its return value to the keys declared in rules() and never touches a controller's error bag, so calling it directly skips clearing stale errors before a resubmit, populating per-field errors on failure, and repainting the form. A controller with the ValidatesRequests mixin calls validateRequest instead, which runs the same authorize/prepare sequence but routes through the mixin's own error bag and returns the FULL prepared map, not the rule-filtered one, so a write can still send fields the backend needs but no rule constrains:
class MonitorController extends MagicController with ValidatesRequests {
Future<void> store(Map<String, dynamic> data) async {
try {
final payload = validateRequest(const StoreMonitorRequest(), data);
await Http.post('/monitors', data: payload);
} on AuthorizationException {
Magic.error('Error', 'You do not have permission to create monitors.');
} on ValidationException catch (e) {
// validationErrors is already populated; UI has already rebuilt.
}
}
}
validateRequest throws AuthorizationException when FormRequest.authorize() returns false, before any field is touched, and runs only synchronous rules (an AsyncRule in the request's rules() is skipped). Use validateRequestAsync when a rule needs to await; it has the same contract, validated through Validator.validateAsync() so an AsyncRule actually runs:
final payload = await validateRequestAsync(SlugUniqueRequest(), data);
CollapsesIndexedErrorKeys#
A backend that validates a list field returns one wire key per element (items.0.name, items.1.name), but a form with a single error slot per field, not per element, has nowhere to put a per-index message. ValidatesRequests.errorFieldFor keeps a wire key as-is by default, which is what an existing controller already depends on; mix in CollapsesIndexedErrorKeys on top to collapse an indexed key down to its field name instead:
class ItemsController extends MagicController
with ValidatesRequests, CollapsesIndexedErrorKeys {}
// A 422 response carrying {"errors": {"items.0.name": ["The items.0.name field is required."]}}
// populates controller.validationErrors as {"name": "The items.0.name field is required."}
Two wire keys that collapse onto the same field (two failing elements of the same list) keep the FIRST message; the later one is dropped rather than overwriting it. A key addressing a distinct sub-key rather than a list element (credentials.token) is left whole, since a form with a separate error slot per sub-key needs each one kept.
A controller that already extends a different base and cannot mix in CollapsesIndexedErrorKeys still reaches the same collapse through the mixin's static helper, CollapsesIndexedErrorKeys.collapse(wireKey):
final field = CollapsesIndexedErrorKeys.collapse('items.0.name'); // 'name'
Server-Side Validation#
Magic automatically handles Laravel-style 422 validation errors from your API.
Controller Setup#
Add the ValidatesRequests mixin to your controller:
class AuthController extends MagicController
with MagicStateMixin<bool>, ValidatesRequests {
Future<void> register(Map<String, dynamic> data) async {
setLoading();
clearErrors(); // Clear previous validation errors
final response = await Http.post('/register', data: data);
if (response.successful) {
setSuccess(true);
MagicRoute.to('/dashboard');
} else {
// Automatically populates field errors from 422 response
handleApiError(response, fallback: 'Registration failed');
}
}
}
handleApiError Method#
The handleApiError() method handles different error types:
| Error Type | Behaviour |
|---|---|
| 422 Validation | Sets field-level errors; with MagicStateMixin, sets the response's error message, or empty when it has none |
| Any other failure | With MagicStateMixin, sets the first error from the response, else fallback, else 'An error occurred' |
ValidatesRequests Methods#
| Method | Description |
|---|---|
handleApiError(response) |
Handle any API error automatically |
setErrorsFromResponse(response) |
Populate errors from 422 response |
hasError('field') |
Check if a field has an error |
getError('field') |
Get error message for a field |
hasErrors |
Check if any errors exist |
clearErrors() |
Clear all validation errors |
Displaying Server Errors#
Server-side errors appear automatically under form fields. For manual display:
if (controller.hasError('email'))
WText(controller.getError('email')!, className: 'text-red-500 text-sm'),
Async Validation#
Some validations cannot decide locally: uniqueness, remote existence, captcha checks. Extend AsyncRule instead of Rule and implement passesAsync. Run the validator with validateAsync() to await async rules; sync failures short-circuit per field so no network call is made for a field that already has a sync error.
The AsyncRule Contract#
AsyncRule is an abstract class that extends Rule. Implement passesAsync for the async check. The synchronous passes method always returns true so the rule is invisible to Validator.validate() (sync-only flows); the real outcome comes from validateAsync().
class Exists extends AsyncRule {
Exists(this.endpoint);
final String endpoint;
@override
Future<bool> passesAsync(
String attribute,
dynamic value,
Map<String, dynamic> data,
) async {
if (value == null) return true;
final response = await Http.get(
endpoint,
query: {attribute: value.toString()},
);
return response.successful;
}
@override
String message() => 'validation.exists';
}
Use validateAsync() whenever the rule set contains async rules:
final validator = Validator.make(data, {
'slug': [Required(), Unique('/validate/unique', field: 'slug')],
});
try {
final validated = await validator.validateAsync();
} on ValidationException catch (e) {
// e.errors maps each failing field to its message
}
The Unique Rule#
Unique(endpoint, field: ...) issues a GET to the endpoint with ?{field}={value} and treats {"unique": true} (or {"available": true}) as a pass. Network errors are logged and pass so a flaky connection never blocks the form; the server remains the source of truth on submit.
// Default HTTP resolver (GET with query param)
Unique('/validate/unique', field: 'slug');
// Custom resolver (e.g. POST to a different shape)
Unique('/validate/unique', field: 'slug').via((endpoint, field, value) async {
final response = await Http.post(endpoint, data: {field: value});
return response.data['unique'] == true;
});
Debounce#
Unique debounces rapid-fire calls with a default window of 400 ms. Only the last call within the window reaches the resolver; earlier calls resolve to true as stale and never record errors. Pass debounce: Duration.zero to disable debouncing entirely.
// Custom debounce window
Unique('/validate/unique', field: 'email', debounce: Duration(milliseconds: 600));
// No debounce
Unique('/validate/unique', field: 'email', debounce: Duration.zero);
Custom Rules#
Create custom rules by extending Rule:
class StrongPassword extends Rule {
@override
bool passes(String attribute, dynamic value, Map<String, dynamic> data) {
if (value is! String || value.isEmpty) return true;
final hasUppercase = value.contains(RegExp(r'[A-Z]'));
final hasLowercase = value.contains(RegExp(r'[a-z]'));
final hasNumber = value.contains(RegExp(r'[0-9]'));
final hasSpecial = value.contains(RegExp(r'[!@#$%^&*]'));
return hasUppercase && hasLowercase && hasNumber && hasSpecial;
}
@override
String message() => 'validation.strong_password';
}
// Usage
validator: rules([Required(), StrongPassword()], field: 'password')
message() returns the translation key, not the translated sentence: the validator translates it, and Rule.name (here strong_password, the key for a messages override) is the part after the last dot. A rule that returns a raw sentence has no usable name and overrides name itself.
Error Styling#
Wind UI's WFormInput automatically adds the error state when validation fails:
WFormInput(
controller: form['email'],
className: '''
p-3 border border-gray-300 rounded-lg
focus:ring-2 focus:ring-blue-500
error:border-red-500 error:ring-red-200
''',
validator: rules([Required(), Email()], field: 'email'),
)
The error: prefix applies styles when the field has validation errors.
Localization#
Validation Messages#
Define validation messages in your language files:
{
"validation": {
"required": "The :attribute field is required.",
"email": "The :attribute must be a valid email address.",
"min": {
"string": "The :attribute must be at least :min characters."
},
"confirmed": "The :attribute confirmation does not match.",
"accepted": "The :attribute must be accepted.",
"uuid": "The :attribute must be a valid UUID.",
"gt": {
"numeric": "The :attribute must be greater than :value."
},
"between": {
"numeric": "The :attribute must be between :min and :max."
},
"strong_password": "The :attribute must contain uppercase, lowercase, number, and special character."
}
}
Attribute Names#
Define user-friendly field names:
{
"attributes": {
"email": "email address",
"password": "password",
"password_confirmation": "password confirmation",
"accept_terms": "terms and conditions"
}
}
The :attribute placeholder is replaced with the localised field name.