Skip to content

Forms

View raw .md

Magic provides a complete form handling system: MagicFormData for centralised form state, wind_kit form widgets for UI, FormRequest classes for structured validation and authorisation, and the ValidatesRequests mixin for controller-side error management.

On this page

Introduction#

Magic provides a powerful form handling system that combines the simplicity of Laravel's request handling with Flutter's form widgets. Forms are managed through MagicFormData, which centralizes form state, validation, and data extraction.

MagicFormData#

Creating Form Data#

Create a form data instance in your stateful view:

dart
class LoginView extends MagicStatefulView<AuthController> {
  const LoginView({super.key});
  
  @override
  State<LoginView> createState() => _LoginViewState();
}

class _LoginViewState extends MagicStatefulViewState<AuthController, LoginView> {
  // Define form fields with initial values
  // String values create TextEditingControllers
  // bool values create ValueNotifiers
  late final form = MagicFormData({
    'email': '',
    'password': '',
    'remember_me': false,
  }, controller: controller);

  @override
  void onClose() => form.dispose();  // Always dispose
}

Accessing Values#

dart
// Get string value
String email = form.get('email');

// Get typed value
bool? rememberMe = form.value<bool>('remember_me');
int? age = form.value<int>('age');

// Get TextEditingController for text fields
TextEditingController emailController = form['email'];

Setting Values#

dart
// Set text field value
form.set('email', '[email protected]');

// Set non-text field value (bool, list, etc.)
form.setValue('remember_me', true);
form.setValue('tags', ['flutter', 'magic']);

MagicForm Widget#

Wrap your form fields with MagicForm for automatic state management:

dart
@override
Widget build(BuildContext context) {
  return MagicForm(
    formData: form,
    child: WDiv(
      className: 'flex flex-col gap-4',
      children: [
        WFormInput(
          controller: form['email'],
          label: trans('attributes.email'),
          validator: rules([Required(), Email()], field: 'email'),
        ),
        WFormInput(
          controller: form['password'],
          type: InputType.password,
          label: trans('attributes.password'),
          validator: rules([Required(), Min(8)], field: 'password'),
        ),
        WButton(
          onTap: _submit,
          child: WText(trans('auth.login')),
        ),
      ],
    ),
  );
}

MagicForm provides:

  • Form key for validation
  • Auto-validation mode when errors exist
  • Automatic error state binding
  • Server-side error display

Form Inputs#

WFormInput#

The primary text input widget, styled with wind_kit class names:

dart
WFormInput(
  controller: form['email'],
  label: trans('attributes.email'),
  placeholder: trans('fields.email_placeholder'),
  type: InputType.email,  // text, password, email, number, multiline
  validator: rules([Required(), Email()], field: 'email'),
  className: '''
    w-full bg-slate-900 border border-gray-700 rounded-lg p-3 text-white
    focus:border-primary focus:ring-2 focus:ring-primary/30
    error:border-red-500 error:ring-red-200
  ''',
  labelClassName: 'text-sm font-medium text-gray-300 mb-1',
  placeholderClassName: 'text-gray-400',
)

Input Types

Type Description
InputType.text Standard text input (default)
InputType.password Obscured password input
InputType.email Email keyboard
InputType.number Numeric keyboard
InputType.multiline Multi-line text area

WFormCheckbox#

Checkbox with validation support:

dart
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'),
  className: 'w-5 h-5 checked:bg-primary error:border-red-500',
)

WFormSelect#

Dropdown select with search support. Its value is not text, so declare it as a non-text field ('country': null as String?) and read it with value<T>:

dart
WFormSelect<String>(
  value: form.value<String?>('country'),
  options: [
    SelectOption(value: 'us', label: 'United States'),
    SelectOption(value: 'gb', label: 'United Kingdom'),
    SelectOption(value: 'tr', label: 'Turkey'),
  ],
  onChange: (v) => form.setValue('country', v),
  label: trans('attributes.country'),
  searchable: true,
  placeholder: trans('fields.select_country'),
  validator: rules([Required()], field: 'country'),
  className: 'w-full bg-slate-900 border border-gray-700 rounded-lg',
)

Form Validation#

Client-Side Validation#

Use the rules() helper for client-side validation. It is a method of MagicStatefulViewState that wraps FormValidator.rules and passes the view's controller along, so a server-side error for the field wins over the client-side rules:

dart
WFormInput(
  controller: form['email'],
  validator: rules([Required(), Email()], field: 'email'),
)

