hschletz/formotron

Form processing and validation

0.2 2025-09-01 18:43 UTC

This package is auto-updated.

Last update: 2025-09-01 18:51:19 UTC


README

Formotron processes an array of input data into an application-defined data object. Processing rules are defined by the data object itself, via property types and custom attributes.

This documentation focuses on form data, but input data can be any associative array, including but not limited to:

  • submitted form data ($_POST)
  • URI query parameters ($_GET)
  • database records
  • decoded JSON data

Key features

  • Easy to use, concise, reusable and self-documenting: Processing rules are defined by the data object itself, having all logic gathered in a single place.
  • Always valid: The data object is instantiated, populated and validated in a single step. Invalid data will cause an exception. Calling code never sees an object in an invalid state.
  • Extensible: Define your own processing rules and attach them to the data object via attributes.
  • Integrates seamlessly: Write your own validators or use external validation libraries. Message formatting is left to the application, allowing usage of the applications's established i18n framework.
  • DI friendly: Data processors and validators are instantiated from a container, allowing them to have arbitrary dependencies injected.
  • Robust: Formotron leverages PHP features like property types, attributes and enums to have validation rules enforced at language level where possible. Formotron does not rely on docblock comments.

Installation

Add Formotron to your project via composer:

composer require hschletz/formotron

Formotron requires an adequately configured PSR-11 compatible container to instantiate transformers, validators and preprocessors. The concrete implementation does not matter as long as it implements Psr\Container\ContainerInterface. If your application already uses a container, you can use just that.

Usage

The main interface is the Formotron\DataProcessor class. First, set up a PSR-11 container and pass it to the constructor:

$dataProcessor = new \Formotron\DataProcessor($container);

Many containers are configured to resolve Psr\Container\ContainerInterface to itself. With proper configuration (autowiring or a factory), the form processor can be instantiated from the container:

$dataProcessor = $container->get(\Formotron\DataProcessor::class);

In practice, the form processor will likely not be instantiated directly, but injected into a class by the container:

// MyClass will be instantiated by the container
class MyClass
{
    public function __construct(private \Formotron\DataProcessor $dataProcessor)
    {
    }
}

The DataProcessor class has a public method process() which receives the input data array and the name of a data object class. It returns a fully populated instance of that class or throws a Formotron\AssertionFailedException if input data is invalid.

$dataObject = $dataProcessor->process($_POST, FormData::class);

The iterate() method works similar, but requires an iterable of arrays (for example, a database result set). It yields a populated data object instance for each element in the input iterable.

foreach ($dataProcessor->iterate($resultSet, RowData::class) as $rowObject) {
    ...
}

Defining a data object

A data object is an object whose properties are populated from a corresponding input field. By default, for every property defined in the class, a key with the same name must be present in the input array. Its value will be assigned to the property. Example:

class DataObject
{
    public $foo;
    public $bar;
}

$input = [
    'foo' => 'value1',
    'bar' => 'value2',
];
$dataObject = $dataProcessor->process($input, DataObject::class);

The data object is constructed via reflection. Properties need not be declared public. Protected and private properties can be populated too. A constructor is bypassed.

It's strongly recommended to declare a data type for properties to enable additional functionality and to avoid unexpected behavior. If a distinct type is not suitable, a property can be explicitly declared as mixed. This has advantages over untyped properties in regards to handling default values (see below).

Data type handling

All types can be declared nullable (i.e. ?string), allowing NULL values in addition to the type-specific rules below.

string

String properties accept any input value that can be safely converted:

  • string
  • int, float (default PHP conversion, not locale-specific)
  • objects with a __toString() method

int

Integer properties accept int values and strings that can be parsed as an integer by strict rules. Only decimal digits and an optional sign (+/-) at the beginning of a string are allowed. Every other input value is rejected, even if PHP's parsing rules would allow it.

bool

Bool properties accept only true or false. Formotron does not attempt to cast other values to bool because PHP's casting rules are error prone and may not give the desired result. If you have non-boolean input values, they need to be transformed first, preferrably with strict rules that match the particular use case.

array

Array properties accept only array input values. Types of array values are not checked.

enum

