search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

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

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 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.