Skip to content
Viames Marino edited this page Jul 24, 2026 · 10 revisions

Pair framework: Form

Pair\Html\Form is a lightweight but now much more complete form builder that manages a collection of FormControl objects (Text, Select, Checkbox, Textarea, etc.).

It helps with:

  • declaring controls in PHP with fluent APIs
  • applying shared classes to controls, labels and the <form> tag
  • auto-injecting framework classes for supported controls when the application runtime selects a UI framework
  • reading/validating submitted values at control level
  • CSRF token generation and validation
  • binding values from arrays, generic objects and ActiveRecord
  • rendering the outer <form> tag when you want a more self-contained builder

Scope and model

Form still works as a control registry first, so existing view-driven layouts remain valid. When you need it, the class can also render the outer <form> tag through open(), close(), render() and the related print helpers.

Recommended usage stays the same for complex layouts: keep wrappers in the view and let Form print controls, labels and tokens.

Minimal pattern:

$form = new \Pair\Html\Form();

// Declares one control and its validation rules.
$form->text('email')->required();
<form method="post" action="">
  <?php // Prints the CSRF hidden field backed by $_SESSION['csrf_token']. ?>
  <?php $form->printToken(); ?>

  <?php // Prints the <label> element configured on the control. ?>
  <?php $form->printLabel('email'); ?>

  <?php // Prints the actual input HTML. ?>
  <?php $form->printControl('email'); ?>
  <button type="submit">Save</button>
</form>

Control factories (instance methods)

Each method creates a control, registers it in the form, and returns the control object for chaining:

  • address($name, $attributes = [])
  • button($name, $attributes = [])
  • checkbox($name, $attributes = [])
  • color($name, $attributes = [])
  • date($name, $attributes = [])
  • datetime($name, $attributes = [])
  • email($name, $attributes = [])
  • file($name, $attributes = [])
  • googleAddress($name, $attributes = [])
  • hidden($name, $attributes = [])
  • image($name, $attributes = [])
  • meter($name, $attributes = [])
  • month($name, $attributes = [])
  • number($name, $attributes = [])
  • password($name, $attributes = [])
  • progress($name, $attributes = [])
  • search($name, $attributes = [])
  • select($name, $attributes = [])
  • tel($name, $attributes = [])
  • text($name, $attributes = [])
  • textarea($name, $attributes = [])
  • time($name, $attributes = [])
  • toggle($name, $attributes = [])
  • url($name, $attributes = [])

Preset-backed factories add the same shared normalization and validation rules documented in FormValidationPreset:

  • bic($name, $attributes = [])
  • e164Phone($name, $attributes = [])
  • ean13($name, $attributes = [])
  • emailAddress($name, $attributes = [])
  • hexColor($name, $attributes = [])
  • iban($name, $attributes = [])
  • ipAddress($name, $attributes = [])
  • ipv4Address($name, $attributes = [])
  • ipv6Address($name, $attributes = [])
  • macAddress($name, $attributes = [])
  • slug($name, $attributes = [])
  • uuid($name, $attributes = [])
  • webUrl($name, $attributes = [])
  • italianFiscalCode($name, $attributes = [])
  • italianPersonalFiscalCode($name, $attributes = [])
  • italianSdiRecipientCode($name, $attributes = [])
  • italianVatNumber($name, $attributes = [])

Example:

$form = new \Pair\Html\Form();

// Defines a required text field with a custom label.
$form->text('firstName')
    ->label('First name')
    ->required()
    ->maxLength(80);

// Defines a required email field with a custom CSS class.
$form->email('email')
    ->label('Email')
    ->required()
    ->class('text-lowercase');

// Defines a select with inline options and an empty placeholder.
$form->select('role')
    // For simple CRUD lists, an associative array is the most direct source.
    ->options([
        'admin' => 'Admin',
        'editor' => 'Editor',
    ])
    ->empty('Select role');

Validation preset factories

Preset-backed factories return normal Pair controls with FormControl::preset(...) already applied. They are useful when the field should be normalized and validated consistently on the server and, when /assets/PairValidation.js is loaded, in the browser.

$form = new \Pair\Html\Form();

$form->emailAddress('email')
    // Required still controls whether an empty value is rejected.
    ->required();

$form->iban('ibanCode')
    ->label('IBAN');

$form->webUrl('website')
    ->label('Website');

$form->italianFiscalCode('fiscalCode')
    ->label('Codice fiscale');

$form->italianVatNumber('vatNumber')
    ->label('Partita IVA');

Form::isValid() runs the server-side preset check through FormControl::validate(). The current submitted value is normalized before validation and written back into $_POST/$_REQUEST, so the owning save flow can read the canonical form after successful validation.

