PHPackages                             coroq/form - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Search &amp; Filtering](/categories/search)
4. /
5. coroq/form

ActiveLibrary[Search &amp; Filtering](/categories/search)

coroq/form
==========

Type-safe PHP form validation library with filtering, nested forms, and typed error objects. Zero dependencies.

v3.0.4(2mo ago)02563MITPHPPHP ^8.0CI failing

Since Aug 23Pushed 2mo ago1 watchersCompare

[ Source](https://github.com/ozami/coroq-form)[ Packagist](https://packagist.org/packages/coroq/form)[ RSS](/packages/coroq-form/feed)WikiDiscussions master Synced 1w ago

READMEChangelog (10)Dependencies (1)Versions (28)Used By (3)

Coroq Form
==========

[](#coroq-form)

PHP form validation library. Type-safe, zero dependencies.

Scope
-----

[](#scope)

### What This Library Does

[](#what-this-library-does)

- **Value validation and filtering** - Validates and normalizes form input (email, URL, numbers, dates, text, etc.)
- **Type-safe form handling** - Provides typed input classes with IDE autocomplete support
- **Error management** - Tracks validation errors as typed objects (not string codes)
- **Nested forms** - Supports hierarchical form structures (forms within forms)
- **Dynamic lists** - Manages repeating form items (e.g., multiple email addresses)
- **Cross-field validation** - Validates relationships between fields (e.g., password confirmation)

### What This Library Does NOT Do

[](#what-this-library-does-not-do)

- **HTML rendering** - This library does not generate HTML. You write your own templates.
- **HTTP request handling** - Does not parse `$_POST` or `$_FILES`. You pass data to `setValue()`.
- **CSRF protection** - Does not generate or validate CSRF tokens. Use your framework's CSRF protection.
- **Database operations** - Does not save or load data from databases. Use your ORM/database layer.
- **Framework integration** - Framework-agnostic. Integrate it yourself or use it standalone.
- **Client-side validation** - Server-side only. Add your own JavaScript validation if needed.

This is a **validation and data processing layer** that sits between your HTTP layer and business logic.

Requirements
------------

[](#requirements)

- PHP &gt;= 8.0
- mbstring extension
- fileinfo extension
- filter extension
- bcmath extension
- intl extension (optional)

Installation
------------

[](#installation)

```
composer require coroq/form
```

Quick Start
-----------

[](#quick-start)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\TextInput;

class LoginForm extends Form {
    public readonly EmailInput $email;
    public readonly TextInput $password;

    public function __construct() {
        $this->email = new EmailInput();
        $this->password = new TextInput();
    }
}

$form = new LoginForm();
$form->setValue($_POST);

if ($form->validate()) {
    $email = $form->email->getEmail();
    // Process login...
} else {
    $errors = $form->getError();
    // Handle validation errors
}
```

Core Concepts
-------------

[](#core-concepts)

### 1. Forms and Form Items

[](#1-forms-and-form-items)

A **Form** holds items with names. Each item represents a single field - an email address, a username, a number, etc.

### 2. Setting Values

[](#2-setting-values)

When you assign values to a form, the form distributes those values to its items by matching names. Each item receives and stores its corresponding value.

### 3. Filtering

[](#3-filtering)

The moment a value is set, it is automatically **filtered** - normalized and transformed according to the item's type. Email addresses get trimmed and lowercased, numbers get stripped of formatting.

### 4. Validation and Errors

[](#4-validation-and-errors)

When you request validation, the form checks all its items. Each item validates its value against its rules. The form returns whether all items are valid. Invalid items store an error object representing what went wrong.

Defining Forms
--------------

[](#defining-forms)

### Recommended: Form Subclasses

[](#recommended-form-subclasses)

Define form classes with typed readonly properties for IDE support:

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\IntegerInput;
use Coroq\Form\FormItem\Select;

class UserRegistrationForm extends Form {
    public readonly TextInput $name;
    public readonly EmailInput $email;
    public readonly IntegerInput $age;
    public readonly Select $country;

    public function __construct() {
        $this->name = (new TextInput())
            ->setLabel('Name')
            ->setMaxLength(100);

        $this->email = (new EmailInput())
            ->setLabel('Email');

        $this->age = (new IntegerInput())
            ->setLabel('Age')
            ->setMin(18)
            ->setMax(120);

        $this->country = (new Select())
            ->setLabel('Country')
            ->setOptions([
                'us' => 'United States',
                'jp' => 'Japan',
                'uk' => 'United Kingdom'
            ]);
    }
}

// Usage with full IDE support
$form = new UserRegistrationForm();
$form->setValue($_POST);

if ($form->validate()) {
    // IDE knows the exact types
    $name = $form->name->getValue();
    $email = $form->email->getEmail();
    $age = $form->age->getInteger();
}
```

### Dynamic Forms (for temporal use)

[](#dynamic-forms-for-temporal-use)

For dynamic or one-off forms, you can use Form directly:

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\TextInput;

$form = new Form();
$form->email = new EmailInput();
$form->name = new TextInput();

$form->setValue($_POST);
$form->validate();
```

Form State
----------

[](#form-state)

Form items have three state flags that control their behavior:

### Required/Optional

[](#requiredoptional)

**Input level:**

- `setRequired(true)` (default) - Empty value fails validation with EmptyError
- `setRequired(false)` - Empty value passes validation

**Form level:**

- `setRequired(true)` (default) - Validates all enabled items even if form is empty
- `setRequired(false)` - If the entire form is empty, validation passes without checking items

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class ProfileForm extends Form {
    public readonly TextInput $name;
    public readonly TextInput $nickname;

    public function __construct() {
        $this->name = new TextInput();  // Required (default)
        $this->nickname = (new TextInput())
            ->setRequired(false);  // Optional
    }
}

$form = new ProfileForm();
$form->setValue(['name' => '', 'nickname' => '']);
$form->validate();
// name has EmptyError, nickname passes validation
```

**Form-level example:**

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class AddressForm extends Form {
    public readonly TextInput $street;
    public readonly TextInput $city;

    public function __construct() {
        $this->street = new TextInput();
        $this->city = new TextInput();
        $this->setRequired(false);  // Make entire form optional
    }
}

$form = new AddressForm();
$form->setValue(['street' => '', 'city' => '']);
$form->validate();  // Passes! Empty optional form skips item validation
```

### Read-Only

[](#read-only)

**Input level:**

- `setValue()` is ignored (value doesn't change)
- Item is included in `getValue()` and `validate()`

**Form level:**

- `setValue()` is ignored for the entire form
- Items are included in `getValue()` and `validate()`

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class UserForm extends Form {
    public readonly TextInput $id;
    public readonly TextInput $name;

    public function __construct() {
        $this->id = (new TextInput())
            ->setValue('12345')
            ->setReadOnly(true);
        $this->name = new TextInput();
    }
}

$form = new UserForm();
$form->setValue(['id' => '99999', 'name' => 'Taro']);

echo $form->id->getValue();    // "12345" (unchanged)
echo $form->name->getValue();  // "Taro"
$form->validate();             // Both items are validated
```

**Form-level example:**

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class DisplayForm extends Form {
    public readonly TextInput $field;

    public function __construct() {
        $this->field = (new TextInput())->setValue('fixed');
        $this->setReadOnly(true);  // Entire form is read-only
    }
}

$form = new DisplayForm();
$form->setValue(['field' => 'new value']);  // Ignored!
echo $form->field->getValue();  // "fixed"
```

### Disabled

[](#disabled)

Disabled items are excluded from output and validation, but still accept values.

**Item behavior when disabled:**

- `getValue()` returns the item's empty value (see each class for definition)
- `isEmpty()` returns `true`
- `setValue()` accepts and stores the value
- `validate()` is skipped

**Parent form behavior:**

- Disabled child items are excluded from parent's `getValue()` and `validate()`
- Useful for conditionally hiding entire form sections

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class OrderForm extends Form {
    public readonly TextInput $customerName;
    public readonly TextInput $legacyField;

    public function __construct() {
        $this->customerName = new TextInput();
        $this->legacyField = (new TextInput())
            ->setDisabled(true);
    }
}

$form = new OrderForm();
$form->setValue([
    'customerName' => 'Taro',
    'legacyField' => 'some value'
]);

$values = $form->getValue();
// ['customerName' => 'Taro']
// legacyField is excluded from getValue()
```

**Form-level example:**

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class CheckoutForm extends Form {
    public readonly TextInput $name;
    public readonly AddressForm $billing;
    public readonly AddressForm $shipping;

    public function __construct() {
        $this->name = new TextInput();
        $this->billing = new AddressForm();
        $this->shipping = new AddressForm();
    }

    public function disableShipping() {
        $this->shipping->setDisabled(true);
        return $this;
    }
}

$form = new CheckoutForm();
$form->disableShipping();

$form->setValue([
    'name' => 'Taro',
    'billing' => ['street' => '1-1-1', 'city' => 'Tokyo'],
    'shipping' => ['street' => '2-2-2', 'city' => 'Osaka']
]);

$values = $form->getValue();
// ['name' => 'Taro', 'billing' => ['street' => '1-1-1', 'city' => 'Tokyo']]
// shipping is excluded from getValue()
```

### State Summary

[](#state-summary)

StatesetValue()getValue()validate()Normal (required=true)✓ Sets value✓ Returns value✓ Validated, must not be emptyOptional (required=false)✓ Sets value✓ Returns value✓ Validated, empty allowedRead-only✗ Ignored✓ Returns value✓ ValidatedDisabled✓ Sets valueReturns empty value✗ Skipped**Form-level states apply to the form as a whole:**

- Required=false on Form: Empty form passes validation
- ReadOnly on Form: setValue() ignored for entire form
- Disabled on Form: Returns empty value; excluded from parent's getValue/validate

Validation
----------

[](#validation)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\IntegerInput;

class LoginForm extends Form {
    public readonly EmailInput $email;
    public readonly IntegerInput $age;

    public function __construct() {
        $this->email = new EmailInput();
        $this->age = (new IntegerInput())->setMin(18);
    }
}

$form = new LoginForm();
$form->setValue([
    'email' => 'invalid-email',
    'age' => '15'
]);

if ($form->validate()) {
    // All valid
} else {
    // Check individual fields
    if ($form->email->hasError()) {
        $error = $form->email->getError();
        echo get_class($error); // "Coroq\Form\Error\InvalidEmailError"
    }

    if ($form->age->hasError()) {
        $error = $form->age->getError();
        echo get_class($error); // "Coroq\Form\Error\TooSmallError"
    }

    // Get all errors at once
    $errors = $form->getError();
    // ['email' => InvalidEmailError, 'age' => TooSmallError]
}
```

### Custom Validators

[](#custom-validators)

All Input subclasses support custom validators via `setValidator()`. This allows you to add validation logic without creating custom subclasses.

#### Basic Example

[](#basic-example)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\Error\InvalidError;

class RegistrationForm extends Form {
    public readonly TextInput $username;

    public function __construct() {
        $this->username = (new TextInput())
            ->setMinLength(3)
            ->setValidator(function($formItem, $value) {
                // Additional validation: no special characters
                if (preg_match('/[^a-z0-9_]/', $value)) {
                    return new InvalidError($formItem);
                }
                return null;
            });
    }
}
```

#### How It Works

[](#how-it-works)

The validator:

- Receives two parameters: `$formItem` (the input itself) and `$value` (the filtered value)
- Runs **after** the input's built-in validation (`doValidate()`) passes
- Returns an `Error` object if validation fails, or `null` if valid
- Does **not** run if the value is empty or if built-in validation fails

```
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\Error\InvalidError;

$email = (new EmailInput())
    ->setValidator(function($formItem, $value) {
        // Block disposable email domains
        if (str_ends_with($value, '@tempmail.com')) {
            return new InvalidError($formItem);
        }
        return null;
    });

$email->setValue('user@tempmail.com');
$email->validate(); // Fails - custom validator returns error

$email->setValue('invalid-email');
$email->validate(); // Fails - built-in email validation fails first
                   // Custom validator never runs
```

#### Advanced Examples

[](#advanced-examples)

**Accessing form item properties:**

```
use Coroq\Form\FormItem\IntegerInput;
use Coroq\Form\Error\InvalidError;

$quantity = (new IntegerInput())
    ->setMin(1)
    ->setMax(100)
    ->setValidator(function($formItem, $value) {
        // Reject quantities not divisible by 5
        if ((int)$value % 5 !== 0) {
            return new InvalidError($formItem);
        }
        return null;
    });
```

### External Validation

[](#external-validation)

When you validate a value in external logic (authentication, API calls, business rules) but want to hold the error in the form, use `setError()` on a form item. The form item can be used only for holding the error.

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\Input;
use Coroq\Form\Error\InvalidError;

class LoginForm extends Form {
    public readonly EmailInput $email;
    public readonly TextInput $password;
    public readonly Input $authResult;

    public function __construct() {
        $this->email = new EmailInput();
        $this->password = new TextInput();
        $this->authResult = (new Input())->setReadOnly(true);
    }
}

$form = new LoginForm();
$form->setValue($_POST);

if ($form->validate()) {
    // External validation
    if (!$authService->authenticate($form->email->getValue(), $form->password->getValue())) {
        $form->authResult->setError(new InvalidError($form->authResult));
    }
}

if ($form->hasError()) {
    // Handle all errors uniformly
}
```

### Conditional Validation

[](#conditional-validation)

Make validation rules conditional based on other field values by overriding `setValue()` in your Form subclass.

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\BooleanInput;
use Coroq\Form\FormItem\TextInput;

class RegistrationForm extends Form {
    public readonly BooleanInput $isCompany;
    public readonly TextInput $companyName;
    public readonly TextInput $division;

    public function __construct() {
        $this->isCompany = new BooleanInput();
        $this->companyName = new TextInput();
        $this->division = new TextInput();
    }

    public function setValue(mixed $value): self {
        parent::setValue($value);

        // Conditional validation: enable company fields only if isCompany is true
        $isCompany = $this->isCompany->getBoolean();
        $this->companyName->setDisabled(!$isCompany);
        $this->division->setDisabled(!$isCompany);

        return $this;
    }
}

$form = new RegistrationForm();
$form->setValue(['isCompany' => 'on', 'companyName' => '', 'division' => '']);
$form->validate();
// companyName and division have EmptyError (enabled and required)

$form->setValue(['isCompany' => '', 'companyName' => '', 'division' => '']);
$form->validate();
// companyName and division are excluded from validation (disabled)
```

This pattern works for any conditional logic: disabling fields, changing constraints, or toggling entire sections.

For conditional sections (multi-step forms, draft/publish workflows), use nested forms with `setDisabled()`:

```
class CheckoutForm extends Form {
    public readonly BillingForm $billing;
    public readonly ShippingForm $shipping;

    public function setValue(mixed $value): self {
        parent::setValue($value);

        // Disable shipping section if same as billing
        $this->shipping->setDisabled($this->billing->sameAsShipping->getBoolean());

        return $this;
    }
}
```

Error Handling
--------------

[](#error-handling)

### Error Customizer

[](#error-customizer)

Transform error objects before they are stored. Useful for converting generic errors to field-specific error types.

```
use Coroq\Form\FormItem\BooleanInput;
use Coroq\Form\Error\Error;
use Coroq\Form\Error\EmptyError;

class NoAgreementError extends Error {}

$agree = (new BooleanInput())
    ->setRequired(true)
    ->setErrorCustomizer(function(Error $error, $formItem): Error {
        if ($error instanceof EmptyError) {
            return new NoAgreementError($formItem);
        }
        return $error;
    });

$agree->validate();
echo get_class($agree->getError()); // "NoAgreementError"
```

The customizer receives `$error` and `$formItem`, runs after validation, and returns the transformed error. You can replace the error object or mutate it by adding properties.

### Error Messages

[](#error-messages)

Use `ErrorMessageFormatter` to convert error objects to human-readable messages. You define your own message set by mapping error class names to messages (strings or closures).

#### Basic Usage

[](#basic-usage)

```
use Coroq\Form\ErrorMessageFormatter;
use Coroq\Form\Error\EmptyError;
use Coroq\Form\Error\InvalidError;
use Coroq\Form\Error\TooLongError;
use Coroq\Form\Error\TooSmallError;

// Define your message set
$messages = [
    EmptyError::class => 'This field is required',
    InvalidError::class => 'Invalid value',  // Catch-all for all Invalid* errors
    TooSmallError::class => 'Value is too small',
    TooLongError::class => 'Text is too long',
];

$formatter = new ErrorMessageFormatter();
$formatter->setMessages($messages);

// Format errors
$form->validate();
if ($form->email->hasError()) {
    echo $formatter->format($form->email->getError());
    // "Invalid value" (InvalidEmailError extends InvalidError)
}
```

### Error Hierarchy and Inheritance

[](#error-hierarchy-and-inheritance)

The formatter uses `instanceof` matching, supporting error class inheritance. Many specific errors extend base error types. For example, `InvalidEmailError`, `InvalidUrlError`, `InvalidDateError`, `InvalidMimeTypeError`, and `InvalidExtensionError` all extend `InvalidError`.

**Define base messages as defaults, then override specific types as needed:**

```
use Coroq\Form\ErrorMessageFormatter;
use Coroq\Form\Error\InvalidError;
use Coroq\Form\Error\InvalidEmailError;

$messages = [
    InvalidError::class => 'Invalid value',  // Base message for all Invalid* errors
    InvalidEmailError::class => 'Please enter a valid email address',  // Specific override
];

$formatter = new ErrorMessageFormatter();
$formatter->setMessages($messages);

// InvalidEmailError → 'Please enter a valid email address' (specific)
// InvalidUrlError → 'Invalid value' (falls back to base)
// InvalidDateError → 'Invalid value' (falls back to base)
```

**Later definitions override earlier ones.** This makes it easy to merge preset messages with custom overrides:

```
// Start with preset base messages
$messages = [
    EmptyError::class => 'This field is required',
    InvalidError::class => 'Invalid value',
    TooLongError::class => 'Text is too long',
    TooSmallError::class => 'Value is too small',
];

// Add specific overrides
$messages = [
    ...$messages,  // Base messages
    InvalidEmailError::class => 'Please enter a valid email address',
    TooLongError::class => fn($e) => "Maximum {$e->formItem->getMaxLength()} characters",
];

$formatter = new ErrorMessageFormatter();
$formatter->setMessages($messages);
```

### Adding Individual Messages

[](#adding-individual-messages)

Use `setMessage()` to add or override individual messages without replacing the entire set:

```
$formatter = new ErrorMessageFormatter();

// Set base messages
$formatter->setMessages([
    EmptyError::class => 'Required field',
    InvalidError::class => 'Invalid value',
]);

// Add or override specific messages
$formatter->setMessage(InvalidEmailError::class, 'Please enter a valid email');
$formatter->setMessage(TooLongError::class, fn($e) => "Max {$e->formItem->getMaxLength()} chars");
```

### Dynamic Messages with Closures

[](#dynamic-messages-with-closures)

Use closures to access error object properties for dynamic messages:

```
use Coroq\Form\ErrorMessageFormatter;
use Coroq\Form\Error\EmptyError;
use Coroq\Form\Error\TooLongError;
use Coroq\Form\Error\TooSmallError;

$messages = [
    EmptyError::class => function(EmptyError $error) {
        return $error->formItem->getLabel() . ' is required';
    },
    TooLongError::class => function(TooLongError $error) {
        return 'Maximum ' . $error->formItem->getMaxLength() . ' characters allowed';
    },
    TooSmallError::class => function(TooSmallError $error) {
        return 'Minimum value is ' . $error->formItem->getMin();
    },
];

$formatter = new ErrorMessageFormatter();
$formatter->setMessages($messages);
```

### Custom Error Types

[](#custom-error-types)

You can create custom error classes for application-specific validation:

```
use Coroq\Form\Error\Error;
use Coroq\Form\FormItem\FormItemInterface;

// Define custom error
class PasswordMismatchError extends Error {
    /** @property-read PasswordInput $formItem */
}

class RateLimitError extends Error {
    public function __construct(
        FormItemInterface $formItem,
        public readonly int $remainingSeconds
    ) {
        parent::__construct($formItem);
    }
}

// Use in messages
$messages = [
    PasswordMismatchError::class => 'Passwords do not match',
    RateLimitError::class => function(RateLimitError $error) {
        return 'Too many attempts. Try again in ' . $error->remainingSeconds . ' seconds';
    },
];
```

### Built-in Error Types

[](#built-in-error-types)

The library provides these error types:

**Base Errors:**

- `EmptyError` - Required field is empty
- `InvalidError` - Generic validation failure (base class for format validation errors)

**Invalid* Hierarchy (all extend InvalidError):*\*

- `InvalidEmailError` - Invalid email format
- `InvalidUrlError` - Invalid URL format
- `InvalidDateError` - Invalid date format
- `InvalidMimeTypeError` - File MIME type not allowed
- `InvalidExtensionError` - File extension not allowed

**Range/Length Errors:**

- `TooShortError`, `TooLongError` - String length validation
- `TooSmallError`, `TooLargeError` - Number range validation
- `TooFewSelectionsError`, `TooManySelectionsError` - Multi-select count

**Type/Format Errors:**

- `NotIntegerError`, `NotNumericError` - Type validation
- `PatternMismatchError` - Pattern validation failure

**Selection Errors:**

- `NotInOptionsError` - Invalid selection value

**File Errors:**

- `FileNotFoundError` - File not found at path
- `FileTooLargeError`, `FileTooSmallError` - File size range

**Derived Errors:**

- `SourceItemInvalidError` - Derived item's source failed validation

**Tip:** Define messages for base error types (like `InvalidError`) as catch-alls, then optionally override specific subtypes for custom messages.

Form Values
-----------

[](#form-values)

Forms provide four methods to retrieve values:

- **`getValue()`** - All values as strings (includes empty values)
- **`getFilledValue()`** - Only non-empty values as strings
- **`getParsedValue()`** - Values with proper types, or `null` if empty/invalid
- **`getFilledParsedValue()`** - Only non-empty values with proper types

### getParsedValue() Contract

[](#getparsedvalue-contract)

**`getParsedValue()` returns `null` if the value is empty or invalid, otherwise returns the value converted to its appropriate type.**

This applies to all form items:

- **Empty values** always return `null` (not empty strings)
- **Invalid values** return `null` (e.g., malformed email, out-of-range integer)
- **Valid values** return the appropriate PHP type (int, float, bool, DateTime, string, array)

**Special cases:**

- `BooleanInput::getParsedValue()` always returns `bool` (never `null`) - unchecked = `false`, checked = `true`
- `MultiSelect::getParsedValue()` always returns `array` (never `null`) - empty selection = `[]`

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\IntegerInput;
use Coroq\Form\FormItem\BooleanInput;
use Coroq\Form\FormItem\TextInput;

class UserForm extends Form {
    public readonly EmailInput $email;
    public readonly IntegerInput $age;
    public readonly BooleanInput $newsletter;
    public readonly TextInput $notes;

    public function __construct() {
        $this->email = new EmailInput();
        $this->age = (new IntegerInput())->setRequired(false);
        $this->newsletter = (new BooleanInput())->setRequired(false);
        $this->notes = (new TextInput())->setRequired(false);
    }
}

$form = new UserForm();
$form->setValue([
    'email' => 'user@example.com',
    'age' => '25',
    'newsletter' => 'on',
    'notes' => ''
]);

// getValue() - raw strings, includes empty
$form->getValue();
// ['email' => 'user@example.com', 'age' => '25', 'newsletter' => 'on', 'notes' => '']

// getFilledValue() - raw strings, excludes empty
$form->getFilledValue();
// ['email' => 'user@example.com', 'age' => '25', 'newsletter' => 'on']

// getParsedValue() - proper types, null for empty/invalid
$form->getParsedValue();
// ['email' => 'user@example.com', 'age' => 25, 'newsletter' => true, 'notes' => null]

// getFilledParsedValue() - proper types, excludes empty/null
$form->getFilledParsedValue();
// ['email' => 'user@example.com', 'age' => 25, 'newsletter' => true]
```

Input Types
-----------

[](#input-types)

### Text Input

[](#text-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\UnicodeNormalization;

class ProfileForm extends Form {
    public readonly TextInput $name;
    public readonly TextInput $bio;

    public function __construct() {
        $this->name = (new TextInput())
            ->setMinLength(2)
            ->setMaxLength(100)
            ->setTrim(TextInput::BOTH)     // LEFT, RIGHT, BOTH, or null
            ->setCase(TextInput::TITLE)    // UPPER, LOWER, TITLE
            ->setMb('KV')                      // mb_convert_kana option
            ->setPattern('/^[A-Za-z ]+$/');    // Regex validation

        $this->bio = (new TextInput())
            ->setMultiline(true)
            ->setEol("\n")                     // Normalize line endings
            ->setMaxLength(1000);
    }
}
```

**Unicode Normalization:**

Text input values are normalized using NFC (Canonical Composition) by default if the `intl` extension is available. This ensures consistent character representation (e.g., Japanese combining marks: か゛ → が).

```
use Coroq\Form\FormItem\UnicodeNormalization;

// Default: NFC if intl available, otherwise no normalization
$input = new TextInput();

// Use different form (NFD, NFKC, NFKD)
$input->setUnicodeNormalization(UnicodeNormalization::NFKC);

// Disable normalization
$input->setUnicodeNormalization(null);
```

For normalization form details, see [Normalizer class documentation](https://www.php.net/manual/en/class.normalizer.php).

### Email Input

[](#email-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\EmailInput;

class ContactForm extends Form {
    public readonly EmailInput $email;

    public function __construct() {
        $this->email = new EmailInput();
        // Note: setLowerCaseDomain(true) is also default
    }
}

$form = new ContactForm();
$form->email->setValue('User@EXAMPLE.COM');
echo $form->email->getValue();    // "User@example.com"
echo $form->email->getEmail();    // "User@example.com" or null if invalid
```

### URL Input

[](#url-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\UrlInput;

class ProfileForm extends Form {
    public readonly UrlInput $website;

    public function __construct() {
        $this->website = new UrlInput();
    }
}

$form = new ProfileForm();
$form->website->setValue('https://example.com/path?query=value');
$form->validate();  // true
echo $form->website->getUrl();  // "https://example.com/path?query=value"

// Invalid URL
$form->website->setValue('not a url');
$form->validate();  // false - InvalidUrlError
```

UrlInput validates URLs using PHP's `FILTER_VALIDATE_URL`. It converts full-width characters to half-width and trims whitespace. You can restrict allowed schemes (default: http, https).

### Telephone Input

[](#telephone-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TelInput;

class ContactForm extends Form {
    public readonly TelInput $phone;

    public function __construct() {
        $this->phone = new TelInput();
    }
}

$form = new ContactForm();

// International format (E.164)
$form->phone->setValue('+81-90-1234-5678');
echo $form->phone->getValue();   // "+819012345678" (E.164 format)

// Domestic format
$form->phone->setValue('090-1234-5678');
echo $form->phone->getValue();   // "09012345678" (domestic, digits only)
```

**TelInput** strips all formatting characters (spaces, hyphens, parentheses) but preserves a leading `+` for international E.164 format. It does **NOT** validate phone numbers - use libphonenumber for validation and formatting:

```
use Coroq\Form\FormItem\TelInput;
use libphonenumber\PhoneNumberUtil;
use libphonenumber\PhoneNumberFormat;

$phone = new TelInput();
$phone->setValue('+81-90-1234-5678');
echo $phone->getValue(); // "+819012345678"

// For validation/formatting, use libphonenumber (giggsey/libphonenumber-for-php)
$phoneUtil = PhoneNumberUtil::getInstance();

// Parse with country hint for domestic numbers
$number = $phoneUtil->parse($phone->getValue(), 'JP');

// Or parse E.164 directly (no country hint needed)
$number = $phoneUtil->parse('+819012345678');

// Format for display
$formatted = $phoneUtil->format($number, PhoneNumberFormat::NATIONAL);
// "090-1234-5678"
```

### Select Input

[](#select-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\Select;

class SettingsForm extends Form {
    public readonly Select $country;

    public function __construct() {
        $this->country = (new Select())
            ->setOptions([
                'us' => 'United States',
                'jp' => 'Japan',
                'uk' => 'United Kingdom'
            ]);
    }
}

$form = new SettingsForm();
$form->country->setValue('jp');
echo $form->country->getValue();          // "jp"
echo $form->country->getSelectedLabel();  // "Japan"
```

### Multi-Select Input

[](#multi-select-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\MultiSelect;

class SurveyForm extends Form {
    public readonly MultiSelect $hobbies;

    public function __construct() {
        $this->hobbies = (new MultiSelect())
            ->setOptions([
                'sports' => 'Sports',
                'music' => 'Music',
                'reading' => 'Reading',
                'gaming' => 'Gaming'
            ])
            ->setMinCount(1)
            ->setMaxCount(3);
    }
}

$form = new SurveyForm();
$form->hobbies->setValue(['sports', 'music']);
print_r($form->hobbies->getValue());         // ['sports', 'music']
print_r($form->hobbies->getSelectedLabel()); // ['Sports', 'Music']
```

### Number Inputs

[](#number-inputs)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\NumberInput;
use Coroq\Form\FormItem\IntegerInput;

class ProductForm extends Form {
    public readonly NumberInput $price;
    public readonly IntegerInput $quantity;

    public function __construct() {
        $this->price = (new NumberInput())
            ->setMin(0.01)
            ->setMax(999999.99);

        $this->quantity = (new IntegerInput())
            ->setMin(1)
            ->setMax(100);
    }
}

$form = new ProductForm();
$form->price->setValue('１２３．４５');  // Full-width input
echo $form->price->getValue();           // "123.45" (normalized)
echo $form->price->getNumber();          // 123.45 (float)
echo $form->quantity->getInteger();      // 42 or null
```

**Note on IntegerInput limits:**

- IntegerInput validates values against PHP\_INT\_MIN to PHP\_INT\_MAX range
- Values outside this range (e.g., very large database bigint IDs) will fail validation with TooLargeError/TooSmallError
- `getInteger()` returns null for values outside PHP int range
- For very large integers (e.g., Twitter snowflake IDs, large database bigints), use TextInput instead

### Date Input

[](#date-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\DateInput;

class EventForm extends Form {
    public readonly DateInput $eventDate;

    public function __construct() {
        $this->eventDate = new DateInput();
    }
}

$form = new EventForm();
$form->eventDate->setValue('2000/1/15');
echo $form->eventDate->getValue();              // "2000-01-15" (normalized)
$dt = $form->eventDate->getDateTime();          // DateTime object or null
$dti = $form->eventDate->getDateTimeImmutable(); // DateTimeImmutable or null
```

### Boolean Input

[](#boolean-input)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\BooleanInput;

class RegistrationForm extends Form {
    public readonly BooleanInput $agreeToTerms;
    public readonly BooleanInput $newsletter;

    public function __construct() {
        // Required boolean - user must accept (value must be truthy)
        $this->agreeToTerms = new BooleanInput();

        // Optional boolean - can be true or false
        $this->newsletter = (new BooleanInput())
            ->setRequired(false);
    }
}

$form = new RegistrationForm();

// User didn't check the checkbox (empty/false)
$form->setValue(['agreeToTerms' => '', 'newsletter' => '']);
$form->validate();  // FAILS - agreeToTerms is required but empty
$form->agreeToTerms->getBoolean();  // false
$form->newsletter->getBoolean();    // false

// User checked both checkboxes
$form->setValue(['agreeToTerms' => 'on', 'newsletter' => '1']);
$form->validate();  // PASSES
$form->agreeToTerms->getBoolean();  // true
$form->newsletter->getBoolean();    // true

// From API with actual booleans
$form->setValue(['agreeToTerms' => true, 'newsletter' => false]);
$form->agreeToTerms->getBoolean();  // true
$form->newsletter->getBoolean();    // false
```

BooleanInput considers only `''`, `null`, and `false` as "empty" (false). Everything else including `'0'`, `0`, `'off'`, `'no'` is considered "not empty" (true).

### File Input

[](#file-input)

FileInput validates files by their path. It checks file size, MIME type, and extension. This library **does not** handle HTTP file uploads ($\_FILES) - that should be done by your HTTP layer.

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\FileInput;

class UploadForm extends Form {
    public readonly FileInput $avatar;
    public readonly FileInput $document;

    public function __construct() {
        // Image upload with size and type restrictions
        $this->avatar = (new FileInput())
            ->setRequired(false)  // Usually optional
            ->setMaxSize(5 * 1024 * 1024)  // 5 MB
            ->setAllowedMimeTypes(['image/jpeg', 'image/png', 'image/gif'])
            ->setAllowedExtensions(['jpg', 'jpeg', 'png', 'gif']);

        // Document upload
        $this->document = (new FileInput())
            ->setRequired(false)
            ->setMaxSize(10 * 1024 * 1024)  // 10 MB
            ->setMinSize(1024)  // 1 KB minimum
            ->setAllowedMimeTypes(['application/pdf'])
            ->setAllowedExtensions(['pdf']);
    }
}

// Your HTTP layer moves uploaded file to temporary storage
$tempPath = '/app/storage/temp/' . uniqid() . '.jpg';
move_uploaded_file($_FILES['avatar']['tmp_name'], $tempPath);

// FileInput validates the file at the path
$form = new UploadForm();
$form->avatar->setValue($tempPath);

if ($form->validate()) {
    $filePath = $form->avatar->getValue();
    // Move to permanent storage, save file ID, etc.
}
```

FileInput works with file paths (strings), not $\_FILES arrays. For tracking uploaded files across form submissions, use a separate TextInput for file ID.

Example upload flow:

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\FileInput;
use Coroq\Form\FormItem\TextInput;

class ProfileForm extends Form {
    public readonly FileInput $newAvatar;  // Optional - for new uploads
    public readonly TextInput $avatarId;   // Required - tracks saved file
}

// First submit: user uploads new file
if ($_FILES['newAvatar']['tmp_name']) {
    $tempPath = moveToTempStorage($_FILES['newAvatar']);
    $form->newAvatar->setValue($tempPath);
}

if ($form->validate()) {
    if ($form->newAvatar->getValue()) {
        // Save new file and get ID
        $avatarId = $storage->save($form->newAvatar->getValue());
        $form->avatarId->setValue($avatarId);
    }
}

// Resubmission after error: newAvatar is empty, avatarId still has value
```

Nested Forms
------------

[](#nested-forms)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\EmailInput;

class AddressForm extends Form {
    public readonly TextInput $street;
    public readonly TextInput $city;
    public readonly TextInput $postal;

    public function __construct() {
        $this->street = new TextInput();
        $this->city = new TextInput();
        $this->postal = new TextInput();
    }
}

class UserForm extends Form {
    public readonly TextInput $name;
    public readonly EmailInput $email;
    public readonly AddressForm $address;

    public function __construct() {
        $this->name = new TextInput();
        $this->email = new EmailInput();
        $this->address = new AddressForm();
    }
}

$form = new UserForm();
$form->setValue([
    'name' => 'Taro Yamada',
    'email' => 'taro@example.com',
    'address' => [
        'street' => '1-1-1 Shibuya',
        'city' => 'Tokyo',
        'postal' => '150-0001'
    ]
]);

// Full IDE support for nested access
echo $form->address->street->getValue();
echo $form->address->postal->getValue();

// Hierarchical values
$values = $form->getValue();
/*
[
  'name' => 'Taro Yamada',
  'email' => 'taro@example.com',
  'address' => [
    'street' => '1-1-1 Shibuya',
    'city' => 'Tokyo',
    'postal' => '150-0001'
  ]
]
*/

// Alternative: getItem() method
$addressForm = $form->getItem('address');  // Returns FormInterface
if ($addressForm instanceof FormInterface) {
    $street = $addressForm->getItem('street');
    echo $street->getValue();
}
```

Repeating Forms
---------------

[](#repeating-forms)

`RepeatingForm` manages dynamic lists of form items using a factory pattern:

```
use Coroq\Form\Form;
use Coroq\Form\RepeatingForm;
use Coroq\Form\FormItem\EmailInput;

class ContactForm extends Form {
    public readonly RepeatingForm $emails;

    public function __construct() {
        $this->emails = (new RepeatingForm())->setFactory(function(int $index) {
            $email = new EmailInput();
            $email->setRequired($index === 0);
            $email->setLabel($index === 0 ? 'Primary Email' : 'Additional Email');
            return $email;
        });

        $this->emails->setMinItemCount(3);
        $this->emails->setMaxItemCount(5);
    }
}

$form = new ContactForm();
$form->setValue(['emails' => ['user@example.com', 'alt@example.com']]);

if ($form->validate()) {
    // Access items by index
    echo $form->emails->getItem(0)->getValue();  // 'user@example.com'
    echo $form->emails->getItem(1)->getValue();  // 'alt@example.com'
    echo $form->emails->getItem(2)->getValue();  // '' (minItemCount=3)

    // Get all values
    print_r($form->emails->getValue());
    // ['user@example.com', 'alt@example.com', '']

    // Get only filled values
    print_r($form->emails->getFilledValue());
    // [0 => 'user@example.com', 1 => 'alt@example.com']
}
```

### Factory Function

[](#factory-function)

The factory function receives an index parameter:

```
use Coroq\Form\RepeatingForm;
use Coroq\Form\FormItem\TelInput;

// Complex business logic
$phoneNumbers = (new RepeatingForm())->setFactory(function(int $index) {
    $phone = new TelInput();

    if ($index === 0) {
        $phone->setLabel('Primary Phone')->setRequired(true);
    } elseif ($index === 1) {
        $phone->setLabel('Mobile Phone')->setRequired(false);
    } else {
        $phone->setLabel('Emergency Contact #' . ($index - 1))->setRequired(false);
    }

    return $phone;
});

$phoneNumbers->setMinItemCount(2);   // Always show primary + mobile
$phoneNumbers->setMaxItemCount(10);  // Max 10 total
```

### Nested Repeating Forms

[](#nested-repeating-forms)

RepeatingForm can contain other forms, including nested RepeatingForms:

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class AddressForm extends Form {
    public readonly TextInput $street;
    public readonly TextInput $city;
    public readonly TextInput $postal;

    public function __construct() {
        $this->street = new TextInput();
        $this->city = new TextInput();
        $this->postal = new TextInput();
    }
}

class UserForm extends Form {
    public readonly RepeatingForm $addresses;

    public function __construct() {
        // RepeatingForm of nested Forms
        $this->addresses = (new RepeatingForm())->setFactory(function(int $index) {
            $form = new AddressForm();
            // First address required, others optional
            $form->setRequired($index === 0);
            return $form;
        });

        $this->addresses->setMinItemCount(2);  // Show 2 address forms
    }
}

$form = new UserForm();
$form->setValue([
    'addresses' => [
        ['street' => '1-1-1 Shibuya', 'city' => 'Tokyo', 'postal' => '150-0001'],
        ['street' => '2-2-2 Umeda', 'city' => 'Osaka', 'postal' => '530-0001'],
    ]
]);

// Access nested values
echo $form->addresses->getItem(0)->street->getValue();  // '1-1-1 Shibuya'
echo $form->addresses->getItem(1)->city->getValue();    // 'Osaka'
```

Items can be added programmatically:

```
use Coroq\Form\RepeatingForm;
use Coroq\Form\FormItem\EmailInput;

$emails = (new RepeatingForm())->setFactory(fn($i) => new EmailInput());
$emails->addItem('user1@example.com');
$emails->addItem('user2@example.com');
echo $emails->count();  // 2
```

Derived Inputs
--------------

[](#derived-inputs)

Derived inputs are special form items that depend on other form items. They can:

- **Calculate values** from source inputs (e.g., full name from first + last name)
- **Perform cross-field validation** (e.g., password confirmation matching)
- **Track external validation** results (e.g., authentication status)

**Key Properties:**

- Always **read-only** - their value comes from sources, not user input
- Return `null` if any source input fails validation
- Can have both value calculation (`setValueCalculator`) and validation (`setValidator`)

### Basic Example: Calculated Values

[](#basic-example-calculated-values)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\Derived;

class UserForm extends Form {
    public readonly TextInput $firstName;
    public readonly TextInput $lastName;
    public readonly Derived $fullName;

    public function __construct() {
        $this->firstName = new TextInput();
        $this->lastName = new TextInput();

        // Derived field calculates value from sources
        $this->fullName = (new Derived())
            ->setValueCalculator(fn($first, $last) => $first . ' ' . $last)
            ->addSource($this->firstName)
            ->addSource($this->lastName);
    }
}

$form = new UserForm();
$form->setValue([
    'firstName' => 'Taro',
    'lastName' => 'Yamada'
]);

echo $form->fullName->getValue(); // "Taro Yamada"

// If a source is invalid, getValue() returns null
$form->firstName->setValue('');  // Empty (fails validation if required)
echo $form->fullName->getValue(); // null
```

### More Calculation Examples

[](#more-calculation-examples)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\NumberInput;
use Coroq\Form\FormItem\IntegerInput;
use Coroq\Form\FormItem\Derived;

class OrderForm extends Form {
    public readonly NumberInput $price;
    public readonly IntegerInput $quantity;
    public readonly Derived $total;

    public function __construct() {
        $this->price = new NumberInput();
        $this->quantity = new IntegerInput();

        // Calculate total price
        $this->total = (new Derived())
            ->setValueCalculator(fn($price, $quantity) => $price * $quantity)
            ->addSource($this->price)
            ->addSource($this->quantity);
    }
}
```

### Cross-Field Validation

[](#cross-field-validation)

Use `setValidator()` to validate relationships between fields. The validator receives:

1. All source values as individual parameters
2. The calculated value as the last parameter (or `null` if no calculator)

The validator returns an `Error` object if invalid, or `null` if valid.

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\Derived;
use Coroq\Form\Error\InvalidError;

class RegistrationForm extends Form {
    public readonly TextInput $password;
    public readonly TextInput $passwordConfirm;
    public readonly Derived $passwordMatch;

    public function __construct() {
        $this->password = (new TextInput())
            ->setMinLength(8);
        $this->passwordConfirm = new TextInput();

        // Validate that passwords match (no value calculator needed)
        $this->passwordMatch = (new Derived())
            ->setValidator(function($password, $confirm, $calculated) {
                // $password = source 1 value
                // $confirm = source 2 value
                // $calculated = null (no setValueCalculator)
                return $password !== $confirm
                    ? new InvalidError($this)
                    : null;
            })
            ->addSource($this->password)
            ->addSource($this->passwordConfirm);
    }
}

$form = new RegistrationForm();
$form->setValue([
    'password' => 'secret123',
    'passwordConfirm' => 'secret456'
]);

if (!$form->validate()) {
    if ($form->passwordMatch->hasError()) {
        echo "Passwords must match";
    }
}
```

**Note:** Derived validation only runs if all source inputs pass their own validation first. If any source fails, the Derived item automatically gets a `SourceItemInvalidError`.

### Combined: Calculation with Validation

[](#combined-calculation-with-validation)

You can use both `setValueCalculator()` and `setValidator()` together:

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\Derived;
use Coroq\Form\Error\TooLongError;

class ProfileForm extends Form {
    public readonly TextInput $firstName;
    public readonly TextInput $lastName;
    public readonly Derived $displayName;

    public function __construct() {
        $this->firstName = new TextInput();
        $this->lastName = new TextInput();

        // Calculate display name and validate its length
        $this->displayName = (new Derived())
            ->setValueCalculator(fn($first, $last) => strtoupper($first . ' ' . $last))
            ->setValidator(function($first, $last, $calculated) {
                // $first = source 1 value
                // $last = source 2 value
                // $calculated = the computed value from setValueCalculator
                return strlen($calculated) > 50
                    ? new TooLongError($this)
                    : null;
            })
            ->addSource($this->firstName)
            ->addSource($this->lastName);
    }
}

$form = new ProfileForm();
$form->setValue(['firstName' => 'Taro', 'lastName' => 'Yamada']);
echo $form->displayName->getValue(); // "TARO YAMADA" (calculated)

// Validation runs on the calculated value
$form->setValue(['firstName' => str_repeat('A', 30), 'lastName' => str_repeat('B', 30)]);
$form->validate(); // Fails - displayName has TooLongError
```

Complete Example
----------------

[](#complete-example)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;
use Coroq\Form\FormItem\EmailInput;
use Coroq\Form\FormItem\IntegerInput;
use Coroq\Form\FormItem\Select;
use Coroq\Form\ErrorMessageFormatter;
use Coroq\Form\Error\EmptyError;
use Coroq\Form\Error\InvalidError;
use Coroq\Form\Error\TooSmallError;

class UserRegistrationForm extends Form {
    public readonly TextInput $name;
    public readonly EmailInput $email;
    public readonly IntegerInput $age;
    public readonly Select $country;

    public function __construct() {
        $this->name = (new TextInput())
            ->setLabel('Name')
            ->setMaxLength(100);

        $this->email = (new EmailInput())
            ->setLabel('Email');

        $this->age = (new IntegerInput())
            ->setLabel('Age')
            ->setRequired(false)  // Make optional
            ->setMin(18)
            ->setMax(120);

        $this->country = (new Select())
            ->setLabel('Country')
            ->setOptions([
                'us' => 'United States',
                'jp' => 'Japan',
                'uk' => 'United Kingdom'
            ]);
    }
}

// Setup error messages
$formatter = new ErrorMessageFormatter();
$formatter->setMessages([
    EmptyError::class => 'This field is required',
    InvalidError::class => 'Invalid value',  // Catch-all for Invalid* errors
    TooSmallError::class => function(TooSmallError $error) {
        return 'Minimum value is ' . $error->formItem->getMin();
    },
]);

// Process form submission
$form = new UserRegistrationForm();
$form->setValue($_POST);

if ($form->validate()) {
    // Get validated data with full type safety
    $name = $form->name->getValue();
    $email = $form->email->getEmail();
    $age = $form->age->getInteger(); // null if not provided
    $country = $form->country->getValue();

    // Save to database
    $db->insert('users', $form->getFilledValue());

    header('Location: /success');
} else {
    // Display errors with IDE support
    foreach ([$form->name, $form->email, $form->age, $form->country] as $field) {
        if ($field->hasError()) {
            echo $field->getLabel() . ': ';
            echo $formatter->format($field->getError());
            echo "\n";
        }
    }
}
```

Configuration
-------------

[](#configuration)

### UTF-8 Invalid Character Handling

[](#utf-8-invalid-character-handling)

This library assumes all input is UTF-8 encoded. Invalid UTF-8 byte sequences are automatically replaced with a substitute character during filtering.

By default, PHP uses `?` (U+003F QUESTION MARK) as the substitute character. For better visibility of data corruption, it's recommended to use `�` (U+FFFD REPLACEMENT CHARACTER) instead by configuring it in your application bootstrap:

```
// Recommended: Use Unicode Replacement Character for invalid UTF-8 bytes
mb_substitute_character(0xFFFD);  // U+FFFD: �
```

Alternative configurations:

```
mb_substitute_character('none');   // Remove invalid bytes silently
mb_substitute_character('long');   // Use U+XXXX notation
mb_substitute_character('entity'); // Use &#XXXX; HTML entities
```

See [mb\_substitute\_character documentation](https://www.php.net/manual/en/function.mb-substitute-character.php) for more options.

API Reference
-------------

[](#api-reference)

### Form

[](#form)

```
use Coroq\Form\Form;
use Coroq\Form\FormItem\TextInput;

class MyForm extends Form {
    public readonly TextInput $field;
    // Define form items as typed readonly properties
}

$form = new MyForm();

// Values
$form->setValue(array $data);
$values = $form->getValue();              // All enabled items (raw values)
$parsed = $form->getParsedValue();        // All enabled items (parsed values)
$filled = $form->getFilledValue();        // Non-empty values only (raw)
$filledParsed = $form->getFilledParsedValue();  // Non-empty values (parsed)

// Validation
$valid = $form->validate();
$hasError = $form->hasError();
$errors = $form->getError();              // Array of errors

// Item access
$item = $form->getItem(mixed $name);      // Get item by name

// State
$form->setRequired(bool);
$form->setReadOnly(bool);
$form->setDisabled(bool);

// Utility
$form->clear();
$isEmpty = $form->isEmpty();
```

### Input

[](#input)

All input types extend `Input` and support:

```
use Coroq\Form\FormItem\TextInput;

$input = new TextInput();

// Values
$input->setValue(mixed $value);
$value = $input->getValue();              // Raw value (string)
$parsed = $input->getParsedValue();       // Parsed value (int, bool, DateTime, etc.) or null if empty/invalid
$input->clear();

// Validation
$valid = $input->validate();
$error = $input->getError();             // Error object or null
$hasError = $input->hasError();

// State
$input->setRequired(bool);
$input->setReadOnly(bool);
$input->setDisabled(bool);
$input->setLabel(string);

// Custom validation and error handling
$input->setValidator(?callable);         // fn($formItem, $value): ?Error
$input->setErrorCustomizer(?\Closure);   // fn($error, $formItem): Error

// Checks
$isEmpty = $input->isEmpty();
$isRequired = $input->isRequired();
$isReadOnly = $input->isReadOnly();
$isDisabled = $input->isDisabled();
```

### Text Input

[](#text-input-1)

```
use Coroq\Form\FormItem\TextInput;

$text = new TextInput();
$text->setMinLength(int);
$text->setMaxLength(int);
$text->setPattern(string);               // Regex
$text->setTrim(string);                  // LEFT, RIGHT, BOTH, null
$text->setCase(int);                     // UPPER, LOWER, TITLE
$text->setMb(string);                    // mb_convert_kana option
$text->setUnicodeNormalization(string);  // NFC, NFD, NFKC, NFKD, null
$text->setMultiline(bool);
$text->setNoWhitespace(bool);
$text->setNoControl(bool);
```

### Select/MultiSelect

[](#selectmultiselect)

```
use Coroq\Form\FormItem\Select;
use Coroq\Form\FormItem\MultiSelect;

$select = new Select();
$select->setOptions(array);
$label = $select->getSelectedLabel();    // string|null

$multi = new MultiSelect();
$multi->setOptions(array);
$multi->setMinCount(int);
$multi->setMaxCount(int);
$labels = $multi->getSelectedLabel();    // array
```

### Number Inputs

[](#number-inputs-1)

```
use Coroq\Form\FormItem\NumberInput;
use Coroq\Form\FormItem\IntegerInput;

$number = new NumberInput();
$number->setMin(string);
$number->setMax(string);
$value = $number->getNumber();           // float|null

$int = new IntegerInput();
$int->setMin(string);
$int->setMax(string);
$value = $int->getInteger();             // int|null
```

### Boolean Input

[](#boolean-input-1)

```
use Coroq\Form\FormItem\BooleanInput;

$bool = new BooleanInput();
$value = $bool->getBoolean();            // bool (true if not empty, false if empty)
// Note: Only '', null, and false are considered empty
```

### File Input

[](#file-input-1)

```
use Coroq\Form\FormItem\FileInput;

$file = new FileInput();
$file->setMaxSize(int);                  // Max file size in bytes
$file->setMinSize(int);                  // Min file size in bytes
$file->setAllowedMimeTypes(array);       // e.g., ['image/jpeg', 'image/png']
$file->setAllowedExtensions(array);      // e.g., ['jpg', 'png', 'pdf']
$path = $file->getValue();               // string|null - file path
// Note: Usually setRequired(false) - file might already be uploaded
```

### RepeatingForm

[](#repeatingform)

```
use Coroq\Form\RepeatingForm;
use Coroq\Form\FormItem\EmailInput;

// Create with factory
$repeating = (new RepeatingForm())->setFactory(function(int $index) {
    return (new EmailInput())->setRequired($index === 0);
});

// Structural constraints
$repeating->setMinItemCount(int);        // Always have at least N items
$repeating->setMaxItemCount(int);        // Never exceed N items
$min = $repeating->getMinItemCount();
$max = $repeating->getMaxItemCount();

// Values
$repeating->setValue(array);             // Recreates all items from factory
$values = $repeating->getValue();        // Array of values (int-indexed)
$parsed = $repeating->getParsedValue();  // Array of parsed values
$filled = $repeating->getFilledValue();  // Non-empty values only
$filledParsed = $repeating->getFilledParsedValue();

// Item access
$item = $repeating->getItem(int);        // Get item at index (or null)
$items = $repeating->getItems();         // Get all items
$count = $repeating->count();            // Number of items

// Manual item addition
$item = $repeating->addItem(?string);    // Add new item, returns the item

// Validation
$valid = $repeating->validate();         // Validates each item
$errors = $repeating->getError();        // Array of errors (int-indexed)
$hasError = $repeating->hasError();

// State (same as Form/Input)
$repeating->setRequired(bool);
$repeating->setReadOnly(bool);
$repeating->setDisabled(bool);
$repeating->clear();                     // Clears all item values
$isEmpty = $repeating->isEmpty();
```

License
-------

[](#license)

MIT

###  Health Score

49

—

FairBetter than 95% of packages

Maintenance83

Actively maintained with recent releases

Popularity11

Limited adoption so far

Community12

Small or concentrated contributor base

Maturity77

Established project with proven stability

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~114 days

Recently: every ~23 days

Total

25

Last Release

86d ago

Major Versions

0.3.1 → 1.0.02021-03-01

1.1.0 → 2.0.02023-04-16

2.1.0 → 3.0-alpha12023-08-30

PHP version history (4 changes)0.1.0PHP &gt;=5.4

2.0.0PHP &gt;=7.2

3.0-alpha1PHP &gt;=8.0

v3.0.0PHP ^8.0

### Community

Maintainers

![](https://www.gravatar.com/avatar/addac0ea12d91786ce888c91ef7de6525bc17252c702cd7e48fe64ab547c012e?d=identicon)[ozami](/maintainers/ozami)

---

Top Contributors

[![ozami](https://avatars.githubusercontent.com/u/170309?v=4)](https://github.com/ozami "ozami (91 commits)")

---

Tags

validationfilterforminputtype-safenested-form

###  Code Quality

TestsPHPUnit

### Embed Badge

![Health badge](/badges/coroq-form/health.svg)

```
[![Health](https://phpackages.com/badges/coroq-form/health.svg)](https://phpackages.com/packages/coroq-form)
```

###  Alternatives

[htmlawed/htmlawed

Official htmLawed PHP library for HTML filtering

401.1M9](/packages/htmlawed-htmlawed)[optimistdigital/nova-input-filter

An input filter for Laravel Nova

24550.6k2](/packages/optimistdigital-nova-input-filter)[digital-creative/nova-range-input-filter

A Laravel Nova range input filter.

18209.3k1](/packages/digital-creative-nova-range-input-filter)[codewithdennis/filament-price-filter

A simple and customizable price filter for FilamentPHP, allowing users to easily refine results based on specified price ranges.

163.2k](/packages/codewithdennis-filament-price-filter)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
