The Joinery platform implements a three-layer validation system:
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└─────────────────────────────────────────────────────────────────┘Joinery uses a custom JoineryValidation library - pure JavaScript with no jQuery dependencies (though compatible with jQuery if present).
/assets/js/joinery-validate.js| Validator | Purpose | Notes |
|---|---|---|
required | Field must have a value | Triggers on blur/change |
email | Valid email format | Uses standard email regex |
url | Valid URL format | Uses URL parsing |
number | Numeric value only | Accepts integers and decimals |
minlength | Minimum character length | Value is character count |
maxlength | Maximum character length | Value is character count |
min | Minimum numeric value | Numeric comparison |
max | Maximum numeric value | Numeric comparison |
equalTo | Must match another field | Value = field name (e.g., 'password') |
time | Valid time format HH:MM | 24-hour format |
date | Valid date format | Various formats supported |
pattern | Regex pattern match | Value = regex pattern |
remote | AJAX validation | Server-side unique check |
unique | Unique value in database | Auto-generated from field_specifications |
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: set_validate() was removed in the FormWriterV2 migration. It was a vestigial remnant of the old jQuery Validate integration and is no longer available. Any remaining ->set_validate(...) calls are dead code and should be removed.
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 custom submit logic
console.log('Form is valid, submitting...');
form.submit();
}
});
});
</script>JoineryValidation automatically applies Bootstrap classes:
<!-- Invalid field -->
<input type="email" id="email" class="form-control is-invalid">
<div class="invalid-feedback">
Please provide a valid email address.
</div>
<!-- Valid field -->
<input type="text" id="username" class="form-control is-valid">
<div class="valid-feedback">
Username looks good!
</div>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'))
)For server-side validation like checking username uniqueness, add a remote rule in field_specifications:
'usr_username' => array(
'type' => 'varchar(64)',
'validation' => array(
'remote' => '/ajax/check_username',
'messages' => array('remote' => 'Username is already taken')
)
)The AJAX endpoint receives the field value as value parameter.
Enable console logging during development by passing debug: true to JoineryValidation.init() in the Manual JavaScript Initialization section below.
Model validation happens at three points:
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
)
),
);
}
?>'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 matchCall 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.";
}
?>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.
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'
);
}
}
}FormWriter provides a convenient interface for generating validation rules alongside form HTML, with automatic validation detection from model field_specifications.
$formwriter = new FormWriterV2Bootstrap('contact_form');
// Define validation rules
$validation_rules = array();
$validation_rules['email']['required']['value'] = 'true';
$validation_rules['email']['email']['value'] = 'true';
$validation_rules['password']['minlength']['value'] = '8';
// Output validation script (generates JavaScript automatically)
echo $formwriter->set_validate($validation_rules);
// Build the form with validated fields
$formwriter->begin_form();
$formwriter->textinput('email', 'Email', ['required' => true, 'validation' => 'email']);
$formwriter->passwordinput('password', 'Password', ['validation' => ['minlength' => 8]]);
$formwriter->end_form();FormWriter includes model-aware validation - it can automatically extract validation rules from model field_specifications for seamless integration.
For complete FormWriter validation documentation, including:
Here's a complete end-to-end validation example with all layers:
<?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');
}
}
}
?><?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?msg=saved');
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>';
}
$formwriter = $page->getFormWriter('product_form');
// Define validation rules
$validation_rules = array();
$validation_rules['pro_name']['required']['value'] = 'true';
$validation_rules['pro_name']['minlength']['value'] = '3';
$validation_rules['pro_price']['required']['value'] = 'true';
$validation_rules['pro_price']['number']['value'] = 'true';
$validation_rules['pro_sku']['required']['value'] = 'true';
$validation_rules['pro_sku']['pattern']['value'] = '"/^[A-Z0-9\-]+$/"';
// Output validation script
echo $formwriter->set_validate($validation_rules);
// Output form
echo $formwriter->begin_form('product_form', 'POST', $_SERVER['PHP_SELF'] . '?pro_id=' . $product->key);
echo $formwriter->textinput('Product Name', 'pro_name', 'form-control', 100,
$product->get('pro_name'), 'Enter product name', 255);
echo $formwriter->textbox('Description', 'pro_description', 'form-control', 5, 40,
$product->get('pro_description'), 'Enter product description');
echo $formwriter->textinput('Price', 'pro_price', 'form-control', 10,
$product->get('pro_price'), 'e.g., 19.99', 10);
echo $formwriter->textinput('SKU', 'pro_sku', 'form-control', 20,
$product->get('pro_sku'), 'e.g., PROD-001', 50);
echo $formwriter->start_buttons();
echo $formwriter->new_form_button('Save Product', 'btn btn-primary');
echo $formwriter->end_buttons();
echo $formwriter->end_form();
$page->admin_footer();
?>User enters data → JavaScript validation → Submit form → Server validation → Database save
Model->prepare() called
- Validates all field_specifications rules
- Checks unique constraints
- Custom business logic validationModel->save() → Database
- If invalid: Throws DisplayableUserException
- Error message shown to user
- Form preserved with user's data// 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';// 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
)'field_name' => array(
'type' => 'varchar(255)',
'validation' => array(
'minlength' => 3, // At least 3 characters
'maxlength' => 255 // No more than 255 characters
)
)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.
'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
)
)'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
)
)'age' => array(
'type' => 'integer',
'validation' => array(
'min' => 0,
'max' => 150
)
)
'quantity' => array(
'type' => 'integer',
'validation' => array(
'min' => 1, // Must be at least 1
)
)'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'
)
)
)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
]);Legacy V1 set_validate() (still works):
$rules['email']['required']['value'] = 'true';
$rules['email']['email']['value'] = 'true';
$rules['password']['required']['value'] = 'true';
$rules['password']['minlength']['value'] = '8';
$rules['password_confirm']['required']['value'] = 'true';
$rules['password_confirm']['equalTo']['value'] = '"#password"'; // V1 uses CSS selector$rules['product_name']['required']['value'] = 'true';
$rules['product_name']['minlength']['value'] = '3';
$rules['product_name']['maxlength']['value'] = '255';
$rules['price']['required']['value'] = 'true';
$rules['price']['number']['value'] = 'true';
$rules['price']['min']['value'] = '0.01';
$rules['sku']['required']['value'] = 'true';
$rules['sku']['pattern']['value'] = '"/^[A-Z0-9\-]+$/"';// Name is required
$rules['name']['required']['value'] = 'true';
// Email is required and must be valid
$rules['email']['required']['value'] = 'true';
$rules['email']['email']['value'] = 'true';
// Message is required with minimum length
$rules['message']['required']['value'] = 'true';
$rules['message']['minlength']['value'] = '10';
// Phone is optional but if provided must be valid
$rules['phone']['pattern']['value'] = '"/^[\\d\-\(\)\s]+$/"'; // Digits, dash, parens, spacesProblem: Form submits without validation
set_validate() being called?Problem: Invalid data saved to database
prepare() called before save()?Problem: Form passes JavaScript but fails server validation
Problem: Password confirm field doesn't validate against password
matches key with a field name (not a CSS selector): 'matches' => 'password'name attribute in the formset_validate(), the value was a quoted CSS selector: '"#password"'Problem: Pattern regex not matching valid input
"/^[a-z]+$/" not "^[a-z]+$""\\d" not "\d"Problem: Default error messages showing instead of custom ones
'"Custom message"' (double then single quotes)prepare()⚠️ IMPORTANT: Never trust client-side validation alone!
prepare())htmlspecialchars()// ✅ 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 validationincludes/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":
| Method | Returns |
|---|---|
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 |
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.
// 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();DnsAuthChecker SPF/DKIM/DMARC checks, built on DnsResolver