To enable the matching browser behavior, load:

<script src="/assets/PairValidation.js" defer></script>

The PHP preset still works without JavaScript.

Preset methods map to these canonical names:

  • bic() -> bic
  • e164Phone() -> e164_phone
  • ean13() -> ean13
  • emailAddress() -> email
  • hexColor() -> hex_color
  • iban() -> iban
  • ipAddress() -> ip_address
  • ipv4Address() -> ipv4_address
  • ipv6Address() -> ipv6_address
  • macAddress() -> mac_address
  • slug() -> slug
  • uuid() -> uuid
  • webUrl() -> url
  • italianFiscalCode() -> it.fiscal_code
  • italianPersonalFiscalCode() -> it.personal_fiscal_code
  • italianSdiRecipientCode() -> it.sdi_recipient_code
  • italianVatNumber() -> it.vat_number

Shared styling helpers

Form keeps backward compatibility with manual classes, but some controls now also react to the UI framework selected at runtime through Application::uiFramework(...):

  • no selected framework keeps output as native HTML without Bootstrap/Bulma classes
  • bootstrap enables Bootstrap-compatible helper markup where Pair renders framework-aware helpers
  • bulma auto-injects Bulma-compatible classes for supported controls (input, textarea, button) and uses a Bulma wrapper for Select

Example:

$app = \Pair\Core\Application::getInstance();
$app->uiFramework('bootstrap');
$app = \Pair\Core\Application::getInstance();
$app->uiFramework('bulma');

Manual classForControls(...), per-control class(...), and classForLabels(...) still work and remain additive.

Framework-specific output is delegated to the active UiRenderer, so projects can register a custom renderer without changing Form.

classForControls(string $class): Form

Adds a class that is injected on each rendered control. The class is applied at render time, so it works even if controls were registered earlier.

Important detail:

  • the class is applied when the control is rendered through Form (printControl(), renderControls(), render())
  • if you call $form->control('email')->render() directly, the form-level class is not injected by that call

classForLabels(string $class): Form

Sets common label class used when printing labels. This class is applied when you print the label through Form::printLabel().

classForForm(string $class): Form

Adds one or more classes to the <form> tag itself. The implementation splits space-separated classes and avoids duplicates.

Example:

// Adds the same input class to every printed control.
$form->classForControls('form-control');

// Adds the same label class to every printed label.
$form->classForLabels('form-label');

// Adds one or more classes to the <form> tag.
$form->classForForm('stacked-form card-body');

Form tag helpers

These methods are additive: they do not change how existing layouts work, but they let Form manage the outer tag when you want a more self-contained builder.

action(string $action): Form / method(string $method): Form / enctype(string $type): Form

Configure the main submission attributes of the form tag.

Current constraints:

  • method(...) only accepts get and post
  • enctype(...) only accepts application/x-www-form-urlencoded, multipart/form-data and text/plain

id(string $id): Form / target(string $target): Form / autocomplete(bool $autocomplete = true): Form

Set the main identity and browser-behavior attributes on the form tag.

Practical note:

  • target(...) accepts the standard browser targets (_blank, _self, _parent, _top) or a custom frame name without spaces

classForForm(string $class): Form / attribute(string $name, ?string $value = null): Form / attributes(array $attributes): Form

Add CSS classes and arbitrary attributes to the form tag.

attribute($name, null) creates a boolean attribute.

novalidate(bool $novalidate = true): Form

Enable or disable native browser validation on the form tag.

clientValidation(bool $enabled = true): Form

Opt a form into the accessible full-form layer in PairValidation.js. The method adds the boolean data-pair-validate attribute and is reversible with clientValidation(false).

It does not add novalidate to the generated HTML. PairValidation disables the native validation UI only after JavaScript initializes, preserving native constraint validation as a progressive fallback.

$form = (new \Pair\Html\Form())
    ->action('/profile/save')
    ->clientValidation();

$form->text('displayName')
    ->required()
    ->requiredMessage('Enter the display name.');

Load /assets/PairValidation.js and /assets/pair.css in the application template. See PairValidation.js for messages, summaries, server errors, widget adapters, and JavaScript lifecycle methods.

open(): string / close(): string / renderControls(): string / render(): string

Render the form tag only, the registered controls only, or the entire form with all registered controls.

printOpen(): void / printClose(): void / print(): void

Echo the same form-level markup directly in the view.

open() automatically uses multipart/form-data when the form contains at least one File control, unless you explicitly set a different encoding.

Example:

$form = (new \Pair\Html\Form())
    // Give the form a stable id so detached controls can target it.
    ->id('profile-form')
    ->action('/profile/save')
    ->method('post')
    ->classForForm('stacked-form')
    // Disable native browser validation if the module prefers server-side flows.
    ->novalidate();

