Skip to content

FormControl

Viames Marino edited this page Mar 26, 2026 · 3 revisions

Pair framework: FormControl

Pair\Html\FormControl is the shared base class behind all Pair form controls: Text, Email, Number, Select, Textarea, Checkbox, Radio, File, and the other HTML widgets exposed by the framework.

If you need to understand how Pair forms really behave, this is the page to read first. Concrete controls mostly add rendering details or stricter validation. The common fluent API, label generation, HTML attributes, and default server-side validation all live here.

It centralizes:

  • control identity (name, id, label)
  • common HTML attributes (class, data-*, aria-*, placeholder, title, form)
  • reusable state flags (required, disabled, readonly, autofocus, array mode)
  • default rendering helpers used by subclasses
  • baseline server-side validation based on submitted POST values

Constructor

new FormControl(string $name, array $attributes = [])

Constructor behavior:

  • the first argument becomes the HTML name
  • if the name ends with [], Pair removes the suffix and automatically enables array mode
  • custom attributes passed in $attributes are stored and rendered later by processProperties()

Example:

// Pair strips [] from the internal name and remembers that this field is an array.
$tags = new \Pair\Html\FormControls\Text('tags[]', [
    // Custom attributes are appended during render.
    'data-role' => 'tag-input',
]);

Main methods

These are the methods you will use most often in day-to-day Pair code.

Identity and label

  • id(string $id): static
  • label(string $label): static
  • labelClass(string $class): static
  • description(string $description): static
  • getLabelText(): string

Use these methods to control how the field is presented and connected to its label.

$email = (new \Pair\Html\FormControls\Text('userEmail'))
    // Use a stable DOM id when JavaScript or CSS relies on the field.
    ->id('profile-email')
    // Uppercase labels longer than 3 chars are treated as translation keys.
    ->label('PROFILE_EMAIL')
    // Add an explicit CSS class to the <label>.
    ->labelClass('form-label')
    // Description adds a tooltip icon inside the label markup.
    ->description('Used for login notifications only.');
$email = (new \Pair\Html\FormControls\Text('userEmail'))
    // Leave label resolution to the base class.
    ->label('PROFILE_EMAIL');

// getLabelText() returns the final resolved label text.
$resolved = $email->getLabelText();

Value and state

  • value(string|int|float|DateTime|null $value): static
  • required(bool $required = true): static
  • disabled(bool $disabled = true): static
  • readonly(bool $readonly = true): static
  • arrayName(): static
  • autofocus(bool $autofocus = true): static

These methods define how the field behaves in HTML and, in part, how it is validated on submit.

$title = (new \Pair\Html\FormControls\Text('title'))
    // Required affects both label rendering and default server-side validation.
    ->required()
    // Preload the current value into the rendered control.
    ->value($record->title)
    // Ask the browser to focus this field on page load.
    ->autofocus();
$filters = (new \Pair\Html\FormControls\Text('filters'))
    // Render the control as filters[] instead of filters.
    ->arrayName()
    // Disabled fields are rendered but not editable.
    ->disabled();

Practical note:

  • the base implementation stores the value as a string; if you have a DateTime, format it first to the exact string expected by the specific control

Validation rules and input hints

  • minLength(int $length): static
  • maxLength(int $length): static
  • pattern(string $pattern): static
  • placeholder(string $placeholder): static
  • inputmode(string $mode): static

These methods are especially common on text-like controls.

$slug = (new \Pair\Html\FormControls\Text('slug'))
    ->label('Slug')
    // Pair prints minlength and maxlength on input-based controls.
    ->minLength(3)
    ->maxLength(80)
    // Pattern is allowed only on a small set of text-like controls.
    ->pattern('^[a-z0-9-]+$')
    // Placeholder is rendered only where supported.
    ->placeholder('my-page-slug')
    // inputmode is a browser hint, not a validator.
    ->inputmode('text');

Generic HTML attributes

  • class(string|array $class): static
  • data(string $name, string $value): static
  • aria(string $name, string $value): static
  • title(string $title): static
  • form(string $formId): static

These methods let you attach presentational or integration attributes without creating a custom control subclass.

$search = (new \Pair\Html\FormControls\Text('search'))
    // Use an array if you want real class splitting and de-duplication.
    ->class(['form-control', 'js-live-search'])
    // data-* attributes are useful for frontend hooks.
    ->data('endpoint', '/api/search/suggestions')
    // aria-* helps when the control participates in richer widgets.
    ->aria('label', 'Search contacts')
    // title is currently accepted by every control in the implementation.
    ->title('Type at least 3 characters');
$floatingField = (new \Pair\Html\FormControls\Text('notes'))
    // Associate the control with a form even if it sits outside the <form> tag.
    // This pairs naturally with Form::id('order-form').
    ->form('order-form');

Rendering and output

  • render(): string
  • renderLabel(): string
  • print(): void
  • printLabel(): void
  • __toString(): string

Concrete controls implement render(), while FormControl provides label rendering and echo helpers.

$name = (new \Pair\Html\FormControls\Text('name'))
    ->id('customer-name')
    ->label('Customer name')
    ->required();

// Render label and control separately when building a custom layout.
echo $name->renderLabel();
echo $name->render();
$summary = (new \Pair\Html\FormControls\Textarea('summary'))
    ->label('Summary')
    ->rows(4);

// print() is just a small echo helper.
$summary->printLabel();
$summary->print();

How labels are generated

getLabelText() follows three different paths:

  • if no label is set, Pair derives it from the field name
  • if the label is uppercase and longer than 3 characters, Pair treats it as a translation key and calls Translator::do(...)
  • otherwise the raw label text is used as-is

