Skip to content

Validation

TwoWee uses three layers of validation. No server calls happen during editing — the client validates locally, then checks with the server only on blur (for lookup fields) and on save.

Layer 1: Client-Side Rules

Rules declared on fields are sent to the client in the validation object. The client enforces them immediately as the user types — no round-trip needed.

php
Text::make('name')
    ->required()                    // validation.required = true
    ->minLength(3)                  // validation.min_length = 3
    ->maxLength(100)                // validation.max_length = 100
    ->inputMask('uppercase')        // validation.input_mask = "uppercase"

Decimal::make('amount')
    ->decimals(2)                   // validation.decimals = 2
    ->min(0)                        // validation.min = 0
    ->max(999999.99)                // validation.max = 999999.99

Text::make('no')
    ->uppercase()
    ->pattern('^[A-Z]{2}\d{4}$')   // validation.pattern = regex

Available rules:

RuleApplies toEffect
requiredAll fieldsCannot be empty
max_lengthText fieldsCharacter limit
min_lengthText fieldsMinimum characters
input_maskText fields"uppercase", "lowercase", "digits_only"
patternText fieldsRegex the value must match
decimalsDecimal fieldsMaximum decimal places
min / maxNumeric fieldsValue bounds

Input mask normalization

Fields with ->uppercase() or ->lowercase() are automatically normalized before validation and save. The plugin transforms the value before any rules run:

php
Text::make('country_code')
    ->uppercase()
    ->exists('countries', 'code')

User types dk → plugin normalizes to DKexists rule checks WHERE code = 'DK' → passes → model filled with DK.

No custom closure rules or model mutators needed for case normalization.

Layer 2: Blur Validation

When a field has blur validation enabled, the client calls the server when the user tabs away. There are two flavours: lookup blur (checks a related model and returns autofill data) and rules blur (validates against Laravel rules from Resource::rules()).

Lookup blur (with autofill)

For fields tied to a related model. The server checks the value exists and returns related data.

Step 1: Mark the field with validate: true on the lookup:

php
Text::make('customer_no')
    ->uppercase()
    ->lookup('customer_no', 'customer_name', validate: true)

Step 2: Define the lookup in your Resource:

php
public static function lookups(): array
{
    return [
        'customer_no' => LookupDefinition::make(\App\Models\Customer::class)
            ->columns([
                TextColumn::make('no')->label('No.')->width(10),
                TextColumn::make('name')->label('Name')->width('fill'),
            ])
            ->valueColumn('no')
            ->autofill(['city', 'name' => 'customer_name', 'phone' => 'phone_no']),
    ];
}

What happens at runtime

  1. User types 10000 in the Customer No. field and presses Tab
  2. Client sends GET /validate/customer_no/10000
  3. ValidateController finds the LookupDefinition for customer_no
  4. LookupDefinition::validateValue() queries Customer::where('no', '10000')
  5. If found, returns the autofill data:
json
{
    "valid": true,
    "autofill": {
        "customer_name": "ACME Corp",
        "city": "Reykjavik",
        "phone_no": "555-1234"
    },
    "error": null
}
  1. Client fills in the Customer Name, City, and Phone fields automatically

If the value doesn't exist:

json
{
    "valid": false,
    "autofill": null,
    "error": "Value not found."
}

The client keeps focus on the field and shows the error — the user must fix or clear it before moving on.

Polymorphic validation

For grid columns where the lookup model depends on a type column (e.g., Item vs Resource vs G/L Account), the client appends ?type= to the validation call:

GET /validate/line_item/1000?type=Item

Handle this in your LookupDefinition query closure:

php
'line_item' => LookupDefinition::make(\App\Models\Item::class)
    ->query(function ($query, array $context) {
        if (($context['type'] ?? null) === 'Resource') {
            $query->from('resources');
        }
    })
    ->valueColumn('no')
    ->autofill(['description']),

Rules blur (without autofill)

For plain fields that need server-side validation on blur but aren't tied to a lookup. Rules live on the field itself — one source of truth for both blur and save.

Mark the field with blurValidate() and add rules directly on the field:

php
Text::make('name')
    ->label('Name')
    ->required()
    ->minLength(3)
    ->maxLength(100)
    ->blurValidate()

When the user tabs out of the name field, the client calls GET /validate/name/Ab and the server runs the field's rules:

json
{
    "valid": false,
    "autofill": null,
    "error": "The name field must be at least 3 characters."
}