echo $form->open();
echo $form->close();

Control management helpers

add(FormControl $control): FormControl

Registers a pre-built custom control. Unlike most form-level methods, add() returns the control itself so you can continue chaining on that control immediately.

control(string $name): ?FormControl

Returns a control by name.

  • accepts names with [] suffix
  • throws AppException (FORM_CONTROL_NOT_FOUND) if missing

Example:

// Both names resolve to the same control instance.
$tags = $form->control('tags[]');
$sameTags = $form->control('tags');

controlExists(string $name): bool

Checks if a control exists in the form.

controls(): array

Returns all registered controls (FormControl[]).

removeControl(string $name): bool

Removes control by name and returns success flag.

Rendering helpers

renderControls(): string

Renders all registered controls in insertion order, without labels and without the CSRF token unless you explicitly registered/printed it.

printControl(string $name): void

Prints one control HTML.

printLabel(string $name): void

Prints one control label (<label>), applying form-level label class if configured.

printToken(): void

Prints a CSRF hidden control generated by generateToken().

Typical rendering pattern:

<div class="field">
  <?php // Label and input come from the same registered control. ?>
  <?php $form->printLabel('email'); ?>
  <?php $form->printControl('email'); ?>
</div>

Practical note:

  • render() returns open() . renderControls() . close()
  • it does not generate labels, wrappers or the CSRF token automatically
  • printToken() is still an explicit step

CSRF helpers

generateToken(): Hidden

Creates (or reuses) $_SESSION['csrf_token'] and returns hidden control csrf_token. This assumes the Pair session is already active.

Important detail:

  • generateToken() registers or overwrites the hidden control named csrf_token inside the form
  • printToken() simply prints the generated hidden control

checkToken(): bool

Boolean check for submitted POST token.

Behavior:

  • requires POST request
  • requires session token + posted token
  • uses hash_equals
  • does not regenerate the token on success

Use checkToken() for boolean flows and validateToken() for exception-based flows.

Form::validateToken(): void

Exception-based CSRF validation.

  • throws AppException with CSRF_TOKEN_NOT_FOUND if session token is missing
  • throws AppException with CSRF_TOKEN_INVALID on mismatch
  • regenerates token after successful check

Practical note:

  • if the session token exists but the posted token is missing or different, the result is the same typed error: CSRF_TOKEN_INVALID

Controller pattern:

try {
    // Stops the request with a typed exception if the token is invalid.
    \Pair\Html\Form::validateToken();

    // Continue with the save flow.
} catch (\Pair\Exceptions\AppException $e) {
    // Stop the save flow and route the CSRF failure through your error handler.
    throw $e;
}

Validation helpers

isValid(): bool

Runs validate() on each registered control and returns combined result.

unvalidControls(): array

Returns only controls that fail validation.

allDisabled(): void / allReadonly(): void

Mass-apply disabled/readonly on all registered controls.

Example:

if (!$form->isValid()) {
    foreach ($form->unvalidControls() as $control) {
        // Inspect the failing control before re-rendering the form.
        $fieldName = $control->name;
    }
}

Binding values

fill(array|object $source): Form

Maps array keys or object properties to controls with the same name. This is the main value-binding method now.

Accepted sources:

  • associative arrays
  • generic objects
  • ActiveRecord instances

When the source is an ActiveRecord, Pair uses getAllProperties(). For generic objects, Pair uses get_object_vars(...), so it reads the object's public properties.

Binding values from ActiveRecord

When fill(...) or values(...) targets a Toggle, Pair casts persisted values such as 0 and 1 to booleans before calling the control. Direct Toggle::value(...) calls should still pass only true or false.

values(ActiveRecord $object): void

Maps object properties to controls with the same name. In the current implementation it is a thin wrapper around fill(...), kept mainly for the classic ActiveRecord workflow.

  • call this after all controls are defined
  • only matching control names are updated

Example:

$form = new \Pair\Html\Form();

// Declare controls first so values() can match them by name.
$form->hidden('id');
$form->text('firstName');
$form->email('email');

// Copies only properties that have a matching control.
$form->values($user); // $user extends ActiveRecord

Practical pattern: generic array/object binding

$form = new \Pair\Html\Form();
$form->text('firstName');
$form->email('email');

// fill() works with arrays too, not only with ActiveRecord objects.
$form->fill([
    'firstName' => 'Marta',
    'email' => 'marta@example.com',
]);

Practical pattern: edit form with repopulation

use Pair\Html\Form;

$form = new Form();
$form->hidden('id');
$form->text('firstName')->label('First name')->required();
$form->email('email')->label('Email')->required();

