PHP code example of coroq / form

1. Go to this page and download the library: Download coroq/form library. Choose the download type require.

2. Extract the ZIP file and open the index.php.

3. Add this code to the index.php.
    
        
<?php
require_once('vendor/autoload.php');

/* Start to develop here. Best regards https://php-download.com/ */

    

coroq / form example snippets


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
}

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();
}

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

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

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

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

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"

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()

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()

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]
}

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

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('[email protected]');
$email->validate(); // Fails - custom validator returns error

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

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

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
}

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 

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;
    }
}

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"

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

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

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)

// Start with preset base messages
$messages = [
    EmptyError::class => 'This field is ',
    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);

$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");

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 r->formItem->getMin();
    },
];

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

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';
    },
];

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' => '[email protected]',
    'age' => '25',
    'newsletter' => 'on',
    'notes' => ''
]);

// getValue() - raw strings, 

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

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

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('[email protected]');
echo $form->email->getValue();    // "[email protected]"
echo $form->email->getEmail();    // "[email protected]" or null if invalid

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

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)

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"

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"

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']

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('123.45');  // Full-width input
echo $form->price->getValue();           // "123.45" (normalized)
echo $form->price->getNumber();          // 123.45 (float)
echo $form->quantity->getInteger();      // 42 or null

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

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 

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.
}

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

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' => '[email protected]',
    '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' => '[email protected]',
  '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();
}

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' => ['[email protected]', '[email protected]']]);

if ($form->validate()) {
    // Access items by index
    echo $form->emails->getItem(0)->getValue();  // '[email protected]'
    echo $form->emails->getItem(1)->getValue();  // '[email protected]'
    echo $form->emails->getItem(2)->getValue();  // '' (minItemCount=3)

    // Get all values
    print_r($form->emails->getValue());
    // ['[email protected]', '[email protected]', '']

    // Get only filled values
    print_r($form->emails->getFilledValue());
    // [0 => '[email protected]', 1 => '[email protected]']
}

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

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 

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

$emails = (new RepeatingForm())->setFactory(fn($i) => new EmailInput());
$emails->addItem('[email protected]');
$emails->addItem('[email protected]');
echo $emails->count();  // 2

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 

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

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";
    }
}

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

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 

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

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

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

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

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

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

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

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

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

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