DEV Community

Cover image for Small Forms 2.2.0: Validation Is Only Half the Problem
sebk69
sebk69

Posted on

Small Forms 2.2.0: Validation Is Only Half the Problem

When processing API input, checking whether a value is valid is only part of the job.

Real input often looks more like this:

{
  "title": "   My   first   article   ",
  "visibility": "public",
  "tags": [" PHP ", "API", "php"],
  "publishedAt": "2026-08-20 10:30:00"
}
Enter fullscreen mode Exit fullscreen mode

Before this data is useful to an application, several things usually need to happen:

  • validate it;
  • normalize whitespace;
  • convert values to application-friendly types;
  • normalize arrays recursively;
  • remove duplicates;
  • validate relationships between fields;
  • convert dates and enums;
  • expose predictable PHP values to the rest of the application.

That is the problem Small Forms is designed to solve.

Version 2.2.0 expands that idea with new validators, modifiers, collection helpers and form-aware validation rules.

Install it with Composer:

composer require small/forms
Enter fullscreen mode Exit fullscreen mode

Small Forms is framework-agnostic. It is not trying to replace a complete web framework or a full form system.

It focuses on one specific problem:

Take external input, describe its expected structure, validate it, normalize it and expose clean PHP values with as little plumbing as possible.

Validation is excellent. Normalization is a different problem.

Symfony Validator already provides a very large collection of constraints and is a mature solution for validating values and object graphs.

The interesting difference with Small Forms is what happens around validation.

Validation answers:

Is this input acceptable?

But API input frequently needs another step:

What should this value look like when the application uses it?

For example, a valid string may still need trimming, lowercasing, enum conversion, JSON decoding or mapping over a collection.

Small Forms makes that normalization step part of the input definition itself.

The pipeline is intentionally simple:

input
  ↓
field definition
  ↓
validation rules
  ↓
modifiers
  ↓
clean PHP value
Enter fullscreen mode Exit fullscreen mode

Validation and normalization belong next to each other

Consider a simple article payload.

use Small\Collection\Collection\StringCollection;
use Small\Forms\Form\Field\Type\ArrayType;
use Small\Forms\Form\Field\Type\StringType;
use Small\Forms\Form\FormBuilder;
use Small\Forms\Modifier\MapModifier;
use Small\Forms\Modifier\NormalizeWhitespaceModifier;
use Small\Forms\Modifier\StringToEnumModifier;
use Small\Forms\Modifier\ToLowerModifier;
use Small\Forms\Modifier\TrimModifier;
use Small\Forms\Modifier\UniqueArrayModifier;
use Small\Forms\ValidationRule\ValidateEach;
use Small\Forms\ValidationRule\ValidateEnum;
use Small\Forms\ValidationRule\ValidateNotEmpty;
use Small\Forms\ValidationRule\ValidateString;

enum Visibility: string
{
    case Public = 'public';
    case Private = 'private';
}

$form = FormBuilder::createInlineForm()
    ->addField(
        'title',
        new StringType(),
        [
            new ValidateNotEmpty(),
        ],
        [
            new TrimModifier(),
            new NormalizeWhitespaceModifier(),
        ],
    )
    ->addField(
        'visibility',
        new StringType(),
        [
            new ValidateEnum(Visibility::class),
        ],
        [
            new StringToEnumModifier(Visibility::class),
        ],
    )
    ->addField(
        'tags',
        new ArrayType(new StringType()),
        [
            new ValidateEach(new ValidateString()),
        ],
        [
            new MapModifier(new TrimModifier()),
            new MapModifier(new ToLowerModifier()),
            new UniqueArrayModifier(),
        ],
    );
Enter fullscreen mode Exit fullscreen mode

Now fill it directly from JSON:

$form->fillFromJson(<<<JSON
{
    "title": "   My   first   article   ",
    "visibility": "public",
    "tags": [" PHP ", "API", "php"]
}
JSON);

$messages = new StringCollection();
$form->validate($messages);
Enter fullscreen mode Exit fullscreen mode

And retrieve normalized values:

$title = $form->getFieldValue('title');
// "My first article"

$visibility = $form->getFieldValue('visibility');
// Visibility::Public

$tags = $form->getFieldValue('tags');
// ["php", "api"]
Enter fullscreen mode Exit fullscreen mode

The interesting part is not that trimming a string or converting an enum is difficult.

The advantage is that normalization becomes reusable and declarative.