// Preloads the form when editing an existing object.
$form->values($user);

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // Validates CSRF before saving.
    Form::validateToken();

    // Re-runs control validation on submitted values.
    if ($form->isValid()) {
        // Persist the object here.
    }
}

Integration with ActiveRecord::getForm()

ActiveRecord::getForm() can auto-generate a form based on mapped properties and DB metadata.

Typical behavior:

  • key fields -> hidden
  • bool -> checkbox
  • DateTime -> date/datetime
  • numeric -> number
  • enum/set -> select
  • text-like DB types -> textarea
  • nullable/emptiable enum-like fields -> empty() on select

Example:

// Builds the full form automatically from object metadata.
$form = $user->getForm();

// Adds shared classes after generation.
$form->classForControls('form-control');
$form->classForLabels('form-label');

Static one-shot builders

Useful when you need a single control HTML without managing a full Form instance.

Form::buildInput($name, $value = null, $type = null, $attributes = [])

// Renders one standalone input without creating a full Form object.
echo \Pair\Html\Form::buildInput('email', 'john@example.com', 'email', [
    // Standalone builders still accept raw HTML attributes.
    'class' => 'form-control'
]);

Form::buildSelect($name, $list, $valName = 'value', $textName = 'text', $value = null, $attributes = null, $prependEmpty = null)

// Renders one standalone select with a preselected value.
echo \Pair\Html\Form::buildSelect(
    'status',
    [
        // An associative array is the most compact valid source here.
        'draft' => 'Draft',
        'published' => 'Published',
    ],
    'value',
    'text',
    'published',
    ['class' => 'form-select'],
    'Choose status'
);

Form::buildTextarea($name, $rows, $cols, $value = null, $attributes = [])

// Renders one standalone textarea.
echo \Pair\Html\Form::buildTextarea('notes', 6, 60, 'Initial notes');

Form::buildButton($value, $type = 'submit', $name = null, $attributes = [])

// Renders one standalone submit button.
echo \Pair\Html\Form::buildButton('Save', 'submit', null, ['class' => 'btn btn-primary']);

Full practical example

use Pair\Html\Form;

$form = new Form();

// Applies shared design-system classes.
$form->classForControls('form-control');
$form->classForLabels('form-label');

// Registers the controls used by the layout.
$form->hidden('id');
$form->text('firstName')->label('First name')->required()->maxLength(120);
$form->email('email')->label('Email')->required();
$form->password('password')->label('Password')->minLength(8);
$form->toggle('active')->label('Active')->value(true);
$form->select('role')->options([
    // Associative arrays map cleanly to Pair select options.
    'admin' => 'Admin',
    'editor' => 'Editor',
])->empty('Select role')->required();
<form method="post" action="" novalidate>
  <?php // Prints the CSRF token required by validateToken(). ?>
  <?php $form->printToken(); ?>

  <div>
    <?php // First name field. ?>
    <?php $form->printLabel('firstName'); ?>
    <?php $form->printControl('firstName'); ?>
  </div>

  <div>
    <?php // Email field. ?>
    <?php $form->printLabel('email'); ?>
    <?php $form->printControl('email'); ?>
  </div>

  <div>
    <?php // Password field. ?>
    <?php $form->printLabel('password'); ?>
    <?php $form->printControl('password'); ?>
  </div>

  <div>
    <?php // Role select field. ?>
    <?php $form->printLabel('role'); ?>
    <?php $form->printControl('role'); ?>
  </div>

  <button type="submit">Save</button>
</form>

Secondary methods worth knowing

These helpers are less central than printControl(), printLabel(), values() and the CSRF methods, but they are useful in real modules:

  • add(FormControl $control): FormControl Registers a prebuilt custom control instance.
  • controls(): array Returns the full control map when you need to inspect or loop over the form.
  • removeControl(string $name): bool Useful for role-based forms that hide one field entirely.
  • allDisabled(): void Turns the whole form into read-only display mode.
  • allReadonly(): void Similar to allDisabled(), but keeps fields submittable.
  • renderControls(): string Useful when the wrapper <form> tag is rendered elsewhere but you still want the controls block in insertion order.

Common pitfalls

  • Forgetting to print CSRF token (printToken()) while validating token server-side.
  • Using CSRF helpers without an active session.
  • Calling values($object) before controls are defined.
  • Passing arrays of arrays to Select::options(...) examples copied from generic HTML habits; Pair Select expects associative arrays or object lists.
  • Assuming render() also creates labels, wrappers or layout structure. It only renders the outer tag and the registered controls.
  • Using control('missing') without checking controlExists() (throws exception).

Related pages

Clone this wiki locally