search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs
You are viewing an older version (0.0.3). Go to the latest.

Validation

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(values) Value must appear in whitelist [In(['public', 'private'])]
InList(values) 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 throws AuthorizationException on false.
  • prepared() normalizes the payload before rules see it (trim, slugify, merge defaults).
  • validate() returns the prepared payload filtered to the keys declared in rules() — same contract as Validator.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 Rules

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.

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
Unique('/validate/unique', field: 'slug');

// Custom resolver
Unique('/validate/unique', field: 'slug').via((endpoint, field, value) async {
  final response = await Http.post(endpoint, data: {field: value});
  return response['unique'] == true;
});

Debounce

Unique debounces rapid-fire calls (default 400ms). Only the last call within the window reaches the resolver; earlier calls resolve to true as stale. Pass debounce: Duration.zero to disable.

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.