There is no cleanup block hidden in a controller:

$title = trim($payload['title']);
$title = preg_replace(...);
$tags = array_map(...);
$tags = array_unique(...);
Enter fullscreen mode Exit fullscreen mode

The normalization rules live with the input contract.

MapModifier: one abstraction for collection normalization

One of the most useful additions is MapModifier.

It applies any modifier to every value in an array or collection:

new MapModifier(
    new TrimModifier(),
);
Enter fullscreen mode Exit fullscreen mode

or:

new MapModifier(
    new ToLowerModifier(),
);
Enter fullscreen mode Exit fullscreen mode

That means a field can express a complete normalization pipeline:

[
    new MapModifier(new TrimModifier()),
    new MapModifier(new ToLowerModifier()),
    new UniqueArrayModifier(),
]
Enter fullscreen mode Exit fullscreen mode

The same compositional idea exists on the validation side:

new ValidateEach(
    new ValidateString(),
);
Enter fullscreen mode Exit fullscreen mode

The mental model stays consistent:

  • ValidateEach validates every item;
  • MapModifier transforms every item.

This is particularly useful for API payloads because validation and normalization are often needed on the same collection.

Cross-field validation without callback boilerplate

Input fields rarely exist independently.

Typical rules include:

confirmPassword must equal password

vatNumber is required if accountType is business

phoneCountry is required when phone is provided

fallbackContact is required when email is empty
Enter fullscreen mode Exit fullscreen mode

Small Forms 2.2.0 provides explicit form-aware validators for these cases.

$form = FormBuilder::createInlineForm()
    ->addField(
        'password',
        new StringType(),
    )
    ->addField(
        'confirmPassword',
        new StringType(),
        [
            new ValidateSameAsField('password'),
        ],
    )
    ->addField(
        'accountType',
        new StringType(),
    )
    ->addField(
        'vatNumber',
        new StringType(),
        [
            new ValidateRequiredIf(
                'accountType',
                'business',
            ),
        ],
    )
    ->addField(
        'phone',
        new StringType(),
    )
    ->addField(
        'phoneCountry',
        new StringType(),
        [
            new ValidateRequiredWith('phone'),
        ],
    );
Enter fullscreen mode Exit fullscreen mode

The validators automatically receive the current form context and can read sibling values.

Symfony can perform cross-property validation, but common conditional rules are often expressed through generic mechanisms such as callbacks or expressions.

For common API rules, this:

new ValidateRequiredIf(
    'accountType',
    'business',
)
Enter fullscreen mode Exit fullscreen mode

is easier to discover and read than repeating custom conditional logic.

Generic callbacks are still useful when a rule is genuinely domain-specific.

But common relationships deserve common names.

Dates: validate one representation, expose another

Dates are another good example.

An API may receive:

2026-08-20 10:30:00
Enter fullscreen mode Exit fullscreen mode

while the application would rather work with:

DateTimeImmutable
Enter fullscreen mode Exit fullscreen mode

Small Forms can describe both requirements on the same field:

$form->addField(
    'publishedAt',
    new StringType(),
    [
        new ValidateDateAfter(
            '2026-01-01 00:00:00',
        ),
    ],
    [
        new StringToDateTimeImmutableModifier(),
    ],
);
Enter fullscreen mode Exit fullscreen mode

Then:

$publishedAt = $form->getFieldValue('publishedAt');

assert($publishedAt instanceof DateTimeImmutable);
Enter fullscreen mode Exit fullscreen mode

The field definition answers two different questions:

What is acceptable input?
Enter fullscreen mode Exit fullscreen mode

and:

What value should my application receive?
Enter fullscreen mode Exit fullscreen mode

without scattering that logic across unrelated application code.

Array structures are first-class input

Small Forms 2.2.0 also adds more tools for structured input:

new ValidateArrayKeys(...);
new ValidateArrayShape(...);
new ValidateArrayItem(...);
new ValidateEach(...);
new ValidateAll(...);
new ValidateNoneOf(...);
Enter fullscreen mode Exit fullscreen mode

The advantage becomes clear when structure validation is combined with Small Forms types and modifiers.

For example:

$form->addField(
    'tags',
    new ArrayType(
        new StringType(),
    ),
    [
        new ValidateEach(
            new ValidateString(),
        ),
    ],
    [
        new MapModifier(
            new TrimModifier(),
        ),
        new MapModifier(
            new ToLowerModifier(),
        ),
        new UniqueArrayModifier(),
    ],
);
Enter fullscreen mode Exit fullscreen mode