Enum properties accept enum instances, as well as strings/integers that match a defined enum value. This is very useful for constraining values to a limited set (for example, radio buttons and dropdowns with a static set of values). Valid input values are defined by the type of enum:

  • Basic enums accept the symbolic name (case sensitive).
  • Backed enums accept the symbolic name (case sensitive) by default. If the Formotron\Attribute\UseBackingValue attribute is set on a property, only the backing value is accepted, but not the name.
enum Basic
{
    case Foo;
    case Bar;
}

enum Backed: string
{
    case Foo = 'foo';
    case Bar = 'bar';
}

class DataObject
{
    public Basic $basic; // valid values: 'Foo', 'Bar', Basic::Foo, Basic::Bar

    public Backed $backedUsingName; // valid values: 'Foo', 'Bar', Backed::Foo, Backed::Bar

    #[UseBackingValue]
    public Backed $backedUsingValue; // valid values: 'foo', 'bar', Backed::Foo, Backed::Bar
}

class

Properties of a class type accept only instances of that class or a subclass.

interface

Properties of an interface type accept only instances of a class that implements the interface.

mixed

Properties declared as mixed or without a type accept any input value.

Other types

Other types are not supported yet. They will be implemented in the future. Until then, use mixed for any property with an unsupported type.

Missing input fields and default values

The input array must contain keys for all properties defined in the data object. Arrays with missing keys are invalid. This can be changed by setting a default value:

class DataObject
{
    public string $foo = 'bar';
}

$dataObject = $dataProcessor->process([], DataObject::class);
// valid, $foo will be 'bar'.

If the key is present, its value will be processed as usual.

For untyped properties, a limitation in the Reflection API prevents distinguishing between an explicit and an implicit default of NULL. To avoid bugs resulting from missing keys, Formotron does not support a default of NULL for untyped properties, even where PHP would:

class DataObject
{
    public $foo; // implicit default NULL
    public $bar = null;
}

$dataObject = $dataProcessor->process([], DataObject::class);
// Both keys would be treated as missing.

If you need a default of NULL and cannot declare a specific type, declare the property as mixed which does not suffer from this limitation.

Reporting the presence of a key only

A default value is not always an adequate solution for a missing key. Some data structures have fields that convey a boolean-like value, but only include the field if it deviates from an implied default. The real information is the presence of the key; the actual value is meaningless. HTML checkboxes are a well-known example for this pattern.

Providing a default value in the data object would work, but may not be the correct choice from a purely logical point of view. Somtimes there is no reasonable default, or the actual default may contradict the logical default (like a default of false for a checkbox that is initially checked).

The KeyOnly Attribute is a better way to express the intent in these situations. It results in a bool value that indicates the presence of the key in the input array. The corresponding value is discarded.

class DataObject
{
    #[Formotron\Attribute\KeyOnly]
    public bool $checked;
}

$dataProcessor->process([], DataObject::class); // $checked will be FALSE
$dataProcessor->process(['checked' => ''], DataObject::class); // $checked will be TRUE

If the property has a default value, the KeyOnly Attribute takes precedence, i.e. the default value is ignored. The default is only relevant if the object is instantiated by other means. This is confusing though and should be avoided if possible.

If the property should receive other values than true/false, a transformer can be applied (see below).

Unmappable input fields

Input arrays with fields that cannot be mapped to a property in the data object are invalid. This helps preventing bugs where input data processing may be incomplete. If you need to ignore unmappable keys, remove them from the input array first.

Using different names for keys and properties

Sometimes, the direct mapping between property names and input array keys may not be appropriate. A different name may be more meaningful in a different context. The mapping can be changed by setting the Key attribute on a property.

use Formotron\Attribute\Key;

class DataObject
{
    #[Key('bar')]
    public string $foo;
}

$dataObject = $dataProcessor->process(['bar' => 'baz'], DataObject::class);

In this example, $foo will receive its value from the bar field. foo is now an invalid key, unless mapped to a different property. This may become confusing though, and is not recommended.

Transforming values

Values from the input array may not be suitable for in-application representation. Further processing is often necessary, like

  • trimming surrounding whitespace from a string
  • converting empty strings to NULL
  • converting a timestamp string to a DateTime or DateTimeImmutable object
  • fetching an object from a database using the input value as key

This is supported by attaching a transformer to a property. A transformer is an attribute implementing either the Formotron\Attribute\TransformerAttribute or the Formotron\Attribute\TransformerServiceAttribute interface. Implementations can do anything with the input value: leave it as is, modify it, throw an exception, ...