How the controller resolves validation

The ValidateController checks in order:

  1. Lookup — if a LookupDefinition exists for the field_id, validate against the model and return autofill
  2. Field rules — if the field is registered as blur-validated, pull its rules from the field definition and run Validator::make()
  3. 404 — if neither exists, return an error

This means lookup fields and rules fields share the same GET /validate/{field_id}/{value} endpoint. A field with both a lookup and blurValidate() uses the lookup (it's more specific).

Layer 3: Save Validation

When the user presses Ctrl+S, the client sends a SaveChangeset with all changed fields. The SaveController collects rules from all form fields and validates before persisting.

Rules on fields

Rules live on the fields themselves — the same rules used for blur validation are also used on save:

php
public static function form(): array
{
    return [
        Section::make('General')->fields([
            Text::make('no')->label('No.')->required()
                ->unique('customers', 'no')
                ->uppercase(),
            Text::make('name')->label('Name')->required()
                ->minLength(3)->maxLength(100)
                ->blurValidate(),
            Email::make('email')->label('E-Mail')
                ->nullable()->email()
                ->unique('customers', 'email')
                ->blurValidate(),
            Decimal::make('credit_limit')->label('Credit Limit')
                ->nullable()->rules(['numeric', 'min:0']),
        ]),
    ];
}

Available rule builders on Field:

MethodLaravel rule
->required()required
->nullable()nullable
->minLength(3)min:3
->maxLength(100)max:100
->email()email
->unique('table', 'column')Rule::unique(...) with auto-ignore on update
->exists('table', 'column')Rule::exists(...)
->enum(StatusEnum::class)Rule::enum(...)
->in(['a', 'b', 'c'])Rule::in(...)
->regex('/pattern/')regex:/pattern/
->rules(['alpha_num', ...])Any raw Laravel rules
->creationRules([...])Rules only applied when creating
->updateRules([...])Rules only applied when updating

The unique() builder automatically calls ->ignore($model->getKey()) on updates, so you don't need to handle that yourself.

Context-specific rules

Use creationRules() and updateRules() when the validation differs between create and update:

php
Text::make('code')
    ->rules(['alpha_num'])                              // always
    ->creationRules(['required', 'unique:items,code'])  // only on create
    ->updateRules(['sometimes'])                        // only on update

Text::make('email')
    ->rules(['email'])
    ->creationRules(['required'])
    ->updateRules(['sometimes'])

The rules() method applies in both contexts. creationRules() are merged on create, updateRules() on update.

Resource::rules() override

For cross-field rules or rules that can't be expressed on a single field, you can still override rules() on the Resource. These are merged with field-level rules (field rules take precedence for the same key):

php
public static function rules(?Model $model = null): array
{
    return [
        // Cross-field rule: end_date must be after start_date
        'end_date' => 'after:start_date',
    ];
}

Sparse changesets

On store (new record): all rules run. On update: only rules for fields present in the changeset run. This means required on an untouched field won't fire — no need for the sometimes prefix.

Line validation

For HeaderLines/Grid layouts, define lineRules() for per-row validation. Keys are column names from lineColumns():

php
public static function lineRules(): array
{
    return [
        'no' => 'required|string',
        'quantity' => 'required|numeric|min:0.01',
        'unit_price' => 'required|numeric|min:0',
    ];
}

Each non-empty row is validated individually. Errors include the line number: "Error: Line 3: The quantity field is required."

Error handling

On validation failure, the SaveController returns the card with the first error in the status field. The card stays open with the user's values intact — no data is lost:

json
{
    "layout": "Card",
    "title": "Customer Card - 10000",
    "status": "Error: The name field is required.",
    "sections": [...],
    ...
}

No rules = no validation

If rules() returns an empty array (the default), the SaveController skips validation and saves directly. This is backwards-compatible — existing resources work unchanged.

Why not Laravel Precognition?

Precognition is designed for HTML forms — it sends partial form data to form requests and returns 422 error bags. TwoWee needs something different:

  • Per-field endpoints (/validate/customer_no/10000) not per-form
  • Autofill responses (return related data, not just valid/invalid)
  • Lookup-driven (validation is "does this value exist in a related model?")
  • Custom response shape ({ valid, autofill, error })

The plugin's LookupDefinition::validateValue() + ValidateController maps directly to the protocol in ~30 lines.