The same field describes:

container type
+ item validation
+ item normalization
+ collection normalization
Enter fullscreen mode Exit fullscreen mode

That composition is the main benefit.

Modifiers are intentionally small

Small Forms 2.2.0 includes modifiers for common input cleanup and conversion, including:

  • ToIntModifier
  • ToFloatModifier
  • ToStringModifier
  • StringToBooleanModifier
  • StringToEnumModifier
  • NormalizeWhitespaceModifier
  • NullIfBlankModifier
  • EmptyStringToNullModifier
  • JsonDecodeModifier
  • JsonEncodeModifier
  • CsvToArrayModifier
  • ReplaceModifier
  • RegexReplaceModifier
  • StripTagsModifier
  • UniqueArrayModifier
  • FilterEmptyArrayModifier
  • SortArrayModifier
  • LowercaseArrayModifier
  • MapModifier
  • SplitModifier
  • JoinModifier

Each modifier does one small job.

Their value comes from composition.

[
    new TrimModifier(),
    new NormalizeWhitespaceModifier(),
    new ToLowerModifier(),
]
Enter fullscreen mode Exit fullscreen mode

is easier to reuse, test and reason about than one large generic sanitizeInput() helper.

And because modifiers implement the same interface, application-specific modifiers can participate in exactly the same pipeline.

The same principle applies to validation

The release also adds validators for common API input such as:

  • enums;
  • date boundaries;
  • URLs;
  • UUIDs;
  • ULIDs;
  • IP addresses;
  • CIDR ranges;
  • hostnames;
  • array keys and shapes;
  • filesystem paths;
  • cross-field relationships.

Some of these have direct equivalents in Symfony Validator.

That is not the differentiator.

Small Forms is useful because those validators live inside the same compact input abstraction as:

types
modifiers
nested forms
arrays
JSON input
URL-encoded input
object input
hydration
Enter fullscreen mode Exit fullscreen mode

The package is focused on the complete input pipeline rather than validation alone.

Why not just write helper functions?

You can.

For a small endpoint, that may be the right solution.

But helpers tend to grow:

trimInput();
normalizeBoolean();
validateEmail();
normalizeTags();
validateTags();
validateDates();
checkRequiredFields();
checkConditionalFields();
Enter fullscreen mode Exit fullscreen mode

Then each endpoint has to decide which helper runs first.

Eventually, execution order becomes part of the input contract, but that contract is scattered through procedural code.

A declarative field definition makes the sequence visible:

$form->addField(
    'tags',
    new ArrayType(new StringType()),
    validationRules: [
        new ValidateEach(
            new ValidateString(),
        ),
    ],
    modifiers: [
        new MapModifier(
            new TrimModifier(),
        ),
        new MapModifier(
            new ToLowerModifier(),
        ),
        new UniqueArrayModifier(),
    ],
);
Enter fullscreen mode Exit fullscreen mode

You can read the input behavior directly from the field definition.

Small Forms is not trying to replace Symfony

Symfony Validator is mature, powerful and much broader in scope.

Small Forms targets a narrower use case:

Backend input where validation and normalization should be defined together without introducing a large amount of plumbing.

That makes it particularly useful for:

REST endpoints
JSON APIs
webhook payloads
CLI input
message consumers
integration layers
application service input
Enter fullscreen mode Exit fullscreen mode

The goal is not “less Symfony”.

The goal is:

less glue code between external input and clean application values.

What changed in 2.2.0

The direction of Small Forms 2.2.0 can be summarized in one sentence:

Make common input transformations and field relationships composable enough that application code no longer needs to care about them.

Validation tells you:

Can I accept this input?
Enter fullscreen mode Exit fullscreen mode

Modifiers answer:

What should this value look like when the application uses it?
Enter fullscreen mode Exit fullscreen mode

Small Forms treats both as first-class parts of the same input definition.

If your application spends a lot of code going from:

$requestPayload
Enter fullscreen mode Exit fullscreen mode

to:

$cleanApplicationValues
Enter fullscreen mode Exit fullscreen mode

that is exactly the gap Small Forms is trying to fill.

composer require small/forms
Enter fullscreen mode Exit fullscreen mode

repository : https://packagist.org/packages/small/forms

Small Forms is open source and released under the MIT license.

Top comments (0)