Because validation occurs only after transformation, transformers must account for potentially invalid input values. The returned value is still subject to the property's validation rules.

Only one transformer can be attached to a single property. Otherwise the order of execution could not be relied upon, and the outcome would become hard to predict. If you need multple transformations (for example, trim whitespace first, only then convert an empty string to NULL), write a transformer that does all transformations in a single step.

Transformers without dependencies

Transformers which can be instantiated directly implement the Formotron\Attribute\TransformerAttribute interface. Like any other attribute class, they can take arguments that are passed to its constructor.

#[Attribute(Attribute::TARGET_PROPERTY)]
class ToBool implements Formotron\Attribute\TransformerAttribute
{
    public function __construct(private mixed $trueValue, private mixed $falseValue)
    {
        assert($trueValue !== $falseValue);
    }

    #[Override]
    public function transform(mixed $value): mixed
    {
        return match ($value) {
            $this->trueValue => true,
            $this->falseValue => false,
        };
    }
}

class DataObject
{
    // input values 't' and 'f' will be transformed to a boolean,
    // other values lead to an error.
    #[ToBool(trueValue: 't', falseValue: 'f')]
    public bool $foo;
}

Transformers instantiated from a container

When the transformation must be done in a class instantiated from the container (typically to have dependencies injected), that class must implement the Formotron\Transformer interface:

// The container must resolve UserMapper::class to an instance of this class.
class UserMapper implements Formotron\Transformer
{
    // The Database object will be injected by the container.
    public function __construct(private Database $database) {}

    #[Override]
    public function transform(mixed $value, array $args): mixed
    {
        assert(!empty($args['role']))

        $user = $this->database->fetchOne(
            'SELECT * FROM users WHERE id = ? AND role = ?',
            [$value, $args['role']]
        );
        if (!$user) {
            throw new InvalidArgumentException('invalid user or wrong role')
        }
        // somehow convert $user to a User object
        ...
        return $userObject;
    }
}

This service cannot be defined as an attribute, because attributes receive their arguments through their constructor, which this class uses for its dependencies. A separate attribute class is required which must implement the Formotron\Attribute\TransformerServiceAttribute interface. A default implementation exists, Formotron\Attribute\Transform, which requires the service name as its first argument, and has additional arguments passed as the second parameter to the service's transform() method.

class DataObject
{
    #[Formotron\Attribute\Transform(UserMapper::class, role: 'admin')]
    public User $user;
}

When using extra arguments, it is often better to define the attribute manually. See "Passing extra arguments to attributes" below.

#[Attribute(Attribute::TARGET_PROPERTY)]
class MapUser implements Formotron\Attribute\TransformerServiceAttribute
{
    public function __construct(private string $role) {}

    #[Override]
    public function getServiceName(): string
    {
        return UserMapper::class;
    }

    #[Override]
    public function getArguments(): array
    {
        // ['role' => $this->role]
        return get_object_vars($this);
    }
}

class DataObject
{
    #[MapUser('admin')]
    public User $user;
}

Validating values

Input data often comes from an untrusted source and must be validated before being consumed by the application. Even with trusted sources, sanity checks improve application robustness.

Basic validation is already achieved by declaring properties with a datatype. Formotron (and, as a last resort, PHP itself) will guarantee that the property is populated with a value that fits its datatype, or can be safely converted.

Validation often goes beyond simple type checks. An input value for a string property may be a string, but not satisfy a length constraint. A value may be constrained to a list of valid values that, unlike enums, is determined dynamically at runtime.

Validation rules can become rather complex. Arbitrary rules can be checked by a validator. A validator is an attribute implementing either the Formotron\Attribute\ValidatorAttribute or the Formotron\Attribute\ValidatorServiceAttribute interface. The validator receives the input value with all transformations already applied, and should throw an exception if it does not meet the criteria for valid values.

Exceptions are currently not handled by Formotron (i.e. they bubble up to the calling code), and processing will stop at the first exception. Support for full validation is planned for the future.

Multiple validators can be attached to a single property, even multiple validators of the same type but with different parameters, but the order of execution must not be relied upon. If order is significant, write a validator that does all necessary checks in a single step.