WFormInput(
  controller: form['password_confirmation'],
  validator: rules([
    Required(),
    Same('password', valueGetter: () => form['password'].text),
  ], field: 'password_confirmation'),
)

To name a field in its messages, call FormValidator.rules with attribute. It wins over the global attributes.<key> and matches what a FormRequest.attributes() entry shows after submit:

dart
WFormInput(
  validator: FormValidator.rules([Required()], field: 'url', attribute: 'Server address'),
)

Server-Side Errors#

Server-side validation errors are automatically displayed when using ValidatesRequests mixin in your controller:

dart
class AuthController extends MagicController with ValidatesRequests {
  Future<void> register(Map<String, dynamic> data) async {
    clearErrors();  // Clear previous errors
    
    final response = await Http.post('/register', data: data);
    
    if (!response.successful) {
      handleApiError(response);  // Auto-populates field errors
    }
  }
}

Custom FormRequest Classes#

FormRequest collapses the authorize-prepare-validate ceremony into a single, reusable class. Subclass it, declare rules(), and optionally override authorize() for access checks and prepared() for input normalisation.

dart
import 'package:magic/magic.dart';

class StoreOrderRequest extends FormRequest {
  @override
  bool authorize() => Gate.allows('order.create');

  @override
  Map<String, dynamic> prepared(Map<String, dynamic> data) => {
    ...data,
    'status': 'pending',
  };

  @override
  Map<String, List<Rule>> rules() => {
    'product_id': [Required()],
    'quantity': [Required(), Min(1)],
    'note': [Max(500)],
    'status': [Required(), In(['pending'])],
  };
}

Pass the raw form data to validate() in the controller. It runs authorize first, then prepared, then the rules, and returns the cleaned data filtered to the keys declared in rules(). A key that prepared() adds survives only when rules() names it, which is why status has a rule above; without one, validate() drops it:

dart
class OrderController extends MagicController with MagicStateMixin<Order>, ValidatesRequests {
  Future<void> store(Map<String, dynamic> data) async {
    setLoading();
    clearErrors();

    try {
      final payload = StoreOrderRequest().validate(data);
      final response = await Http.post('/orders', data: payload);

      if (!response.successful) {
        handleApiError(response);
        return;
      }

      setSuccess(Order.fromMap(response.data));
      MagicRoute.to('/orders');
    } on AuthorizationException {
      setError('You are not authorized to create orders.');
    } on ValidationException catch (e) {
      setErrorsFromMap(e.errors.map((field, message) => MapEntry(field, [message])));
      setEmpty();
    }
  }
}

Override attributes() to name a field for :attribute in this request's messages, ahead of the global attributes.<key> translation:

dart
@override
Map<String, String> attributes() => {'product_id': 'Product'};

Failure modes:

  • authorize() returns false: throws AuthorizationException
  • A rule in rules() fails: throws ValidationException with a field-keyed error map

Validating Requests in Controllers#

The ValidatesRequests mixin adds Laravel-style validation directly to a controller. Import it from the public barrel package:magic/magic.dart.

Apply the mixin with the with keyword on any MagicController subclass:

dart
import 'package:magic/magic.dart';

class ProfileController extends MagicController with ValidatesRequests {
  Future<void> update(Map<String, dynamic> data) async {
    clearErrors(); // Clear previous field errors before re-submitting

    try {
      final validated = validate(data, {
        'name': [Required(), Min(2), Max(100)],
        'email': [Required(), Email()],
      });

      final response = await Http.put('/profile', data: validated);

      if (!response.successful) {
        handleApiError(response); // Populates validationErrors from 422 response
        return;
      }

      // Success path
    } on ValidationException {
      // validationErrors is already populated and the view was refreshed
      // The MagicForm widget switches to AutovalidateMode.always automatically
    }
  }
}

How errors surface in MagicFormData:

When the controller bound to a MagicFormData instance implements ValidatesRequests, field errors flow to the form automatically:

  • handleApiError(response) calls setErrorsFromResponse(response), which parses the Laravel {"errors": {"field": ["msg"]}} format and populates validationErrors.
  • MagicForm detects hasRelevantErrors on the form data and switches AutovalidateMode to always, so server-side errors display inline next to each field.
  • MagicFormData attaches listeners to each field controller so clearFieldError(field) fires as soon as the user types, removing stale errors one field at a time.

Available mixin members:

