Skip to content
Viames Marino edited this page Mar 26, 2026 · 4 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
  • 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 = [])

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');

Shared styling helpers

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.

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

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('1');
$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