Forms
Magic provides a complete form handling system: MagicFormData for centralized form state, Wind form widgets for UI, FormRequest classes for structured validation and authorization, and the ValidatesRequests mixin for controller-side error management.
- Introduction
- MagicFormData
- MagicForm Widget
- Form Inputs
- Form Validation
- Custom FormRequest Classes
- Submitting Forms
- Form Processing
- Error Management
- Form Introspection
- Complete Example
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.
[!TIP] You can generate a validated form request stub using the Magic CLI:
dart run magic:artisan make:request(e.g.,dart run magic:artisan make:request StoreOrder). The generated file atlib/app/requests/store_order_request.dartprovides a structured place for your validation rules.
MagicFormData
Creating Form Data
Create a form data instance in your stateful view:
class LoginView extends MagicStatefulView {
const LoginView({super.key});
@override
State createState() => _LoginViewState();
}
class _LoginViewState extends MagicStatefulViewState {
// 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
// Get string value
String email = form.get('email');
// Get typed value
bool? rememberMe = form.value('remember_me');
int? age = form.value('age');
// Get TextEditingController for text fields
TextEditingController emailController = form['email'];
Setting Values
// 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']);
[!NOTE]
set(field, value)is for text fields (backed byTextEditingController).setValue(field, value)is for non-text fields (backed byValueNotifier).
MagicForm Widget
Wrap your form fields with MagicForm for automatic state management:
@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 with Wind UI styling:
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.phone |
Phone number keyboard |
InputType.multiline |
Multi-line text area |
WFormCheckbox
Checkbox with validation support:
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'),
className: 'w-5 h-5 checked:bg-primary error:border-red-500',
)
WFormSelect
Dropdown select with search support:
WFormSelect(
value: form.get('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:
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'),
)
Server-Side Errors
Server-side validation errors are automatically displayed when using ValidatesRequests mixin in your controller:
class AuthController extends MagicController with ValidatesRequests {
Future register(Map 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 normalization.
import 'package:magic/magic.dart';
class StoreOrderRequest extends FormRequest {
@override
bool authorize() => Gate.allows('order.create');
@override
Map prepared(Map data) => {
...data,
'status': 'pending',
};
@override
Map> rules() => {
'product_id': [Required()],
'quantity': [Required(), Min(1)],
'note': [Max(500)],
};
}
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():
class OrderController extends MagicController with MagicStateMixin, ValidatesRequests {
Future store(Map 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) {
validationErrors = Map.from(e.errors);
notifyListeners();
setEmpty();
}
}
}
Failure modes:
authorize()returnsfalse: throwsAuthorizationException- A rule in
rules()fails: throwsValidationExceptionwith 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; its implementation lives under lib/src/concerns/ (not lib/src/http/), but app code never imports from lib/src/ directly.
[!IMPORTANT] Import
ValidatesRequestsfrompackage:magic/magic.dart. The mixin lives inlib/src/concerns/validates_requests.dart. Do not import fromlib/src/http/; the mixin is not there.
Apply the mixin with the with keyword on any MagicController subclass:
import 'package:magic/magic.dart';
class ProfileController extends MagicController with ValidatesRequests {
Future update(Map 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; notifyListeners() was called
// 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)callssetErrorsFromResponse(response), which parses the Laravel{"errors": {"field": ["msg"]}}format and populatesvalidationErrors.MagicFormdetectshasRelevantErrorson the form data and switchesAutovalidateModetoalways, so server-side errors display inline next to each field.MagicFormDataattaches listeners to each field controller soclearFieldError(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 |
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 of current errors |
Submitting Forms
Using validated()
The validated() method validates the form and returns data if valid:
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:
void _submit() {
if (form.validate()) {
// Form is valid
final data = {
'email': form.get('email'),
'password': form.get('password'),
'remember_me': form.value('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
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:
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:
// 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(
listenable: form.processingListenable,
builder: (isProcessing) => WButton(
isLoading: isProcessing,
onTap: _submit,
child: WText(trans('common.save')),
),
)
[!TIP] Prefer
form.isProcessingovercontroller.isLoadingwhen you need form-scoped loading state. This avoids full-page rebuilds and keeps loading indicators tied to the specific form being submitted.
Error Management
Clearing All Errors
Use clearErrors() on the controller to remove all validation errors at once:
class AuthController extends MagicController with ValidatesRequests {
Future register(Map 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:
// 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');
[!NOTE]
clearErrors()andclearFieldError()live on theValidatesRequestsmixin, not onMagicFormData. They are accessed through the controller.
Form Introspection
fieldNames
The fieldNames getter returns a Set of all registered field names (both text and non-text fields):
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:
if (form.hasRelevantErrors) {
// At least one of this form's fields has a server-side error
}
Complete Example
class RegisterView extends MagicStatefulView {
const RegisterView({super.key});
@override
State createState() => _RegisterViewState();
}
class _RegisterViewState extends MagicStatefulViewState {
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('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);
}
}
}