Member Description
validate(data, rules) Run rules; returns validated data or throws ValidationException
validateRequest(request, data) Run a FormRequest (authorize, prepare, rules); returns the prepared data or throws
setErrorsFromResponse(response) Populate field errors from a 422 MagicResponse
handleApiError(response, {fallback}) Combined handler: sets field errors for 422, generic error otherwise
clearErrors() Remove all validation errors and notify listeners
clearFieldError(field) Remove a single field's error and notify listeners
hasError(field) Returns true if the field has an error
getError(field) Returns the error message for a field, or null
firstError The first error message in validationErrors, or null
hasErrors true if any validation errors are present
validationErrors The raw Map<String, String> of current errors

Submitting Forms#

Using validated()#

The validated() method validates the form and returns data if valid:

dart
void _submit() {
  final data = form.validated();
  
  if (data.isNotEmpty) {
    // Form is valid, submit to controller
    controller.register(data);
  }
  // If empty, validation failed (errors are shown automatically)
}

Using validate()#

For more control, use validate() separately:

dart
void _submit() {
  if (form.validate()) {
    // Form is valid
    final data = {
      'email': form.get('email'),
      'password': form.get('password'),
      'remember_me': form.value<bool>('remember_me'),
    };
    controller.login(data);
  }
}

Form Processing#

The process() method wraps an async action with automatic loading state management. It sets isProcessing to true before execution and false after, regardless of success or failure.

Basic Usage#

dart
void _submit() async {
  final data = form.validated();
  if (data.isEmpty) return;

  await form.process(() => controller.register(data));
}

Preventing Double Submissions#

process() throws a StateError if the form is already processing, preventing concurrent submissions:

dart
void _submit() async {
  final data = form.validated();
  if (data.isEmpty) return;

  try {
    await form.process(() => controller.register(data));
  } on StateError {
    // Already processing, ignore
  }
}

Processing-Aware UI#

Use isProcessing for simple checks, or processingListenable for granular rebuilds:

dart
// Simple: check in build
WButton(
  isLoading: form.isProcessing,
  onTap: _submit,
  child: WText(trans('common.save')),
)

// Efficient: rebuild only the button when processing state changes
MagicBuilder<bool>(
  listenable: form.processingListenable,
  builder: (isProcessing) => WButton(
    isLoading: isProcessing,
    onTap: _submit,
    child: WText(trans('common.save')),
  ),
)

Error Management#

Clearing All Errors#

Use clearErrors() on the controller to remove all validation errors at once:

dart
class AuthController extends MagicController with ValidatesRequests {
  Future<void> register(Map<String, dynamic> data) async {
    clearErrors();  // Clear previous errors before new submission

    final response = await Http.post('/register', data: data);

    if (!response.successful) {
      handleApiError(response);
    }
  }
}

Clearing a Single Field Error#

Use clearFieldError(field) to remove the error for a specific field. This is called automatically when the user types in a text field or changes a non-text field value, thanks to MagicFormData's built-in listeners:

dart
// Automatic: MagicFormData auto-clears field errors on input change.
// No manual wiring needed for fields registered in MagicFormData.

// Manual: clear a specific field error explicitly
controller.clearFieldError('email');

Form Introspection#

fieldNames#

The fieldNames getter returns a Set<String> of all registered field names (both text and non-text fields):

dart
final form = MagicFormData({
  'name': '',
  'email': '',
  'accept_terms': false,
});

print(form.fieldNames); // {'name', 'email', 'accept_terms'}

hasRelevantErrors#

The hasRelevantErrors getter checks if the controller has validation errors that match fields in this form. This prevents cross-form error leakage when multiple forms share a controller:

dart
if (form.hasRelevantErrors) {
  // At least one of this form's fields has a server-side error
}

MagicFormObject#

The pattern above (MagicFormData in a State, ValidatesRequests on the controller) is the right shape for a form that shares its controller with the rest of the screen. When a form is its own unit of work, unrelated to the screen's read state, MagicFormObject collapses MagicFormData, ValidatesRequests, CollapsesIndexedErrorKeys, and RunsActions into one class: a Livewire-style Form object.

dart
class MonitorFormObject extends MagicFormObject {
  MonitorFormObject({this.editing});

  final Monitor? editing;

  @override
  Map<String, dynamic> get initial => {
    'name': editing?.name ?? '',
    'url': editing?.url ?? '',
  };

  @override
  FormRequest get request =>
      editing == null ? const StoreMonitorRequest() : const UpdateMonitorRequest();

  @override
  Future<bool> persist(Map<String, dynamic> validated) async {
    final monitor = editing ?? Monitor();
    monitor.fill(validated, strict: true);
    return monitor.save();
  }
}

Create ONE instance per State and dispose it from that State's onClose:

