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.
- Introduction
- Quick Start
- Defining Validation Rules
- Available Rules
- Form Requests
- Server-Side Validation
- Async Validation
- Custom Rules
- Error Styling
- Localization
Introduction
Magic provides a powerful validation system that integrates seamlessly 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 {
const RegisterView({super.key});
@override
State createState() => _RegisterViewState();
}
class _RegisterViewState extends MagicStatefulViewState {
// 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('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()] |
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 |
Value must appear in whitelist | [In |
InList |
Value must match an enum (name or instance) | [InList(Severity.values)] |
Whitelist Rules (In / InList)
In validates against a primitive whitelist. The generic T is the element type, so type mismatches fail explicitly rather than silently coercing:
'visibility': [In(['public', 'private'])],
'priority': [In([1, 2, 3, 5, 8])],
InList 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.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, normalize, 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 {
StoreMonitorRequest(this.actor);
final User actor;
@override
bool authorize() => actor.can('monitor.create');
@override
Map prepared(Map data) => {
...data,
'slug': slugify(data['name'] as String? ?? ''),
};
@override
Map> rules() => {
'name': [Required(), Max(120)],
'slug': [Required()],
};
}
// In the controller:
try {
final payload = StoreMonitorRequest(Auth.user()!).validate(form.data);
await Http.post('/monitors', data: payload);
} on AuthorizationException {
Magic.toast.error('You do not have permission to create monitors.');
} on ValidationException catch (e) {
setFieldErrors(e.errors); // ValidatesRequests mixin
}
authorize()runs first and throwsAuthorizationExceptiononfalse.prepared()normalizes the payload before rules see it (trim, slugify, merge defaults).validate()returns the prepared payload filtered to the keys declared inrules()— same contract asValidator.validate.
Pairs cleanly with Model.fill(payload, strict: true) so mass-assignment catches schema drift at the boundary.
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, ValidatesRequests {
Future register(Map 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 | Behavior |
|---|---|
| 422 Validation | Sets field-level errors, form shows errors |
| 401 Unauthorized | Shows unauthorized message |
| 500+ Server Error | Shows fallback error message |
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 passesAsync(
String attribute,
dynamic value,
Map 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) {
setFieldErrors(e.errors);
}
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 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() => trans('validation.strong_password');
}
// Usage
validator: rules([Required(), StrongPassword()], field: 'password')
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.",
"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 localized field name.