Unlike similar packages, Formotron does not ship with any validator implementations, but only provides the validation framework. Many validators are trivial and easy to implement. You can still use an external validation library and wrap its functions in a ValidatorAttribute or Validator object.

Validation of null values

Most validators work on specific types only. For example, a string length check requires string input. Because, in many cases, the input value passed to validators can be anything, validators may have to check the value's type before proceeding.

This can become problematic if the input value is null. Validity depends on the property's type, but this information is not available to validators. For this reason, the property type determines how null values get validated:

  • For non-nullable types, (like string), null is invalid by definition, and an error is thrown. Validators are not invoked in that case. Attached validators will never receive a null value.

  • For nullable types, (like ?string), null is valid by definition. Validators are not invoked in that case. Attached validators will never receive a null value.

  • For untyped or mixed properties, null is valid by default, but validators will get invoked. Attached validators may receive a null value and have to decide whether or not it is valid.

Validators without dependencies

Validators which can be instantiated directly implement the Formotron\Attribute\ValidatorAttribute interface. Like any other attribute class, they can take arguments that are passed to its constructor.

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
class StringLength implements Formotron\Attribute\ValidatorAttribute
{
    public function __construct(private int $min = 0, private ?int $max = null)
    {
        assert($min >= 0);
        assert($max === null || ($max >= 1 && $min <= $max));
    }

    #[Override]
    public function validate(mixed $value): void
    {
        if (!is_string($value)) {
            throw new InvalidArgumentException('not a string');
        }
        $length = mb_strlen($value);
        if ($length < $this->min) {
            throw new InvalidArgumentException('too short');
        }
        if ($this->max && $length > $this->max) {
            throw new InvalidArgumentException('too long');
        }
    }
}

class DataObject
{
    #[StringLength(min: 1, max: 30)]
    public string $name;
}

Validators instantiated from a container

When validation must be done in a class instantiated from the container (typically to have dependencies injected), that class must implement the Formotron\Validator interface.

// The container must resolve UniqueValidator::class to an instance of this class.
class UniqueValidator implements Formotron\Validator
{
    // The Database object will be injected by the container.
    public function __construct(private Database $database) {}

    #[Override]
    public function validate(mixed $value, array $args): void
    {
        assert($args['table']);
        assert($args['column']);

        if (
            $database->fetchOne(
                "SELECT count(*) FROM $table WHERE $column = ?",
                [$value],
            )
        ) {
            throw new InvalidArgumentException('value already exists');
        }
    }
}

This service cannot be defined as an attribute, because attributes receive their arguments through their constructor, which this class uses for its dependencies. A separate attribute class is required which must implement the Formotron\Attribute\ValidatorServiceAttribute interface. A default implementation exists, Formotron\Attribute\Validate, which requires the service name as its first argument, and has additional arguments passed as the second parameter to the service's validate() method.

class DataObject
{
    // Name must not exist in users.name
    #[Formotron\Attribute\Validate(UniqueValidator::class, table: 'users', column: 'name')]
    public string $name;
}

When using extra arguments, it is often better to define the attribute manually. See "Passing extra arguments to attributes" below.

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
class Unique implements Formotron\Attribute\ValidatorServiceAttribute
{
    public function __construct(private string $table, private string $column)
    {
        assert($table);
        assert($column);
    }

    #[Override]
    public function getServiceName(): string
    {
        return UniqueValidator::class;
    }

    #[Override]
    public function getArguments(): array
    {
        // ['table' => $this->table, 'column' => $this->column]
        return get_object_vars($this);
    }
}

class DataObject
{
    #[Unique(table: 'users', column: 'name')]
    public string $name;
}

Preprocessing input data

Transformers and validators operate on individual data object properties and their associated input array elements, isolated and independent of each other. They have no knowledge of the context in which the input value was submitted.

Sometimes this is not sufficient. Some processing rules need access to the whole input array, or the array needs modification before individual fields can be processed. Common operations include

  • removing keys that are not mapped to the data object
  • providing non-static default values for missing keys
  • transformations or validations that depend on more than one field

This could be done manually before calling DataProcessor::process(), but that would have several drawbacks:

  • There is now a contract to follow, which must be explicitly documented and enforced.
  • The extra processing is disconnected from the data object. Everything else is defined in the data object class via property types and attributes. While the actual implementation is located in transformer and validator objects, the general processing logic is outlined in the data object class, but the preprocessing logic is not.
  • The calling code becomes more complex and difficult to test.