dart
class _MonitorFormViewState extends MagicStatefulViewState<MonitorFormController, MonitorFormView> {
  late final form = MonitorFormObject(editing: widget.editing);

  @override
  void onClose() => form.dispose();

  void _submit() => form.submit();
}

submit() clears stale errors, validates the current data against request (painting a failing client rule onto data's fields and stopping before persist ever runs), then routes persist through data.process so data.isProcessing covers the write and a server-side ValidationException from persist is caught the same way a client-side failure is. Never register a MagicFormObject via Magic.put/findOrPut: it is scoped to a single screen's lifetime, and onClose asserts (in debug) that it never ended up in the container. See Actions for the RunsActions half of the mix.

bash
dart run magic:dowel make:form Monitor                          # -> MonitorFormObject
dart run magic:dowel make:form Monitor --request=StoreMonitorRequest
dart run magic:dowel make:form Monitor --resource=Monitor       # full create and edit contract

Complete Example#

dart
class RegisterView extends MagicStatefulView<AuthController> {
  const RegisterView({super.key});
  
  @override
  State<RegisterView> createState() => _RegisterViewState();
}

class _RegisterViewState extends MagicStatefulViewState<AuthController, RegisterView> {
  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 ListenableBuilder(
      listenable: controller,
      builder: (context, _) => _buildForm(),
    );
  }
  
  Widget _buildForm() {
    return WDiv(
      className: 'max-w-md mx-auto p-6 bg-slate-900 rounded-xl',
      child: MagicForm(
        formData: form,
        child: WDiv(
          className: 'flex flex-col gap-4',
          children: [
            WText(trans('auth.register_title'), 
              className: 'text-2xl font-bold text-white text-center'),
            
            WFormInput(
              controller: form['name'],
              label: trans('attributes.name'),
              validator: rules([Required(), Min(2)], field: 'name'),
              className: _inputClass,
            ),
            
            WFormInput(
              controller: form['email'],
              type: InputType.email,
              label: trans('attributes.email'),
              validator: rules([Required(), Email()], field: 'email'),
              className: _inputClass,
            ),
            
            WFormInput(
              controller: form['password'],
              type: InputType.password,
              label: trans('attributes.password'),
              validator: rules([Required(), Min(8)], field: 'password'),
              className: _inputClass,
            ),
            
            WFormInput(
              controller: form['password_confirmation'],
              type: InputType.password,
              label: trans('attributes.password_confirmation'),
              validator: rules([
                Required(),
                Same('password', valueGetter: () => form['password'].text),
              ], field: 'password_confirmation'),
              className: _inputClass,
            ),
            
            WFormCheckbox(
              value: form.value<bool>('accept_terms'),
              onChanged: (v) => setState(() => form.setValue('accept_terms', v)),
              label: WText(trans('auth.accept_terms'), className: 'text-gray-300'),
              validator: rules([Accepted()], field: 'accept_terms'),
            ),
            
            SizedBox(height: 8),
            
            WButton(
              isLoading: controller.isLoading,
              onTap: _submit,
              className: 'w-full bg-primary p-4 rounded-lg',
              child: WText(trans('auth.register'), className: 'text-white text-center'),
            ),
          ],
        ),
      ),
    );
  }
  
  String get _inputClass => '''
    w-full bg-slate-800 border border-gray-700 rounded-lg p-3 text-white
    focus:border-primary error:border-red-500
  ''';
  
  void _submit() {
    final data = form.validated();
    if (data.isNotEmpty) {
      controller.register(data);
    }
  }
}

Gotchas#

Mistake Fix
Reading a bool or list field with form['x'], or a text field with value<T>('x') Strings become TextEditingControllers (form['x'], get, set); every other type is a ValueNotifier (value<T>, setValue). The wrong accessor fails an assert
Not calling form.dispose() from the state's onClose MagicFormData owns every controller and notifier it created; dispose() releases them all
Passing a controller to MagicFormData that does not mix in ValidatesRequests Server-side errors never reach the form and field errors are not cleared as the user types; add the mixin to the controller
Calling form.process(...) again while a submit is running It throws a StateError; guard the button with isProcessing (or isLoading: form.isProcessing)
Assuming form.validated() always returns data It returns an empty map when validation fails; check isNotEmpty before submitting
Registering a MagicFormObject with Magic.put or findOrPut Create one per State (late final form = MyForm();) and dispose it from onClose; it asserts in debug when it finds itself in the container
esc

Search index did not load.

No match for "".

Try a class like rounded-lg or a widget like WButton.