Validation System Documentation

The Joinery platform implements a three-layer validation system:

  1. Client-side JavaScript validation - Immediate user feedback
  2. FormWriter validation - Framework integration and HTML generation
  3. Server-side model validation - Data integrity and security

System Architecture

┌─────────────────────────────────────────────────────────────────┐
│                    VALIDATION FLOW                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  User Input → JavaScript Validation → Form Submission          │
│                    (client-side)         (with errors blocked)  │
│                          ↓                                       │
│                    Server Receives                              │
│                          ↓                                       │
│              FormWriter Processes Data                          │
│                          ↓                                       │
│       Model->prepare() → Server Validation                      │
│            (field_specifications rules)                         │
│                          ↓                                       │
│              Model->save() → Database                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘


1. Client-Side JavaScript Validation

Joinery uses a custom JoineryValidation library - pure JavaScript with no jQuery dependencies (though compatible with jQuery if present).

Library File

  • Location: /assets/js/joinery-validate.js
  • Version: 1.1.1
  • Dependencies: None (standalone)

Built-in Validators

ValidatorPurposeNotes
requiredField must have a valueTriggers on blur/change
emailValid email formatUses standard email regex
urlValid URL formatUses URL parsing
numberNumeric value onlyAccepts integers and decimals
minlengthMinimum character lengthValue is character count
maxlengthMaximum character lengthValue is character count
minMinimum numeric valueNumeric comparison
maxMaximum numeric valueNumeric comparison
equalToMust match another fieldValue = field name (e.g., 'password')
timeValid time format HH:MM24-hour format
dateValid date formatVarious formats supported
patternRegex pattern matchValue = regex pattern
remoteAJAX validationServer-side unique check
uniqueUnique value in databaseAuto-generated from field_specifications

JavaScript Validation with FormWriterV2

In FormWriterV2, client-side validation is automatic — no manual configuration required. Define validation rules in $field_specifications and end_form() emits the validation script automatically:

<?php
// In data/example_class.php — define validation in field_specifications
public static $field_specifications = array(
    'exa_email' => array(
        'type' => 'varchar(255)',
        'required' => true,
        'validation' => array(
            'email' => true,
            'messages' => array('email' => 'Please enter a valid email address')
        )
    ),
    'exa_password' => array(
        'type' => 'varchar(255)',
        'required' => true,
        'validation' => array(
            'minlength' => 8,
            'messages' => array('minlength' => 'Password must be at least 8 characters')
        )
    ),
);

// In your view — validation JS is output automatically by end_form()
$formwriter = $page->getFormWriter('contact_form');
$formwriter->begin_form(['action' => '/contact', 'method' => 'POST']);
$formwriter->textinput('exa_email', 'Email:', ['required' => true]);
$formwriter->passwordinput('exa_password', 'Password:', ['required' => true]);
$formwriter->submitbutton('btn_submit', 'Submit');
$formwriter->end_form(); // <-- emits validation JS automatically
?>

> Note: There is no set_validate() method. Validation rules live in field_specifications (for core model fields) or are declared inline in each input's validation option; end_form() emits the client-side script automatically. A ->set_validate(...) call will fatal.

Manual JavaScript Initialization

If you're not using FormWriter, initialize JoineryValidation directly:

// In your HTML
<script src="/assets/js/joinery-validate.js"></script>

<script>
document.addEventListener('DOMContentLoaded', function() {
    JoineryValidation.init('myFormId', {
        debug: false,  // Set true for console logging
        rules: {
            email: {
                required: true,
                email: true
            },
            password: {
                required: true,
                minlength: 8
            },
            confirm_password: {
                required: true,
                equalTo: '#password'  // Selector to match
            }
        },
        messages: {
            email: {
                required: 'Email is required',
                email: 'Please enter a valid email'
            },
            password: {
                required: 'Password is required',
                minlength: 'Password must be at least 8 characters'
            }
        },
        submitHandler: function(form) {
            // Optional. If you provide one, YOU own submission — see the
            // contract below. Submit natively so other listeners still fire:
            form.requestSubmit();
        }
    });
});
</script>

How a validated form submits — the re-dispatch contract