Instead, preprocessing should be done in a class that implements the Formotron\PreProcessor interface:

interface PreProcessor
{
    public function process(array $formData): array;
}

Implementations receive the input array (or the output of another preprocessor) and return an array that will be fed to the next preprocessor (if any) and finally used to populate the data object. The implementation can do anything: add or remove keys, modify values, replace the whole array, validate data and throw exceptions, ...

Preprocessors are attached to the data object class via the PreProcess attribute. It takes the name of a service – typically a class name – which will be pulled from the container supplied to the DataProcessor's constructor. The container must resolve the name to an object implementing the PreProcessor interface.

Multiple preprocessors can be attached to a single class, but the order of execution should not be relied upon. If order is significant, write a preprocessor that does all necessary operations in a single step.

The following example demonstrates the use of a preprocessor for CSRF protection. The form data contains a token, which is compared to the token stored in session data. (The actual implementation is not provided here.) Once validation has succeeded, the token is no longer useful and therefore not part of the data object.

use Formotron\Attribute\PreProcess;

// The container must resolve CsrfProtection::class to an instance of this class.
class CsrfProtection implements Formotron\PreProcessor
{
    public function process(array $formData): array
    {
        if (!is_token_valid($formData['token'] ?? '')) {
            throw new Formotron\AssertionFailedException('invalid token');
        }
        // The 'token' key is not mapped to a property and must be removed.
        unset($formData['token']);
        return $formData;
    }
}

#[PreProcess(CsrfProtection::class)]
class DataObject
{
    // Only fields of interest are defined here.
    // The token is only relevant for the preprocessor.
    public string $foo;
}

Passing extra arguments to attributes

Extra arguments to the Transform and Validate attributes are passed to the transform()/validate() method, allowing generic implementations with parameters.

The arguments are not defined in code. Arbitrary names and values may be passed. The transformer/validator may have to validate the extra arguments, editors cannot provide hints and autocompletion, and code analysis tools cannot check types, or may even complain about undefined argument names.

To provide language-level definitions for attribute arguments, create an attribute class which implements the appropriate interface, and define the arguments in its constructor. Argument types can be anything that is allowed for attribute arguments, and they can have default values.

A strict constructor signature provides basic validation of arguments. Additional validation might be necessary, either in the attribute constructor or in the invoked method. Keep in mind that attribute arguments are not user input, but part of the code. Invalid arguments are a bug in the code that uses the attribute, not a runtime issue.

If there is a chance that the transformer/validator may be used directly, i.e. not via an attribute, it should validate its arguments more thoroughly, particularly if its extra arguments may contain user input.

Error handling

Handling logical errors

Most errors reported by Formotron are failed sanity checks where input data is formally incorrect: a value has an unsuitable data type or does not map to an enum. This typically means that something went seriously wrong: A bug in the backend, or malformed input data that could not have originated from the expected form.

These errors are reported via a Formotron\AssertionFailedException. Transformers, validators and preprocessors can also throw this exception for hard errors. In fact, Formotron does not catch any exceptions, so it's possible to throw anything that is appropriate.

End-users are not expected to see these errors as a response to incorrect user action, like leaving a required field empty. In most cases, no special handling is required for these exceptions. The application's default exception handler is probably the most appropriate tool to handle them. This is a response to a bug or a technical error, and a standard message, optionally with a backtrace, will convey more useful debugging information than a beautifully formatted error message.

Handling invalid user input

Not all errors are caused by malfunction. Many forms are not immune to user errors: some required fields could be left blank or an entered string may be too long or too short.

Throwing an exception without special handling would be inappropriate for this kind of error. Invalid user input is typically handled by collecting errors and re-displaying the form with error messages.

Formotron does not yet support advanced error handling, but you can implement your own. You could throw a custom exception in a transformer, validator or preprocessor, and catch it:

try {
    $formData = $dataProcessor->process($input, DataObject::class);
} catch (MyCustomException $exception) {
    // Handle invalid user input
}

This has the disadvantage of aborting on the first error. Additional errors would stay unnoticed until the form is re-submitted with valid data for the first field. As an alternative, instead of throwing an exception, you could collect errors somewhere and continue, and evaluate the errors afterwards. However, this breaks the promise of an always valid data object, and there is now a contract to follow. This can be avoided by wrapping this kind of error handling in a reusable method:

function process(array $input, string $className)
{
    $dataObject = $this->dataProcessor->process($input, $className);
    $errors = $this->getErrors(); // retrieve collected errors
    if ($errors) {
        // Report detailed errors to calling code
        throw new ValidationException($errors);
    }
    return $dataObject;
}

This wrapper returns a valid data object or throws an exception for invalid input. Calling code can catch the special exception and evaluate the detailed error information contained wihin.

Taking advantage of frontend form validation

Modern web applications often validate form data by themselves, either while typing or immediately before submission. While this frontend validation enhances the user experience, it does not remove the necessity for thorough backend validation. The submitted form data is still untrusted, and some validations can only be done in the backend.

However, with frontend validation in place, backend error handling can be simplified. When it can be assumed that the data has already been validated once and validation errors have been gracefully handled in the frontend, validation failures in the backend can be treated as unexpected errors, because something went wrong:

  • Frontend validation has somehow been bypassed, which should not happen if the application is used as intended.
  • Mismatch between validation rules, the backend rules are more strict than the frontend rules.
  • Some other bug.

These unexpected situations have to be handled, but they don't have to be handled nicely. Like with logical errors, simply throwing an exception and have regular exception handling deal with it may be the best choice. Complex detailed handling of invalid user input is only necessary for failures that are not covered in the frontend.

There are many JavaScript libraries for validation in the frontend, but HTML form elements already provide attributes for basic constraints that are followed by modern browsers and only need to be verified in the backend.

Handling HTML form data

Submitted HTML form data can be straightforward (most elements produce just a string that contains the entered value), but sometimes surprising and annoying. Knowing how form element values are represented can make transformation and validation in Formotron quite simple.

Form data is generally transmitted as a map of string values. Many elements constrain the format of their values by their type. Additional constraints can be applied by HTML attributes. These constraints are an excellent starting point for validation in the backend, and it is recommended to make use of them as much as possible. Anything that does not fullify these constraints can be handled as an unexpected error. More sophisticated error handling is mostly useful for additional constraints that cannot be expressed in HTML.

The procedures below only apply to single values. If a form element's name ends with [], the result will be an array of strings. Formotron can only verify that the input value is an array. The array values themselves are not validated automatically. This must be done by a custom validator that iterates over the array and validates the values according to the rules below.

Most HTML inputs support the required attribute which will by default prevent form submission if no value is entered. The backend can assume required fields to be present and non-empty. Formotron will throw an exception if the key is missing and the data object property has no default value. As with all frontend validation, it cannot be relied upon on its own.

Frontend validation via HTML attributes only affects default form submission. JavaScript code may send form data bypassing builtin validations.

Text, password, search and telephone inputs

<input type="text"> produces a string with the element's value. The minlength, maxlength and pattern attributes can constrain the content.

<input type="password">, <input type="search"> and <input type="tel"> behave the same way in regards to submitted data. The only difference is how browsers may style the element and control interaction with the user.

<textarea> supports only the minlength and maxlength attributes, but not pattern.

Number and range inputs

<input type="number"> produces a string that can be parsed as a number or an empty string. The max, min and step attributes further constrain the range of possible values.

The number is represented in a non-localized format without thousands separator. Integer values for the step attribute (default is 1) allow integers only. Fractional step values allow integer and fractional input values, the latter with a dot as fractional separator, regardless of locale.

<input type="range"> behaves identically in regards to submitted data. The only difference is how browsers display the element.

Hidden elements

<input type="hidden"> produces a string with the element's value. Interpretation is up to the application. There are no client-side constraints.

Checkboxes

<input type="checkbox"> produces a string with the element's value attribute (or a default value of on) if the checkbox is checked. The field will be missing completely otherwise.

The easiest way to handle this information is a KeyOnly attribute on the property.

class DataObject
{
    #[Formotron\Attribute\KeyOnly]
    public bool $checked;
}

Some applications and frameworks deal with checkboxes by adding a hidden element to the form with the same name as the checkbox element and the unchecked value, so that the key will always be present. This moves the logic to the form markup, which is not the best place for this implementation detail and should be avoided if possible. If you must use this pattern, validate and apply the input value directly or transform it into a boolean or any other value.

Radio buttons

