The FormWriter system provides a structured, consistent way to build forms in the Joinery platform. It handles HTML generation, validation integration, CSRF protection, and field visibility logic.
FormWriter is a PHP class system that generates HTML forms with:
FormWriterV2Bootstrap - Bootstrap 4/5 themed implementationFormWriterV2Tailwind - Tailwind CSS themed implementationFormWriterV2HTML5 - Pure HTML5 with semantic markup (no CSS framework dependencies)FormWriterV2Base - Abstract base with all core functionalityIn a view file with PublicPage or AdminPage:
// Get FormWriter instance (automatically selects correct theme)
$formwriter = $page->getFormWriter('contact_form', 'v2');
// Start the form
$formwriter->begin_form();
// Add fields with clean options array
$formwriter->textinput('name', 'Your Name', ['required' => true]);
$formwriter->textinput('email', 'Email Address', [
'validation' => 'email',
'required' => true,
'placeholder' => '[email protected]'
]);
$formwriter->textarea('message', 'Message', [
'rows' => 5,
'required' => true
]);
// Submit button
$formwriter->submitbutton('submit', 'Send Message');
// End the form
$formwriter->end_form();In logic files or other contexts:
// Bootstrap theme
require_once(PathHelper::getIncludePath('includes/FormWriterV2Bootstrap.php'));
$formwriter = new FormWriterV2Bootstrap('my_form');
// Tailwind theme
require_once(PathHelper::getIncludePath('includes/FormWriterV2Tailwind.php'));
$formwriter = new FormWriterV2Tailwind('my_form');
// HTML5 (framework-agnostic)
require_once(PathHelper::getIncludePath('includes/FormWriterV2HTML5.php'));
$formwriter = new FormWriterV2HTML5('my_form');
$formwriter->begin_form();
// ... add fields ...
$formwriter->end_form();// Pass options to constructor
$formwriter = new FormWriterV2Bootstrap('my_form', [
'action' => '/process',
'method' => 'POST',
'enctype' => 'multipart/form-data', // For file uploads
'class' => 'custom-form'
]);FormWriter supports automatic value population:
// Load model data
$user = new User($user_id, TRUE);
// Pass model directly - all fields auto-fill!
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $user
]);
$formwriter->begin_form();
// No need to specify 'value' - auto-filled from model!
$formwriter->textinput('usr_email', 'Email');
$formwriter->textinput('usr_first_name', 'First Name');
$formwriter->textinput('usr_last_name', 'Last Name');
$formwriter->end_form();With value overrides:
// Pass both model AND specific value overrides
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $user,
'values' => [
'usr_email' => '[email protected]' // This overrides model value
]
]);When editing existing records, use edit_primary_key_value to pass the record's primary key:
// View file - editing an existing event
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $event,
'edit_primary_key_value' => $event->key
]);
$formwriter->begin_form();
$formwriter->textinput('evt_name', 'Event Name');
// ... other fields ...
$formwriter->submitbutton('btn_submit', 'Save');
$formwriter->end_form();What FormWriter outputs:
When edit_primary_key_value is provided, begin_form() automatically outputs a hidden field:
<input type="hidden" name="edit_primary_key_value" value="123">CRITICAL: Logic file must check for this field
The hidden field is named edit_primary_key_value (not the model's column name like evt_event_id). Your logic file must check for this field when loading records:
// Logic file - CORRECT pattern
function admin_event_edit_logic($get_vars, $post_vars) {
// CRITICAL: Check edit_primary_key_value (form submission) first, fallback to GET
if (isset($post_vars['edit_primary_key_value'])) {
$event = new Event($post_vars['edit_primary_key_value'], TRUE);
} elseif (isset($get_vars['evt_event_id'])) {
$event = new Event($get_vars['evt_event_id'], TRUE);
} else {
$event = new Event(NULL);
}
// Process form submission
if ($post_vars) {
$event->set('evt_name', $post_vars['evt_name']);
// ... set other fields ...
$event->prepare();
$event->save();
return LogicResult::redirect('/admin/admin_event?evt_event_id=' . $event->key);
}
return LogicResult::render(['event' => $event]);
}Why this pattern matters:
?evt_event_id=123)edit_primary_key_value// ❌ WRONG - Will create new records on form submission!
if (isset($get_vars['evt_event_id'])) {
$event = new Event($get_vars['evt_event_id'], TRUE);
} else {
$event = new Event(NULL); // Form submission hits this branch!
}
// ✅ CORRECT - Check edit_primary_key_value first
if (isset($post_vars['edit_primary_key_value'])) {
$event = new Event($post_vars['edit_primary_key_value'], TRUE);
} elseif (isset($get_vars['evt_event_id'])) {
$event = new Event($get_vars['evt_event_id'], TRUE);
} else {
$event = new Event(NULL);
}FormWriter automatically detects and applies validation rules from model field_specifications:
// In /data/user_class.php
public static $field_specifications = array(
'usr_email' => array(
'type' => 'varchar(255)',
'required' => true,
'unique' => true,
'validation' => array('email' => true)
)
);
// In your form - validation is automatic!
$formwriter->textinput('usr_email', 'Email');
// ↑ Automatically validates as required email from User::$field_specificationsHow it works:
usr_ from usr_email)usr → User)User::$field_specifications// Basic text input
$formwriter->textinput('username', 'Username');
// With validation and placeholder
$formwriter->textinput('email', 'Email', [
'validation' => 'email',
'required' => true,
'placeholder' => '[email protected]',
'helptext' => 'We will never share your email'
]);
// Read-only or disabled
$formwriter->textinput('user_id', 'User ID', [
'value' => '12345',
'readonly' => true
]);
// With prepend text (Bootstrap)
$formwriter->textinput('loc_link', 'Link', [
'prepend' => $settings->get_setting('webDir').'/location/'
]);
// Shows as: [/location/][user types here]// With strength meter
$formwriter->passwordinput('password', 'Password', [
'show_strength' => true,
'required' => true,
'validation' => ['minlength' => 8]
]);
// Confirm password
$formwriter->passwordinput('password_confirm', 'Confirm Password', [
'validation' => ['equalTo' => 'password']
]);// Standard dropdown
$formwriter->dropinput('country', 'Country', [
'options' => [
'us' => 'United States',
'ca' => 'Canada',
'uk' => 'United Kingdom'
],
'value' => 'us', // Default selected
'empty_option' => '-- Select Country --',
'required' => true
]);Note: The dropdown options format is: 'actual_value' => 'Display Text' (value => label)
$formwriter->textarea('description', 'Description', [
'rows' => 5,
'cols' => 80,
'placeholder' => 'Enter detailed description',
'validation' => ['minlength' => 10, 'maxlength' => 500]
]);$formwriter->checkboxinput('accept_terms', 'I accept the terms and conditions', [
'required' => true,
'helptext' => 'You must accept to continue'
]);$formwriter->radioinput('subscription', 'Subscription Plan', [
'options' => [
'free' => 'Free',
'basic' => 'Basic ($9.99/mo)',
'premium' => 'Premium ($19.99/mo)'
],
'value' => 'free' // Default selected
]);Multiple checkboxes that submit as an array:
$formwriter->checkboxList('newsletter_subscriptions', 'Select Newsletters:', [
'options' => [
1 => 'Weekly Updates',
2 => 'Monthly Digest',
3 => 'Special Announcements'
],
'checked' => [1, 3], // Pre-select these options
'disabled' => [], // Disable specific options
'readonly' => [2] // Read-only (disabled visually, submitted via hidden input)
]);Option Keys:
options (required) - Associative array of value => label pairschecked - Array of values that should be checked initiallydisabled - Array of values to disable (user cannot interact)readonly - Array of values that are read-only (disabled visually, but submitted via hidden input)POST data: newsletter_subscriptions[] = [1, 3]In PHP, access via:
$_POST['newsletter_subscriptions'] // Array of checked values// Date input
$formwriter->dateinput('start_date', 'Start Date', [
'min' => '2025-01-01',
'max' => '2025-12-31',
'required' => true
]);
// Time input (uses hour/minute/AM-PM dropdowns)
$formwriter->timeinput('meeting_time', 'Meeting Time', [
'required' => true,
'helptext' => 'Select preferred meeting time'
]);
// DateTime input (combines date picker with time dropdowns)
$formwriter->datetimeinput('deadline', 'Deadline', [
'required' => true
]);The datetimeinput() method accepts DateTime values in multiple formats:
Accepted input formats:
'2024-09-09 18:02:00' - MySQL DATETIME
- '2024-09-09T18:02:00+00:00' - ISO 8601
- 'September 9, 2024 6:02pm' - Human readable// Load model with datetime fields
$coupon = new CouponCode($coupon_id, TRUE);
// Pass to FormWriter - handles DateTime objects automatically
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $coupon // DateTime objects in export_as_array() are auto-converted
]);
$formwriter->begin_form();
// Automatically converts DateTime to user's timezone and populates fields
$formwriter->datetimeinput('ccd_start_time', 'Start time');
$formwriter->datetimeinput('ccd_end_time', 'End time');
$formwriter->end_form();How it works:
Y-m-d for the date pickerH:i (24-hour) for conversion to 12-hour dropdownsUse the static helper method to process datetime submissions:
// In logic file
require_once(PathHelper::getIncludePath('includes/FormWriterV2Base.php'));
// Process datetime - automatically converts from user's timezone to UTC
$start_time = FormWriterV2Base::process_datetimeinput($_POST, 'ccd_start_time', true);
if($start_time !== NULL){
$model->set('ccd_start_time', $start_time);
}
// Or get local time without UTC conversion
$local_time = FormWriterV2Base::process_datetimeinput($_POST, 'meeting_time', false);FormWriterV2Base::process_datetimeinput() Parameters:
$post_vars - The $_POST array$field_name - Base field name (e.g., 'ccd_start_time')$to_utc - Convert to UTC timezone (default: true)$to_utc is true (e.g., '2024-09-09T18:02:00+00:00')$to_utc is false (e.g., '2024-09-09 18:02:00')NULL if required fields not present in POST data// admin_event_edit.php (view)
$event = new Event($event_id, TRUE);
$form_values = $event->export_as_array();
// Convert UTC times to user's local timezone for display
if($event->key){
if($form_values['evt_start_time']){
$form_values['evt_start_time'] = LibraryFunctions::convert_time(
$form_values['evt_start_time'],
'UTC',
$session->get_timezone(),
'Y-m-d H:i:s'
);
}
}
$formwriter = $page->getFormWriter('form1', 'v2', ['values' => $form_values]);
$formwriter->begin_form();
$formwriter->datetimeinput('evt_start_time', 'Event Start Time');
$formwriter->end_form();
// admin_event_edit_logic.php (processing)
if($_POST){
// Process datetime from user's timezone to UTC for storage
$start_time = FormWriterV2Base::process_datetimeinput($_POST, 'evt_start_time', true);
if($start_time !== NULL){
$event->set('evt_start_time', $start_time);
}
$event->save();
}$formwriter->fileinput('document', 'Upload Document', [
'accept' => '.pdf,.doc,.docx',
'helptext' => 'PDF or Word documents only'
]);
// Important: Form must have enctype
$formwriter = new FormWriterV2Bootstrap('upload_form', [
'enctype' => 'multipart/form-data'
]);$formwriter->hiddeninput('user_id', '', ['value' => $user_id]);Important: Always use the three-argument form with an empty string as the second parameter (label), even though labels are ignored for hidden fields. This maintains consistency with other FormWriter methods:
// CORRECT - use three arguments
$formwriter->hiddeninput('field_name', '', ['value' => $value]);
// AVOID - two arguments (works due to backwards compatibility, but not recommended)
$formwriter->hiddeninput('field_name', ['value' => $value]);Warning — duplicate IDs when multiple forms share a field name: FormWriter generates an id attribute for every field using the field name as the default. When two or more FormWriter forms are rendered on the same page and both declare a field with the same name (most commonly hiddeninput('action', ...)), the page ends up with duplicate id attributes, which is invalid HTML.
Fix: pass an explicit 'id' option to any shared-name hidden inputs so each gets a unique ID:
// ✅ CORRECT — unique IDs across forms on the same page
$form_a->hiddeninput('action', '', ['value' => 'save', 'id' => 'save_action']);
$form_b->hiddeninput('action', '', ['value' => 'delete', 'id' => 'delete_action']);
// ❌ PROBLEM — both produce id="action" in the DOM
$form_a->hiddeninput('action', '', ['value' => 'save']);
$form_b->hiddeninput('action', '', ['value' => 'delete']);Repeater fields allow users to add multiple sets of related fields dynamically. Used primarily by the Page Component System for configurable content blocks.
// Basic repeater with subfields
$formwriter->repeater('features', 'Features List', [
'value' => [
['title' => 'Feature 1', 'description' => 'First feature'],
['title' => 'Feature 2', 'description' => 'Second feature']
],
'fields' => [
['name' => 'title', 'label' => 'Title', 'type' => 'textinput'],
['name' => 'description', 'label' => 'Description', 'type' => 'textarea']
],
'add_label' => '+ Add Feature',
'helptext' => 'Add as many features as needed'
]);Options:
value - Array of existing data rows (each row is an associative array)fields - Array of subfield definitions with name, label, and typeadd_label - Button text for adding rows (default: '+ Add Item')helptext - Help text displayed below the labeltextinput, textarea, dropinput, checkboxinput, etc.// Repeater with dropdown subfield
$formwriter->repeater('links', 'Navigation Links', [
'fields' => [
['name' => 'label', 'label' => 'Link Text', 'type' => 'textinput'],
['name' => 'url', 'label' => 'URL', 'type' => 'textinput'],
[
'name' => 'target',
'label' => 'Open In',
'type' => 'dropinput',
'options' => ['_self' => 'Same Window', '_blank' => 'New Window']
]
]
]);Processing Repeater Data:
Use the static helper method to process repeater submissions:
// In logic file or form processing
require_once(PathHelper::getIncludePath('includes/FormWriterV2Base.php'));
if ($_POST) {
// Process repeater data - cleans up array structure
$features = FormWriterV2Base::process_repeater_data($_POST['features']);
// $features is now a clean indexed array:
// [
// ['title' => 'Feature 1', 'description' => 'First feature'],
// ['title' => 'Feature 2', 'description' => 'Second feature']
// ]
$model->set('config', json_encode(['features' => $features]));
}JavaScript: Repeater JavaScript is automatically included when you use a repeater field. It handles:
Model Form Helpers are static methods in data model classes that render complete form field sets using FormWriter. They encapsulate field definitions, validation rules, and configuration within the model itself, following the DRY principle while maintaining MVC separation.
Models with form helpers provide static methods like renderFormFields():
Address Form Example:
// In admin page, profile page, or any form
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $address,
'edit_primary_key_value' => $address->key
]);
$formwriter->begin_form();
// Single method call renders: country, address1, address2, city, state, zip
Address::renderFormFields($formwriter, [
'required' => true,
'include_country' => true,
'include_user_id' => false,
'model' => $address
]);
$formwriter->submitbutton('btn_submit', 'Submit');
$formwriter->end_form();PhoneNumber Form Example:
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $phone_number,
'edit_primary_key_value' => $phone_number->key
]);
$formwriter->begin_form();
// Single method call renders: country code, phone number
PhoneNumber::renderFormFields($formwriter, [
'required' => true,
'include_user_id' => false,
'model' => $phone_number
]);
$formwriter->submitbutton('btn_submit', 'Submit');
$formwriter->end_form();Address::renderFormFields()
Address::renderFormFields($formwriter, [
'required' => true, // Make all fields required (default: true)
'include_country' => true, // Show country dropdown (default: true)
'include_user_id' => false, // Add hidden user_id field (default: false)
'user_id' => $user->key, // User ID value if include_user_id is true
'model' => $address // Address object for prepopulation (default: null)
]);Renders fields:
PhoneNumber::renderFormFields($formwriter, [
'required' => true, // Make all fields required (default: true)
'include_user_id' => false, // Add hidden user_id field (default: false)
'user_id' => $user->key, // User ID value if include_user_id is true
'model' => $phone_number // PhoneNumber object for prepopulation (default: null)
]);Renders fields:
Admin Page (Edit Mode):
$address = new Address($address_id, TRUE);
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $address,
'edit_primary_key_value' => $address->key
]);
$formwriter->begin_form();
Address::renderFormFields($formwriter, [
'required' => true,
'include_country' => true,
'include_user_id' => true,
'user_id' => $user_id,
'model' => $address
]);
$formwriter->submitbutton('btn_submit', 'Submit');
$formwriter->end_form();Profile Page (Optional Fields):
if(!Address::GetDefaultAddressForUser($user_id)) {
$user_address = $user->address();
Address::renderFormFields($formwriter, [
'required' => true,
'include_country' => true,
'include_user_id' => false,
'model' => $user_address
]);
}Product Registration (Create New):
PhoneNumber::renderFormFields($formwriter, [
'required' => true,
'include_user_id' => false,
'model' => NULL // No prepopulation for new records
]);Using Model Form Helpers significantly reduces code and improves maintainability:
Manual field definitions:
// Manually defining multiple address fields requires ~33 lines
$country_codes = Address::get_country_drop_array2();
$formwriter->dropinput('usa_cco_country_code_id', 'Country', [
'options' => $country_codes
]);
$formwriter->textinput('usa_address1', 'Street Address', [
'maxlength' => 255,
'validation' => ['required' => true]
]);
$formwriter->textinput('usa_address2', 'Apt, Suite, etc. (optional)', [
'maxlength' => 255
]);
// ... 8 more fields ...Using Model Form Helper:
// Single method call - 6 lines total
Address::renderFormFields($formwriter, [
'required' => true,
'include_country' => true,
'include_user_id' => false,
'model' => $address
]);Model Form Helpers follow these principles:
$options parameterrenderFormFields() method nameStore form field HTML instead of echoing immediately. Essential for multiple forms in loops.
Use deferred output: Multiple forms in loops (inline action forms in listing pages) Use immediate output (default): Single forms in views
// Enable deferred mode
$form = $page->getFormWriter('form_' . $item->id, 'v2', [
'deferred_output' => true,
'action' => '/admin/process?id=' . $item->id
]);
// Add fields (stored, not echoed)
$form->hiddeninput('action', '', ['value' => 'delete']);
$form->submitbutton('btn_delete', 'Delete');
// Get HTML as string
$html = $form->getFieldsHTML();foreach ($items as $item) {
$row = [];
// ... add columns ...
$form = $page->getFormWriter('delete_' . $item->id, 'v2', [
'deferred_output' => true,
'action' => '/admin/process'
]);
$form->hiddeninput('item_id', '', ['value' => $item->id]);
$form->submitbutton('btn_delete', 'Delete');
$row['action'] = $form->getFieldsHTML();
array_push($rowvalues, $row);
}Works with all field types, validation, visibility rules, custom scripts, and all theme implementations (Bootstrap, Tailwind, HTML5).
Feature: FormWriter supports dynamic field visibility with smooth fade transitions and custom JavaScript logic.
For simple show/hide based on select field values, define rules and FormWriter generates JavaScript automatically:
// Example: Show different fields based on question type
$formwriter->dropinput('question_type', 'Question Type', [
'options' => [
'text' => 'Text Answer',
'multiple_choice' => 'Multiple Choice',
'rating' => 'Rating Scale'
],
'visibility_rules' => [
'text' => [
'show' => ['text_options', 'char_limit'],
'hide' => ['choices_list', 'rating_scale']
],
'multiple_choice' => [
'show' => ['choices_list'],
'hide' => ['text_options', 'char_limit', 'rating_scale']
],
'rating' => [
'show' => ['rating_scale'],
'hide' => ['text_options', 'char_limit', 'choices_list']
]
]
]);
// Create the target fields (using their field IDs only)
$formwriter->textinput('text_options', 'Text Options');
$formwriter->textinput('char_limit', 'Character Limit');
$formwriter->textarea('choices_list', 'Multiple Choice Options');
$formwriter->dropinput('rating_scale', 'Rating Scale', [
'options' => ['1-5' => '1-5 Stars', '1-10' => '1-10 Scale']
]);Notes:
field_id_container if it existsfield_id_container elements first. This is the standard FormWriter pattern where fields are wrapped in container divs.For custom logic on a specific field, provide the event handler body - FormWriter wraps it with addEventListener:
// Example: Update price based on size selection
$formwriter->dropinput('product_size', 'Size', [
'options' => ['small' => 'Small', 'medium' => 'Medium', 'large' => 'Large'],
'custom_script' => '
const size = this.value;
const priceField = document.getElementById("price");
const bulkWarning = document.getElementById("bulk_warning");
if (size === "small") {
priceField.value = "9.99";
if (bulkWarning) bulkWarning.style.display = "none";
} else if (size === "medium") {
priceField.value = "19.99";
if (bulkWarning) bulkWarning.style.display = "none";
} else if (size === "large") {
priceField.value = "29.99";
if (bulkWarning) bulkWarning.style.display = "";
}
'
]);
$formwriter->textinput('price', 'Price', ['readonly' => true]);
$formwriter->textinput('bulk_warning', 'Bulk orders require manager approval', [
'readonly' => true
]);Notes:
this refers to the select elementDOMContentLoaded automaticallychange event attached automaticallyFor cross-field logic, add raw JavaScript to run when the form loads:
// Example: Country selection changes field labels and visibility
$formwriter->addReadyScript('
const countryField = document.getElementById("country");
if (countryField) {
countryField.addEventListener("change", function() {
const country = this.value;
// Use field IDs only - container detection is automatic!
const stateContainer = document.getElementById("state_container");
const zipContainer = document.getElementById("zip_container");
const customContainer = document.getElementById("custom_location_container");
// Get input elements for setting placeholders
const stateField = document.getElementById("state");
const zipField = document.getElementById("zip");
if (country === "us") {
stateContainer.style.display = "";
zipContainer.style.display = "";
customContainer.style.display = "none";
if (stateField) stateField.placeholder = "State";
if (zipField) zipField.placeholder = "ZIP Code (5 digits)";
} else if (country === "ca") {
stateContainer.style.display = "";
zipContainer.style.display = "";
customContainer.style.display = "none";
if (stateField) stateField.placeholder = "Province";
if (zipField) zipField.placeholder = "Postal Code";
} else {
stateContainer.style.display = "none";
zipContainer.style.display = "none";
customContainer.style.display = "";
}
});
// Trigger on load
countryField.dispatchEvent(new Event("change"));
}
');Notes:
DOMContentLoaded automaticallyfield_id_container divsfield_id_container elements rather than field IDs directly. This hides the entire field wrapper (label + input) instead of just the input.All visibility changes include smooth fade transitions:
CSS Classes (automatically injected):
.fw-field-hidden {
opacity: 0 !important;
transition: opacity 0.3s ease-out;
pointer-events: none;
}
.fw-field-visible {
opacity: 1;
transition: opacity 0.3s ease-in;
}FormWriter integrates with the JoineryValidator system for client-side validation and works seamlessly with model-based server-side validation.
User Input → JavaScript Validation → Form Submission
(client-side) (errors blocked)
↓
Server Receives Data
↓
FormWriter Processes
↓
Model->prepare() → Server Validation
↓
Model->save() → DatabaseFormWriter automatically generates validation rules from model field_specifications:
// In /data/user_class.php
public static $field_specifications = array(
'usr_email' => array(
'type' => 'varchar(255)',
'required' => true,
'unique' => true,
'validation' => array(
'email' => true,
'minlength' => 5,
'maxlength' => 255
)
)
);
// In your form - NO validation setup needed!
$formwriter = $page->getFormWriter('user_form', 'v2');
$formwriter->begin_form();
// Validation is AUTOMATIC from model specs!
$formwriter->textinput('usr_email', 'Email');
// ↑ Automatically validates as required, unique, email
$formwriter->end_form();For fields without model specs, add validation manually:
$formwriter->textinput('custom_field', 'Custom Field', [
'validation' => [
'required' => true,
'minlength' => 5,
'maxlength' => 100
]
]);
// Or use shorthand for common types
$formwriter->textinput('email', 'Email', [
'validation' => 'email', // Shorthand
'required' => true
]);| PHP Rule Key | JS Rule | Usage | Example |
|---|---|---|---|
required | required | Field must have value | 'required' => true |
email | email | Valid email format | 'validation' => 'email' |
url | url | Valid URL format | 'validation' => 'url' |
phone | phone | Valid phone number | 'validation' => 'phone' |
number | number | Numeric value only | 'validation' => 'number' |
minlength | minlength | Min character length | 'minlength' => 8 |
maxlength | maxlength | Max character length | 'maxlength' => 255 |
min | min | Min numeric value | 'min' => 0 |
max | max | Max numeric value | 'max' => 100 |
matches | equalTo | Must match another field | 'matches' => 'password' |
pattern | pattern | Regex match | 'pattern' => '/^[A-Z0-9]+$/' |
matches rule value is a field name (e.g., 'password'), not a CSS selector. FormWriter outputs it as equalTo in JavaScript, where form.elements[name] looks up the target field.Add a messages sub-array alongside your validation rules:
$formwriter->textinput('antispam_question', 'Verification', [
'required' => true,
'validation' => [
'required' => true,
'matches' => 'antispam_question_answer',
'messages' => [
'required' => 'This field is required.',
'matches' => 'You must type the correct word here',
],
],
]);Message keys correspond to the PHP rule keys (e.g., 'matches' not 'equalTo'). FormWriter maps them to the correct JS rule names automatically.
Email Signup Form:
$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']
]);Product Form with Price:
$formwriter->textinput('product_name', 'Product Name', [
'required' => true,
'validation' => ['minlength' => 3]
]);
$formwriter->textinput('price', 'Price', [
'required' => true,
'validation' => [
'number' => true,
'min' => 0.01
]
]);
$formwriter->textinput('sku', 'SKU', [
'required' => true,
'validation' => ['pattern' => '/^[A-Z0-9\-]+$/']
]);FormWriter provides three built-in methods for protecting public forms from bots. These are typically used together on forms accessible to non-logged-in users.
if (!$is_logged_in) {
$formwriter->antispam_question_input(); // Human verification question
$formwriter->honeypot_hidden_input(); // Hidden field trap for bots
$formwriter->captcha_hidden_input(); // CAPTCHA integration
}antispam_question_input($type) — Renders a text field asking the user to type a specific word (configured in Settings as anti_spam_answer). Automatically registers required and matches validation rules so end_form() outputs the JS validation. Pass 'blog' for comment forms (uses anti_spam_answer_comments setting).
honeypot_hidden_input() — Renders a hidden field that bots tend to fill in. Server-side logic rejects submissions where this field has a value.
captcha_hidden_input() — Renders CAPTCHA integration if configured in settings.
Skip for logged-in users: These protections are unnecessary for authenticated users — wrap them in a !$is_logged_in check.
Always validate on the server - never trust client-side validation alone!
// In logic file
require_once(PathHelper::getIncludePath('data/user_class.php'));
$user = new User(NULL);
$user->set('usr_email', $_POST['email']);
$user->set('usr_username', $_POST['username']);
$user->set('usr_password', $_POST['password']);
try {
// Server-side validation from field_specifications
$user->prepare();
// Save to database
$user->save();
return LogicResult::success(['message' => 'User created successfully']);
} catch (DisplayableUserException $e) {
// User-friendly error message
return LogicResult::error($e->getMessage());
} catch (SystemBaseException $e) {
// System error - log it
error_log($e->getMessage());
return LogicResult::error('An error occurred while processing your request');
}For complete validation system documentation, see validation.md
htmlspecialchars() // In logic file
$result = profile_logic($_GET, $_POST);
// Logic handles validation via model->prepare()FormWriter passes every label and every select/radio option value through htmlspecialchars(). HTML tags inside labels — <strong>, <em>, <code>, <span>, etc. — will render as literal escaped text, not as markup. Use plain text only in labels and option arrays.
// ✅ CORRECT — plain text
$formwriter->radioinput('install_mode', 'Install Type', [
'options' => [
'fresh' => 'Fresh install — empty site with default schema',
'from_backup' => 'Install from backup — clone an existing node',
]
]);
// ❌ WRONG — renders as "<strong>Fresh install</strong> — ..."
$formwriter->radioinput('install_mode', 'Install Type', [
'options' => [
'fresh' => '<strong>Fresh install</strong> — empty site',
]
]);To visually group related fields inside a long form, output a <label> element as a section heading. Do not use <p> tags (wrong spacing in Bootstrap form context) or <h*> tags (wrong semantic weight).
echo '<label class="form-label fw-semibold d-block mt-4">Server Settings</label>';
$formwriter->textinput('mgn_hostname', 'Hostname');
$formwriter->numberinput('mgn_port', 'Port');
echo '<label class="form-label fw-semibold d-block mt-4">Credentials</label>';
$formwriter->textinput('mgn_user', 'Username');
$formwriter->passwordinput('mgn_pass', 'Password');The first section heading omits mt-4 if it appears at the very top of the form (no preceding fields to separate from).
// Show shipping fields for physical products only
'visibility_rules' => [
'physical' => ['show' => ['weight', 'dimensions']],
'digital' => ['hide' => ['weight', 'dimensions']]
]usr_email, pro_name
- Use underscores not hyphens: first_name not first-name'user_email') - container detection is automatic
- In form-level scripts: Target field_id_container elements to hide both label and field together
- FormWriter automatically wraps fields in containers, so _container elements always exist
- Example: document.getElementById("user_email_container") hides the field + labelCSRF (Cross-Site Request Forgery) protection is automatic for all POST forms:
// CSRF automatically enabled for POST forms
$formwriter = new FormWriterV2Bootstrap('form', [
'method' => 'POST' // CSRF token auto-generated!
]);
// Server-side validation in logic file
require_once(PathHelper::getIncludePath('includes/FormWriterV2Bootstrap.php'));
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$formwriter = new FormWriterV2Bootstrap('form');
if (!$formwriter->validateCSRF($_POST)) {
return LogicResult::error('Security token expired. Please refresh and try again.');
}
// Continue processing...
}Features:
FormWriter automatically converts UTC DateTime objects to the user's local timezone for display:
// In view - DateTime objects auto-converted to user's timezone!
$formwriter = $page->getFormWriter('form1', 'v2', [
'model' => $event // DateTime fields in model are auto-converted
]);
$formwriter->begin_form();
$formwriter->datetimeinput('evt_start_time', 'Event Start Time');
$formwriter->end_form();How it works:
export_as_array() creates DateTime objects with UTC timezoneY-m-d H:i:s for displayBootstrap theme supports prepending text to input fields:
// Show URL prefix before the input field
$formwriter->textinput('loc_link', 'Link', [
'prepend' => $settings->get_setting('webDir').'/location/'
]);
// Shows as: [/location/][user types here]
// Currency prefix
$formwriter->textinput('price', 'Price', [
'prepend' => '$'
]);
// Shows as: [$][user types here]Enable console logging during development:
$formwriter = $page->getFormWriter('form1', 'v2', [
'debug' => true // Logs validation detection to console
]);Console output:
=== FormWriterV2 DEBUG ===
Form ID: form1
🔍 Automatic Model Validation Detected:
✓ usr_email → Model: User {required: true, email: true}
✓ usr_username → Model: User {required: true, minlength: 3}
✓ Validation rules appliedFormWriter stores validation errors internally:
// In logic file
if (!$formwriter->validate($_POST)) {
$errors = $formwriter->getErrors();
// Returns:
// [
// 'field_name' => ['Error message 1', 'Error message 2']
// ]
return LogicResult::error('Validation failed', ['errors' => $errors]);
}Methods available:
hasErrors() - Check if any errors existgetErrors() - Get all errorsgetFieldErrors($field) - Get errors for specific fieldsetErrors($errors) - Set errors manuallyaddError($field, $message) - Add single errorclearErrors() - Clear all errorsFormWriter provides:
/utils/forms_example_bootstrapv2.phpThe FormWriter v2 system uses a prepare/render split to ensure behavioral consistency across all themes.
All behavioral logic (value resolution, state determination, option normalization) lives in FormWriterV2Base. Subclasses are responsible only for generating themed HTML.
FormWriterV2Base (concrete output methods)
└── outputCheckboxInput($name, $label, $options)
├── prepareCheckboxData(...) → $data array [ALL logic here]
├── renderCheckboxInput($data) ← abstract, subclass implements
└── handleOutput(...)
FormWriterV2HTML5::renderCheckboxInput($data) ── HTML only
FormWriterV2Bootstrap::renderCheckboxInput($data) ── HTML only
FormWriterV2Tailwind::renderCheckboxInput($data) ── HTML onlyImplement only render*() methods. Never implement output*() methods. The base class handles all data preparation.
class FormWriterV2MyTheme extends FormWriterV2Base {
protected function renderTextInput($data) {
$class = $data['class'] ?: 'my-input-class';
$html = '<div class="my-wrapper">';
$html .= '<label>' . htmlspecialchars($data['label']) . '</label>';
$html .= '<input type="' . htmlspecialchars($data['type']) . '"';
$html .= ' name="' . htmlspecialchars($data['name']) . '"';
$html .= ' value="' . htmlspecialchars($data['value']) . '"';
if ($data['required']) $html .= ' required';
if ($data['disabled']) $html .= ' disabled';
$html .= '>';
$html .= '</div>';
return $html;
}
// ... implement all other render*() methods
}| Method | Key fields in $data |
|---|---|
renderTextInput | name, label, id, value, type, placeholder, class, readonly, disabled, autofocus, required, autocomplete, onchange, pattern, min, max, step, minlength, maxlength, prepend, has_errors, errors, helptext |
renderPasswordInput | Same as textInput + strength_meter |
renderNumberInput | Same as textInput (type='number') |
renderDropInput | name, label, id, value, options_list ([value=>label]), empty_option, class, multiple, disabled, required, onchange, ajaxendpoint, has_errors, errors, helptext, visibility_rules, custom_script |
renderCheckboxInput | name, label, id, checked_value, is_checked, class, disabled, required, onchange, has_errors, errors, helptext, visibility_rules, custom_script |
renderRadioInput | name, label, value, options_list, class, disabled, required, onchange, has_errors, errors, helptext |
renderDateInput | name, label, id, value (YYYY-MM-DD), class, min, max, readonly, disabled, required, onchange, has_errors, errors, helptext |
renderTimeInput | name, label, id, value, hour, minute, ampm, class, readonly, disabled, has_errors, errors, helptext |
renderDateTimeInput | name, label, date_name, time_name, date_value, time_value, hour, minute, ampm, class, readonly, disabled, date_errors, time_errors, helptext |
renderFileInput | name, label, id, class, accept, multiple, disabled, required, onchange, has_errors, errors, helptext |
renderHiddenInput | name, id, value |
renderSubmitButton | name, label, id, class, disabled, onclick |
renderTextarea | name, label, id, value, placeholder, class, rows, cols, readonly, disabled, required, minlength, maxlength, onchange, has_errors, errors, helptext |
renderCheckboxList | name, label, id, options_list, checked (array), disabled (array), readonly (array), type, has_errors, errors, helptext |
renderTextbox | name, label, id, value, class, rows, htmlmode, readonly, disabled, has_errors, errors, helptext |
renderImageInput | name, label, id, value, images, preview_size, class, disabled, has_errors, errors, helptext |
To add a new option (e.g., 'autocapitalize'), change only one place — the prepare*Data() method in FormWriterV2Base. All three themes automatically receive it in $data and can use it in their renderer.
// In FormWriterV2Base::prepareTextData():
'autocapitalize' => $options['autocapitalize'] ?? '',
// In any renderer:
if ($data['autocapitalize']) {
$html .= ' autocapitalize="' . htmlspecialchars($data['autocapitalize']) . '"';
}