When a form passes validation, JoineryValidation does not call form.submit(). A programmatic form.submit() fires no submit event, so it silently navigates over every other submit listener on the form — step-up confirmation interceptors, payment tokenizers (Stripe/PayPal), and analytics all get skipped. That exact behavior was the root cause of a multi-day passkey step-up outage: the validator submitted the form out from under the step-up ceremony before it could run.

Instead, on a valid form the validator:

  1. Sets a one-shot data-jy-validated="1" stand-aside flag on the form.
  2. Re-dispatches natively via form.requestSubmit(submitter) (deferred by a task), which fires a real submit event and carries the clicked button's name/value.
  3. On that second event it sees its own flag, clears it, and passes through so the browser performs the navigation.
Two rules follow from this, and both are load-bearing:

  • Never call form.submit() on a validated form from page JS. It bypasses all submit listeners. If you must submit programmatically, use form.requestSubmit(). A custom submitHandler takes over submission entirely — inside it, submit natively (form.requestSubmit()), never form.submit().
  • Any submit listener you add must be idempotent. The validated submission fires the submit event twice (once caught-and-prevented by the validator, once on the native re-dispatch). An interceptor that runs a one-time ceremony must guard with its own flag so the second pass falls through instead of re-triggering. form.submit() remains only as the legacy fallback for browsers without requestSubmit.

Styling Classes

On each field JoineryValidation toggles two classes — is-invalid when a rule fails, is-valid when it passes — and injects the message as a sibling div.invalid-feedback (also tagged .joinery-error-label). The class names are Bootstrap-compatible, but nothing here loads Bootstrap: the active theme's .jy-ui form kit styles these states, so validation looks consistent across every theme (vanilla HTML5 and framework themes alike). Override the class names per-form with the errorClass / validClass / errorLabelClass init options.

<!-- Invalid field: is-invalid added, message injected after it -->
<input type="email" id="email" class="is-invalid">
<div class="invalid-feedback joinery-error-label">
    Please provide a valid email address.
</div>

<!-- Valid field -->
<input type="text" id="username" class="is-valid">

The Error Summary

A submit attempt that fails validation reveals a summary block — .jy-error-summary — immediately above the form's first submit button: a title ("N fields need attention:") and one linked item per invalid field. Activating an item scrolls to the field (honouring prefers-reduced-motion), opens any collapsed <details> or hidden ancestor in the way, and focuses the field — without adding a history entry, so Back still leaves the page. Focus moves to the summary itself on the failed submit (role="alert", tabindex="-1"), so a screen reader announces the explanation instead of a button that did nothing.

The summary appears only in response to a submit attempt. Blur- and change-time errors while typing update a visible summary's items but never reveal it. Fixing a listed field removes its item; fixing the last one hides the block.