<input type="radio"> produces a string with the value of the selected element, or no data at all if no button was selected. The latter can only happen if no button was preselected via the checked attribute. In that case the missing array key must be accounted for, for example with a default value for the data object property.

Radio button groups with a static set of values can be easily mapped to an enum. If a string property is used or the set of values is generated at runtime, validation must be done manually.

Dropdowns

<select> produces a string with the value of the selected entry. Options with a static set of values can be easily mapped to an enum. If a string property is used or the set of values is generated at runtime, validation must be done manually.

One option is always selected - the first if no option has the selected attribute. An empty option gives the illusion of no selection, but its value (or an empty string when it has no value attribute) is submitted. This must be accounted for when processing the value. The appropriate strategy depends on your use case. The most straightforward strategies are:

  • Define an enum, with the "empty" value as a member. Empty strings are supported by backed enums only, other values can be mapped to any enum type.
  • Define the data object property as nullable, and apply a transformer to convert the "empty" value to NULL.

E-mail addresses

<input type="email"> produces a string that contains a single e-mail address, a comma-separated list of addresses (if the multiple attribute is set) or an empty string. The minlength, maxlength and pattern attributes can constrain the value further.

Input is validated to be a syntactically correct e-mail address, but the address is not guaranteed to exist. The validation ruleset may also be incomplete. Validating e-mail addresses is tricky, with some non-obvious rules and edge cases. It is recommended to wrap an existing validation library (don't write your own, it will most likely be incorrect) in a Validator class and handle failures more gracefully than just throwing an exception.

URL

<input type="url"> produces a string that contains a syntactically valid URL or an empty string. The minlength, maxlength and pattern attributes can constrain the value further.

Validation is limited to syntax checking. The URL does not have to lead to an existing ressource, or even contain a meaningful scheme. The backend may need to validate the URL more thoroughly:

  • Check for adequate scheme (http or https in most cases)
  • Try to contact the ressource (for example, try to make a HTTP request)

Failures should be handled more gracefully than just throwing an exception.

Date/time pickers

Date/time pickers produce a string that can parsed with the formats below, or an empty string if no value was entered. The max, min and step attributes further constrain the range of possible values.

  • <input type="date">: Y-m-d
  • <input type="time">: H:i or H:i:s depending on the step attribute
  • <input type="datetime-local"> Y-m-d\TH:i or Y-m-d\TH:i:s depending on the step attribute

Month/week pickers

<input type="month"> and <input type="week"> are not recommended because Firefox will just show a text input that allows entering arbitrary text. With browsers that support it, they produce a string that can be parsed as Y-m (month) or Y-\WW (week) or an empty string. The max, min and step attributes further constrain the range of possible values. Because of the possibility of invalid input, validation failure should be handled more gracefully than just by throwing an exception.

Color pickers

<input type="color"> produces a hash sign followed by a 6-digit hexadecimal RGB value, like #aabbcc, or an empty string.

Submit buttons

<input type="submit"> does not produce any data by default. If a name attribute is provided. it will be present as a key, with the button's value (or default text, if missing). However, the double role of the value attribute as a label and form data is bad design, particularly with multilingual user interfaces.

<button type="submit"> is a better alternative. It does not produce any data by default. The name and value attributes behave the same way, but value is used only for form data (empty by default), not as a label.

Either way, the name attribute should only be set to distinct multiple buttons. With only 1 button, the resulting form data element would just be meaningless noise which has to be removed before processing. By using multiple buttons with the same name but different value, it is possible to determine which button was clicked and take appropriate action:

<button type="submit" name="action" value="this">Do This</button>
<button type="submit" name="action" value="that">Do That</button>

Now the action field can be evaluated to determine what to do with the form data. An enum is a simple way to represent and validate the action.

It might be even simpler to assign different handlers to the buttons via the formaction attribute and keep the button out of the form data (no name attribute).

Other elements

Some elements do not produce any form data and are mentioned here for completeness only:

  • <input type="file"> is only useful for forms with multipart/formdata encoding, in which case uploaded files will be available via $_FILES and handled entirely different. With this encoding, $_POST will not contain any data for this element.
  • <input type="image"> adds the clicked coordinates of the image in the x and y keys, optionally prefixed by the name attribute. Not very useful unless you want to implement some sort of click map.
  • <input type="reset"> does not produce any form data.