Examples:

// Derived label: "User Email"
$a = new \Pair\Html\FormControls\Text('userEmail');

// Translation key: Translator::do('USER_EMAIL')
$b = (new \Pair\Html\FormControls\Text('userEmail'))
    ->label('USER_EMAIL');

// Literal label: "User e-mail"
$c = (new \Pair\Html\FormControls\Text('userEmail'))
    ->label('User e-mail');

renderLabel() also adds two optional UI details:

  • <span class="required-field">...</span> when the field is required and not readonly/disabled
  • a tooltip icon when description(...) has been configured

Validation behavior

The default validate() implementation is intentionally simple and very important to understand:

  • it reads the submitted value from Post::get($this->name)
  • it does not validate against the current in-memory $this->value
  • it checks required, minLength, and maxLength
  • it writes failures to Logger
  • it returns true only if all these checks pass

This means the base validator is a good baseline, not a full business validation layer.

$username = (new \Pair\Html\FormControls\Text('username'))
    ->required()
    ->minLength(3)
    ->maxLength(20);

// On submit, validate() checks Post::get('username'),
// not the value previously assigned with ->value(...).
$isValid = $username->validate();

Concrete controls can override validate() to add stricter checks. Common examples are Email, Number, Url, and Select.

Supported and restricted methods

Some fluent methods are available only on specific controls.

caption(...)

Allowed only on:

  • Button
  • Meter
  • Progress
  • Textarea
$message = (new \Pair\Html\FormControls\Textarea('message'))
    // Textarea uses caption as inner text.
    ->caption("Initial line 1\nInitial line 2");
$save = (new \Pair\Html\FormControls\Button('save'))
    // Buttons can use caption as visible text.
    ->caption('Save');

pattern(...)

Allowed only on:

  • Text
  • Search
  • Tel
  • Email
  • Password
  • Url

Calling it on unsupported controls throws an AppException.

placeholder(...)

Blocked on:

  • Checkbox
  • Radio
  • File
  • Color
  • Range
  • Hidden

Calling it on these controls throws an AppException.

aria(...)

Ignored without exception on:

  • Hidden
  • File
  • Image

inputmode(...)

Ignored without exception on:

  • Button
  • Checkbox
  • Color
  • Date
  • Datetime
  • File
  • Hidden
  • Image
  • Month
  • Password
  • Select
  • Textarea
  • Time

Secondary methods and internal helpers

These methods are useful to know when you extend controls or debug rendering:

  • nameProperty(): string Builds the escaped name="..." attribute and appends [] when array mode is active.
  • processProperties(): string Collects the common attributes (id, required, disabled, readonly, placeholder, pattern, classes, custom attributes).
  • renderInput(string $type): string Shared helper used by simple input controls such as Text, Email, and Search.
  • __get(string $name): mixed Exposes internal properties and throws AppException if the property does not exist.
  • __set(string $name, mixed $value): void Writes arbitrary properties directly on the object.

Practical detail:

  • processProperties() does not print the HTML required attribute for Checkbox and Radio

Practical examples

1. Standard text control with full metadata

$title = (new \Pair\Html\FormControls\Text('title'))
    // Stable id for label, CSS and JavaScript hooks.
    ->id('article-title')
    // Explicit label avoids relying on automatic name conversion.
    ->label('Title')
    // Required affects both the label markup and base validation.
    ->required()
    // Length limits are rendered on the input and checked server-side.
    ->minLength(3)
    ->maxLength(120)
    // Placeholder is a hint, not a real default value.
    ->placeholder('Insert title')
    // Use an array to add multiple CSS classes cleanly.
    ->class(['form-control', 'form-control-lg']);

echo $title->renderLabel();
echo $title->render();

2. Field attached to an external form

$quickNote = (new \Pair\Html\FormControls\Textarea('quickNote'))
    ->label('Quick note')
    ->rows(3)
    // The control can belong to a form rendered elsewhere in the page.
    ->form('ticket-form')
    ->description('Useful when the toolbar is outside the modal form.');

3. Control created outside the form tag but linked to a real Form

$form = (new \Pair\Html\Form())
    // Give the real form a stable DOM id.
    ->id('ticket-form');

$subject = (new \Pair\Html\FormControls\Text('subject'))
    ->label('Subject')
    // Link the detached control to the form by id.
    ->form('ticket-form');

4. Array-style field names

$tags = (new \Pair\Html\FormControls\Text('tags[]'))
    // The constructor already activates array mode because of [].
    ->placeholder('Tag')
    // data-* attributes are often used by autocomplete/tag widgets.
    ->data('role', 'tag-input');

5. Restricted methods in practice

$notes = (new \Pair\Html\FormControls\Textarea('notes'))
    // Textarea stores its content in the caption slot.
    ->caption('Initial notes');

$button = (new \Pair\Html\FormControls\Button('save'))
    // Button caption becomes visible button text.
    ->caption('Save');

// This would throw an AppException because Text does not support caption().
// (new \Pair\Html\FormControls\Text('name'))->caption('Name');

Notes and caveats

  • class('a b') is treated as one class string; use class(['a', 'b']) when you want two separate classes.
  • title(...) is accepted by the current implementation on every control, even though an internal unsupported list exists.
  • value(...) stores a string in the base class; format dates explicitly before passing them in.
  • validate() only covers generic checks. Domain rules, cross-field checks, and permission-sensitive validation still belong in the model/controller/request layer.
  • __get() throws AppException with ErrorCodes::PROPERTY_NOT_FOUND when you ask for a missing property.

See also: Form, Text, Select, Textarea, Checkbox, Post, Translator.

Clone this wiki locally