Each item reads Label — message, where the message is the same string shown inline next to the field. The label resolves in order: the label[for=] text for the field (trailing */: markers stripped; a radio/checkbox group reads its container's .form-label heading), the field's aria-label, its placeholder, then the field name humanised (joinery_ai_local_model → "Joinery ai local model"). The link target resolves in order: the field's own id, the {name}_container element FormWriter emits, or an id the validator assigns so the link always has somewhere to point. Radio and checkbox groups always link their container, since no single input represents the group.

Only reachable fields can be listed: validation skips disabled fields and fields that fail checkVisibility() (collapsed panels, visibility_rules sections), so the summary never links to something that cannot be seen or fixed. That invariant is load-bearing — do not weaken it.

FormWriter emits the container server-side (see FormWriter § Validation Integration); on a form without one (hand-rolled markup), the validator creates it before the first submit button, falling back to the end of the form. Opt out per form with errorSummary: false in the init options; errorSummaryTitle overrides the title text ({n} is replaced with the count).

Array Fields (Checkboxes, Multi-select)

For fields with array notation [], use the base name without brackets in field_specifications:

// field_specifications entry for a multi-select
'product_ids' => array(
    'type' => 'text',
    'validation' => array('minlength' => 1, 'messages' => array('minlength' => 'Select at least one item'))
)

AJAX Validation (Remote Check)

For server-side validation like checking username uniqueness, add a remote rule in field_specifications:

'usr_username' => array(
    'type' => 'varchar(64)',
    'validation' => array(
        'remote' => '/api/v1/action/username_available',
        'messages' => array('remote' => 'Username is already taken')
    )
)

The AJAX endpoint receives the field value as value parameter.

A failing remote rule blocks submission like any other rule, and names itself in the error summary — so a field whose server-side check rejects it (a stale file path, a taken username) is called out next to the submit button even when the field is scrolled off screen.

Debug Mode

Enable console logging during development by passing debug: true to JoineryValidation.init() in the Manual JavaScript Initialization section below.


2. Server-Side Model Validation

Model validation happens at three points:

  1. Field Specifications - Declarative validation rules
  2. Model->prepare() - Validation before save
  3. Model->save() - Final safety checks

Field Specifications Validation

Define validation rules in field_specifications:

<?php
// In /data/user_class.php

class User extends SystemBase {
    public static $field_specifications = array(
        'usr_email' => array(
            'type' => 'varchar(255)',
            'required' => true,           // Must be non-empty string
            'unique' => true,             // Must be unique in table
            'validation' => array(
                'email' => true,           // Must be valid email
                'minlength' => 5,
                'maxlength' => 255,
                'messages' => array(
                    'email' => 'Email must be a valid email address',
                    'minlength' => 'Email must be at least 5 characters'
                )
            )
        ),
        'usr_first_name' => array(
            'type' => 'varchar(255)',
            'required' => true,
            'validation' => array(
                'minlength' => 2,
                'maxlength' => 255,
                'pattern' => '/^[a-zA-Z\s\'-]+$/',  // Letters, spaces, hyphens, apostrophes
                'messages' => array(
                    'minlength' => 'Name must be at least 2 characters',
                    'pattern' => 'Name contains invalid characters'
                )
            )
        ),
        'usr_username' => array(
            'type' => 'varchar(64)',
            'required' => true,
            'unique' => true,
            'validation' => array(
                'minlength' => 3,
                'maxlength' => 64,
                'pattern' => '/^[a-zA-Z0-9_\.]+$/',  // Alphanumeric, underscore, period only
            )
        ),
        'usr_password' => array(
            'type' => 'varchar(255)',
            'required' => true,
            'validation' => array(
                'minlength' => 8
            )
        ),
        'usr_status' => array(
            'type' => 'integer',
            'required' => true,
            'default' => 1,
            'validation' => array(
                'numeric' => true
            )
        ),
    );
}
?>

Supported Validation Rules

Basic Rules

'required' => true,              // Must be non-null and non-empty string
'unique' => true,                // Single field must be unique
'unique_with' => array('field2'), // Composite unique constraint

// Numeric rules
'numeric' => true,               // Must be numeric
'min' => 0,                      // Minimum numeric value
'max' => 100,                    // Maximum numeric value

// String rules
'minlength' => 3,                // Minimum character count
'maxlength' => 255,              // Maximum character count

// Format validation
'email' => true,                 // Must be valid email (auto-detected)
'url' => true,                   // Must be valid URL
'pattern' => '/regex/',          // Regex pattern match

Model->prepare() Method

Call prepare() to validate before saving:

<?php
// In a logic file or view
$user = new User(NULL);  // Create new user
$user->set('usr_email', $_POST['email']);
$user->set('usr_username', $_POST['username']);
$user->set('usr_password', $_POST['password']);

try {
    // This triggers all validation from field_specifications
    $user->prepare();

    // If prepare() succeeds, save to database
    $user->save();

    echo "User created successfully!";
} catch (DisplayableUserException $e) {
    // User-friendly error message (from validation rules)
    echo "Error: " . htmlspecialchars($e->getMessage());
} catch (SystemBaseException $e) {
    // System error (log it, don't show to user)
    error_log($e->getMessage());
    echo "An error occurred while processing your request.";
}
?>

Multiple Unique Constraints

For multi-field unique constraints:

'usr_email' => array(
    'type' => 'varchar(255)',
    'unique' => true,  // Single field unique
),
'usr_code' => array(
    'type' => 'varchar(10)',
    'unique_with' => array('org_id'),  // Unique combination with org_id
),

This ensures (usr_code, org_id) combination is unique.

Validation in Model Methods

Override prepare() for custom validation:

class Product extends SystemBase {
    // ... field specifications ...

    function prepare() {
        // Call parent validation first
        parent::prepare();

        // Custom validation logic
        if ($this->get('pro_price') < 0) {
            throw new DisplayableUserException('Price cannot be negative');
        }

        if ($this->get('pro_quantity') < 0) {
            throw new DisplayableUserException('Quantity cannot be negative');
        }

        // Check business logic (example)
        if ($this->get('pro_quantity') > 1000 && $this->get('pro_price') < 1) {
            throw new DisplayableUserException(
                'High quantity items must have minimum price of $1'
            );
        }
    }
}


3. FormWriter Integration

FormWriter provides a convenient interface for generating validation rules alongside form HTML, with automatic validation detection from model field_specifications.

Quick Example

$formwriter = new FormWriterV2HTML5('contact_form');

// No separate "rules" array and no set_validate() call. Validation rules are
// read from the model's field_specifications (for core fields) and/or declared
// inline per field. end_form() emits the matching client-side JS automatically.
$formwriter->begin_form();
$formwriter->textinput('email', 'Email', ['required' => true, 'validation' => ['email' => true]]);
$formwriter->passwordinput('password', 'Password', ['validation' => ['minlength' => 8]]);
$formwriter->end_form(); // <-- emits the validation script automatically

FormWriter includes model-aware validation - it extracts validation rules from a model's field_specifications by matching each field's prefix to a model. Core model fields resolve automatically. For a plugin model (which the core scan does not see), pass the owning model to the form — getFormWriter('form', ['model' => $obj]) — and its field_specifications drive detection for that model's fields. You can always declare validation rules inline on an input to override or supplement what is detected. *Server-side validation in prepare()/save() runs regardless of any of this — client-side detection is a convenience, not the gate.

For complete FormWriter validation documentation, including:

  • Usage patterns and examples
  • Model-aware validation
  • Common validation patterns
  • Custom error messages
  • Integration examples
See formwriter.md - Section 5: Validation Integration

Settings carry their rules in the manifest

A setting cannot hang its rules on a model's field_specifications: every setting is a row in stg_settings with the same stg_name / stg_value shape, so there is no per-setting column to attach them to. The rules live on the setting's declaration in settings.json or a plugin.json instead, under a validation key holding a rule array in exactly the form above. The vocabulary is unchanged — SettingsWriter seeds those rules into a FormWriter instance with registerValidationField() and runs the same validate().

That indirection is the point: rules seeded from the declarations apply to every page that can write the value, including one that never rendered the field. Rules read from the fields a form happened to draw would only ever apply to that form, which is how two pages editing the same setting came to enforce different limits. See settings.md.

Note that a pattern rule needs regex delimiters ("#^[a-z]+$#i") — a bare ^…$ makes preg_match fail and rejects every value.


4. Complete Validation Example

Here's a complete end-to-end validation example with all layers:

Step 1: Define Model with Validation Rules

<?php
// /data/product_class.php

class Product extends SystemBase {
    public static $tablename = 'pro_products';
    public static $pkey_column = 'pro_id';

    public static $field_specifications = array(
        'pro_id' => array('type'=>'int8', 'is_nullable'=>false, 'serial'=>true),

        'pro_name' => array(
            'type' => 'varchar(255)',
            'is_nullable' => false,
            'required' => true,
            'unique' => true,
            'validation' => array(
                'minlength' => 3,
                'maxlength' => 255,
                'messages' => array(
                    'minlength' => 'Product name must be at least 3 characters'
                )
            )
        ),

        'pro_description' => array(
            'type' => 'text',
            'is_nullable' => true,
            'validation' => array(
                'maxlength' => 5000
            )
        ),

        'pro_price' => array(
            'type' => 'numeric(10,2)',
            'is_nullable' => false,
            'required' => true,
            'validation' => array(
                'numeric' => true,
                'min' => 0,
                'messages' => array(
                    'numeric' => 'Price must be a number',
                    'min' => 'Price cannot be negative'
                )
            )
        ),

        'pro_sku' => array(
            'type' => 'varchar(50)',
            'is_nullable' => false,
            'required' => true,
            'unique' => true,
            'validation' => array(
                'pattern' => '/^[A-Z0-9\-]+$/',
                'maxlength' => 50
            )
        ),
    );

    // Custom validation
    function prepare() {
        parent::prepare();

        // Business logic validation
        if ($this->get('pro_price') < 0.01) {
            throw new DisplayableUserException('Price must be at least $0.01');
        }
    }
}
?>

Step 2: Create Admin Form with FormWriter

<?php
// /adm/admin_product_edit.php

require_once(PathHelper::getIncludePath('includes/AdminPage.php'));
require_once(PathHelper::getIncludePath('data/product_class.php'));

$session = SessionControl::get_instance();
$session->check_permission(5);
$session->set_return();

// Handle form submission
$product = NULL;
$product_id = $_GET['pro_id'] ?? NULL;
$product = new Product($product_id ?? NULL, !empty($product_id));

if ($_POST) {
    try {
        $product->set('pro_name', $_POST['pro_name']);
        $product->set('pro_description', $_POST['pro_description']);
        $product->set('pro_price', $_POST['pro_price']);
        $product->set('pro_sku', $_POST['pro_sku']);

        // Server-side validation
        $product->prepare();
        $product->save();

        header('Location: /adm/admin_products');
        exit;
    } catch (DisplayableUserException $e) {
        $error_message = $e->getMessage();
    }
}

// Display form
$page = new AdminPage();
$page->admin_header(array(
    'menu-id' => 'products',
    'page_title' => 'Products',
    'readable_title' => empty($product_id) ? 'Add Product' : 'Edit Product',
    'session' => $session,
));

if (isset($error_message)) {
    echo '<div class="alert alert-danger">' . htmlspecialchars($error_message) . '</div>';
}

// Pass the model so the form auto-fills current values and (for core model
// fields) auto-detects validation rules from field_specifications. There is no
// separate rules array and no set_validate() call.
$formwriter = $page->getFormWriter('product_form', ['model' => $product]);

$formwriter->begin_form(['action' => '/adm/admin_product_edit?pro_id=' . $product->key, 'method' => 'POST']);

// V2 inputs take ($name, $label, $options). Values come from the model above;
// validation rules come from field_specifications (or inline, as needed).
$formwriter->textinput('pro_name', 'Product Name', ['required' => true, 'placeholder' => 'Enter product name']);
$formwriter->textbox('pro_description', 'Description', ['rows' => 5, 'placeholder' => 'Enter product description']);
$formwriter->textinput('pro_price', 'Price', ['required' => true, 'placeholder' => 'e.g., 19.99']);
$formwriter->textinput('pro_sku', 'SKU', ['required' => true, 'placeholder' => 'e.g., PROD-001']);

$formwriter->submitbutton('btn_save', 'Save Product');

echo $formwriter->end_form();

$page->admin_footer();
?>

Step 3: Form Submission Flow

User enters data → JavaScript validation → Submit form → Server validation → Database save

  1. JavaScript Validation (instant feedback) - User types in "Pro" (too short) - JavaScript detects minlength violation - Red border appears, error message displays - Submit button stays enabled (user can fix)
  1. User fixes and submits - User types "Product Name" (valid) - JavaScript removes error - User clicks Submit - Form data sent to server
  1. Server Validation (final check) - Model->prepare() called - Validates all field_specifications rules - Checks unique constraints - Custom business logic validation
  1. Save or Reject - If valid: Model->save() → Database - If invalid: Throws DisplayableUserException - Error message shown to user - Form preserved with user's data

5. Validation Rule Reference

Required Fields

// In field_specifications
'field_name' => array(
    'type' => 'varchar(255)',
    'required' => true,  // Must not be NULL or empty string
)

// JavaScript validation
$rules['field_name']['required']['value'] = 'true';

Unique Fields

// Single field unique
'username' => array(
    'type' => 'varchar(64)',
    'unique' => true,  // Must be unique across table
)

// Multi-field unique (composite key)
'sku' => array(
    'type' => 'varchar(50)',
    'unique_with' => array('store_id'),  // (sku, store_id) must be unique
)

String Length

'field_name' => array(
    'type' => 'varchar(255)',
    'validation' => array(
        'minlength' => 3,    // At least 3 characters
        'maxlength' => 255   // No more than 255 characters
    )
)

Format Validation

The email rule does two things: a format check, then a fail-open DNS check that the domain can receive mail (an MX record, or an A record as the RFC 5321 fallback). The DNS half is shared with LibraryFunctions::IsValidEmail() via DnsResolver::domainAcceptsMail() — see DNS Lookups below. A transient DNS failure never rejects an address; only a domain that definitively has neither MX nor A is failed.

MX Check Toggle (email_validation_mx_check)

The DNS step is controlled by the email_validation_mx_check site setting (Email Settings tab in admin):

  • "1" (default) — syntax + MX check. Current behavior; no change for existing deployments.
  • "0" — syntax only. The DNS round-trip is skipped entirely. Use this for bulk imports, internal/test domains (e.g. example.test), split-horizon DNS environments, or any context where latency matters more than deliverability gating.
Both validation paths honor the toggle: SystemBase::validateField() (model saves) and LibraryFunctions::IsValidEmail() (standalone callers). Any future call site that calls DnsResolver::domainAcceptsMail() directly must add the same inline check so the toggle is always honored:

$settings = Globalvars::get_instance();
if ((string)$settings->get_setting('email_validation_mx_check') !== '0') {
    // DnsResolver::domainAcceptsMail($domain) call here
}

'email' => array(
    'type' => 'varchar(255)',
    'validation' => array(
        'email' => true,  // Must be valid email format
    )
)

'website' => array(
    'type' => 'varchar(255)',
    'validation' => array(
        'url' => true,  // Must be valid URL
    )
)

'age' => array(
    'type' => 'integer',
    'validation' => array(
        'numeric' => true,  // Must be numeric
    )
)

Pattern Matching

'username' => array(
    'type' => 'varchar(64)',
    'validation' => array(
        'pattern' => '/^[a-zA-Z0-9_]{3,64}$/',  // Alphanumeric + underscore only
    )
)

'phone' => array(
    'type' => 'varchar(20)',
    'validation' => array(
        'pattern' => '/^\d{3}-\d{3}-\d{4}$/',  // XXX-XXX-XXXX format
    )
)

Numeric Range

'age' => array(
    'type' => 'integer',
    'validation' => array(
        'min' => 0,
        'max' => 150
    )
)

'quantity' => array(
    'type' => 'integer',
    'validation' => array(
        'min' => 1,  // Must be at least 1
    )
)

Custom Error Messages

'field_name' => array(
    'type' => 'varchar(255)',
    'validation' => array(
        'required' => true,
        'minlength' => 3,
        'messages' => array(
            'required' => 'This field cannot be empty',
            'minlength' => 'Must be at least 3 characters long'
        )
    )
)


6. Common Validation Patterns

Email Signup Form

FormWriter V2 (preferred):

$formwriter->textinput('email', 'Email', ['validation' => 'email', 'required' => true]);
$formwriter->passwordinput('password', 'Password', ['required' => true, 'validation' => ['minlength' => 8]]);
$formwriter->passwordinput('password_confirm', 'Confirm Password', [
    'required' => true,
    'validation' => ['matches' => 'password']  // Field name, not selector
]);

Product Creation Form

$formwriter->textinput('product_name', 'Product Name', [
    'required' => true,
    'validation' => ['minlength' => 3, 'maxlength' => 255]
]);
$formwriter->textinput('price', 'Price', [
    'required' => true,
    'validation' => ['numeric' => true, 'min' => 0.01]
]);
$formwriter->textinput('sku', 'SKU', [
    'required' => true,
    'validation' => ['pattern' => '/^[A-Z0-9\-]+$/']
]);

Contact Form with Optional Fields

// Name is required
$formwriter->textinput('name', 'Name', ['required' => true]);

// Email is required and must be valid
$formwriter->textinput('email', 'Email', ['required' => true, 'validation' => ['email' => true]]);

// Message is required with minimum length
$formwriter->textbox('message', 'Message', ['required' => true, 'validation' => ['minlength' => 10]]);

// Phone is optional but if provided must match the pattern
$formwriter->textinput('phone', 'Phone', ['validation' => ['pattern' => '/^[\d\-\(\)\s]+$/']]);


7. Troubleshooting

Validation not triggering

Problem: Form submits without validation

  • Check: Is end_form() being called? (it emits the validation script)
  • Check: Is form ID correct in JoineryValidation.init()?
  • Check: Is joinery-validate.js loaded?
  • Check: Are rules defined correctly (required vs optional)?

Delete / secondary button blocked by validation

Problem: On a form with required fields, a Delete or Cancel button won't submit (the validator flags the empty required fields instead of running the action).

  • Cause: The client validator runs on every submit button by default.
  • Solution: Mark that button to bypass client validation: $formwriter->submitbutton('btn_delete', 'Delete', ['formnovalidate' => true]);
  • Note: This skips only the client check — server-side validation still applies, so the server action behind the button must not assume a valid model. See the "Multi-Action Forms" section in FormWriter for the full pattern (and how the clicked button's name is preserved through submission).

Server-side validation not catching errors

Problem: Invalid data saved to database

  • Check: Is prepare() called before save()?
  • Check: Are field_specifications validation rules defined?
  • Check: Is exception being caught and handled?

JavaScript validation rules not matching server rules

Problem: Form passes JavaScript but fails server validation

  • Solution: Mirror JavaScript rules in field_specifications
  • Solution: Use FormWriter v2 to auto-generate from model

"equalTo" not working

Problem: Password confirm field doesn't validate against password

  • Check: Use the matches key with a field name (not a CSS selector): 'matches' => 'password'
  • Check: The target field must have a matching name attribute in the form

Pattern validation failing

Problem: Pattern regex not matching valid input

  • Check: Regex must be valid JavaScript (not PHP) syntax
  • Check: Include delimiters: "/^[a-z]+$/" not "^[a-z]+$"
  • Check: Escape special characters: "\\d" not "\d"

Custom messages not showing

Problem: Default error messages showing instead of custom ones

  • Check: Message is properly quoted: '"Custom message"' (double then single quotes)
  • Check: Message defined in correct rule
  • Check: Check console for JavaScript errors

8. Performance Considerations

Minimize Validation Rules

  • Only validate what needs validation
  • Don't validate computed fields
  • Avoid complex regex patterns

Unique Constraints

  • Unique checks query the database
  • Use remote AJAX for real-time checks
  • Don't validate uniqueness on every change

Server-side Validation

  • Validate once in prepare()
  • Don't repeat validation in custom methods
  • Cache validation results when possible

9. Security Notes

⚠️ IMPORTANT: Never trust client-side validation alone!

  • JavaScript validation can be bypassed
  • Always validate on server (in prepare())
  • Always use prepared statements (model classes do this)
  • Sanitize output in views with htmlspecialchars()
  • Never display database errors to users
// ✅ CORRECT - Validate on server
try {
    $user->prepare();  // Server validation
    $user->save();
} catch (DisplayableUserException $e) {
    echo htmlspecialchars($e->getMessage());  // Safe error message
}

// ❌ WRONG - Only JavaScript validation
// User can disable JavaScript and bypass validation


DNS Lookups (DnsResolver)

includes/DnsResolver.php is the single place the platform performs raw DNS lookups. Any code that needs DNS — email-domain validation, SPF/DKIM/DMARC checks, SSRF guards, provisioning checks — should go through it rather than calling dns_get_record() / gethostby*() directly.

It is a static, policy-free class. Each method returns a normalized shape and distinguishes "no such record" from "the lookup failed":

MethodReturns
getMx($domain)[['host'=>…,'pri'=>…], …], lowest priority first
getA($host) / getAaaa($host)array of IP strings
getTxt($name)array of TXT strings
getCname($name)the target string, or null
resolveHostIps($host)every A and AAAA address, de-duplicated
domainAcceptsMail($domain)bool — MX, or A as RFC 5321 fallback
Error vs. empty. A genuine "no record" returns an empty array. A
resolver* failure (timeout, SERVFAIL, network error) throws DnsLookupException. This is deliberate: the class takes no fail-open / fail-closed stance — each caller catches DnsLookupException and applies its own policy. Validation, email-auth and provisioning checks catch it and fail open; SSRF guards catch it and fail closed.

Testability. DnsResolver::setBackend($double) swaps the raw-DNS layer for a test double (clearBackend() restores it in teardown). The seam sits at the bottom of the stack, so one setBackend() call also makes every consumer — including DnsAuthChecker — testable. See tests/unit/dns_resolver_test.php and Testing.

// Production code just calls it — no setup needed:
if (!DnsResolver::domainAcceptsMail($domain)) { /* reject */ }

// Tests inject canned records:
DnsResolver::setBackend($fakeBackend);   // $fakeBackend->getRecords($name, $type)
// … exercise code that resolves DNS …
DnsResolver::clearBackend();


Related Documentation