tetthys/commit-validator

Framework-agnostic commit-time validator for session-aggregated payloads using DTO + PHP Attributes.

Installs: 2

Dependents: 0

Suggesters: 0

Security: 0

Stars: 1

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/tetthys/commit-validator

0.0.2 2026-01-13 09:46 UTC

This package is auto-updated.

Last update: 2026-01-13 09:46:49 UTC


README

A fast, framework-agnostic validator designed for commit-time validation of session-aggregated payloads (multi-step forms), using DTOs + PHP Attributes and an optional lightweight DSL for commit-only rules.

This library is intentionally minimal: it validates the final, assembled data right before you “commit” it to your database (or any durable store).

  • PHP: 8.4+
  • Framework: none (pure PHP)
  • Primary style: DTO + Attributes
  • Secondary style: DSL (for commit-only overlays)
  • No global helper is shipped: avoid name collisions; you can define your own optional helper (cv() recommended).

Table of Contents

Why this library exists

Many validators are request-scoped: they validate a single request.
But multi-step forms often work like this:

  1. Step 1 validates and stores partial data (session, cache, temp DB row, etc.)
  2. Step 2 validates and stores more data
  3. Step N stores the last piece
  4. Commit: assemble the final payload and validate it holistically

At commit-time you need constraints like:

  • "product.images must exist, and have 1..6 items"
  • "shipping.address must be present if shipping.method is delivery"
  • "items must have at least one item, and each item must be valid"
  • "Nested DTOs must satisfy their own rules"

tetthys/commit-validator focuses specifically on this commit-time validation problem.

Install

composer require tetthys/commit-validator

Requirements:

  • PHP ^8.4

Concepts

Commit-time validation

“Commit-time validation” means you validate the final assembled payload, not each partial step in isolation.

This library assumes you already have a mechanism to store intermediate step data and to assemble it into a final array or DTO at commit time.

DTOs and Attributes

You define a DTO (data transfer object) and attach validation rules to its public properties using PHP Attributes:

use CommitValidator\Attributes\Required;
use CommitValidator\Attributes\Length;

final readonly class PostDTO
{
    public function __construct(
        #[Required('Title is required.')]
        #[Length(min: 3, max: 80, message: 'Title must be 3..80 chars.')]
        public string $title,

        #[Required('Body is required.')]
        #[Length(min: 10, message: 'Body must be at least 10 chars.')]
        public string $body,
    ) {}
}

Paths and error format

Validation errors are keyed by a dot path:

  • title
  • product.images
  • product.images.0.url
  • address.city

Errors are returned as a map:

[
  "product.images" => [
    ["code" => "min", "message" => "Images must be between 1 and 6."],
  ],
  "product.images.2.url" => [
    ["code" => "regex", "message" => "Image URL must be a valid http(s) URL."],
  ],
]

This makes it easy to match errors back to nested form inputs and UI components.

Quick Start

1) Define DTO + Attributes

<?php
declare(strict_types=1);

namespace App;

use CommitValidator\Attributes\Required;
use CommitValidator\Attributes\Length;

final readonly class CommitPayload
{
    public function __construct(
        #[Required('Title is required.')]
        #[Length(min: 3, max: 80, message: 'Title must be 3..80 chars.')]
        public string $title,

        #[Required('Body is required.')]
        #[Length(min: 10, message: 'Body must be at least 10 chars.')]
        public string $body,
    ) {}
}

2) Assemble your final payload and create the DTO

$finalPayload = [
  'title' => 'Hello world',
  'body'  => 'This is a long enough body...',
];

$dto = new \App\CommitPayload(
  title: (string)($finalPayload['title'] ?? ''),
  body: (string)($finalPayload['body'] ?? ''),
);

3) Validate at commit-time

use CommitValidator\CommitValidator;

$result = CommitValidator::for(\App\CommitPayload::class)->validate($dto);

if (!$result->isValid()) {
    print_r($result->errors());
    // abort commit
}

Recommended: define a local cv() helper (optional)

This package does not ship a global helper function to avoid collisions with user code and other packages.

If you like a short DSL entry point, define your own helper in your application (and autoload it however you prefer):

<?php
// e.g. app/helpers.php (or any file you autoload)

function cv(): CommitValidator\Rules\Builder
{
    return new CommitValidator\Rules\Builder();
}

Then you can write:

$validator = CommitValidator::for(\App\CommitPayload::class)
    ->with('title', cv()->minLen(10, 'Commit title must be >= 10.')->build());

Defining DTOs

Basic fields

Available built-in attributes:

  • #[Required(message?)]
  • #[Length(min?, max?, message?)]
  • #[Regex(pattern, message?)]
  • #[InSet(values[], message?)]
  • #[Count(min?, max?, message?)]

Example:

use CommitValidator\Attributes\Required;
use CommitValidator\Attributes\Regex;
use CommitValidator\Attributes\InSet;

final readonly class UserDTO
{
    public function __construct(
        #[Required]
        #[Regex('/^[a-z0-9_]{3,20}$/u', 'Username must be 3..20 lowercase chars.')]
        public string $username,

        #[Required]
        #[InSet(['user', 'admin'], 'Role must be user|admin.')]
        public string $role,
    ) {}
}

Nested DTOs

Use #[Nested(SomeDTO::class)] to validate a nested object recursively.

use CommitValidator\Attributes\Nested;
use CommitValidator\Attributes\Required;

final readonly class AddressDTO
{
    public function __construct(
        #[Required] public string $city,
        #[Required] public string $street,
    ) {}
}

final readonly class ProfileDTO
{
    public function __construct(
        #[Nested(AddressDTO::class)]
        public ?AddressDTO $address = null,
    ) {}
}

If address is present, the validator will validate AddressDTO and produce paths like:

  • address.city
  • address.street

Arrays of DTOs (Each)

Use #[Each(ItemDTO::class)] to validate every element in an array as a DTO.

use CommitValidator\Attributes\Each;
use CommitValidator\Attributes\Required;

final readonly class ItemDTO
{
    public function __construct(
        #[Required] public string $name,
    ) {}
}

final readonly class CartDTO
{
    public function __construct(
        #[Each(ItemDTO::class)]
        public array $items = [],
    ) {}
}

Errors point to the exact index:

  • items.0.name
  • items.3.name

Collection constraints (Count)

Use #[Count(min: X, max: Y)] on arrays to validate the number of items.

use CommitValidator\Attributes\Count;

final readonly class GalleryDTO
{
    public function __construct(
        #[Count(min: 1, max: 6, message: 'Images must be between 1 and 6.')]
        public array $images = [],
    ) {}
}

Validating

Validating a DTO instance

use CommitValidator\CommitValidator;

$validator = CommitValidator::for(\App\SomeDTO::class);
$result = $validator->validate($dto);

if ($result->isValid()) {
    // safe to commit
} else {
    $errors = $result->errors();
}

Adding commit-only rules (DSL overlays)

Attributes define your “always-on” rules.

Sometimes you want stricter commit-time constraints without affecting draft saves. For that, use a DSL overlay:

use CommitValidator\CommitValidator;

$validator = CommitValidator::for(\App\CommitPayload::class)
    ->with('title', cv()->minLen(10, 'Commit title must be >= 10.')->build())
    ->with('images', cv()->count(1, 6, 'Images must be between 1 and 6.')->build());

$result = $validator->validate($dto);

DSL methods:

  • cv()->required(message?)
  • cv()->minLen(min, message?)
  • cv()->maxLen(max, message?)
  • cv()->regex(pattern, message?)
  • cv()->inSet(values, message?)
  • cv()->count(min?, max?, message?)
  • ->build() returns a RuleSet

Multi-step form workflow (session aggregation pattern)

This library does not manage session storage. It assumes you do.

Step storage

Example (pseudo-code):

// Step 1
$_SESSION['draft']['product']['name'] = $input['name'];

// Step 2
$_SESSION['draft']['product']['images'] = $input['images'];

// Step 3
$_SESSION['draft']['product']['price'] = $input['price'];

Final aggregation

At commit time, you assemble the final structure:

$final = $_SESSION['draft'] ?? [];

You can normalize it (remove nulls, ensure arrays exist, etc.) before creating DTOs.

Commit validation

Construct DTO(s) from the final array and validate:

use CommitValidator\CommitValidator;

$product = \App\ProductDTO::fromArray($final['product'] ?? []);

$result = CommitValidator::for(\App\ProductDTO::class)->validate($product);

if (!$result->isValid()) {
    return $result->errors();
}

// persist to DB

Error handling

Displaying errors

A simple rendering loop:

$errors = $result->errors();

foreach ($errors as $path => $messages) {
    foreach ($messages as $e) {
        echo "{$path}: {$e['code']->value} - {$e['message']}\n";
    }
}

If you JSON-encode the Result, codes become strings:

echo json_encode($result, JSON_PRETTY_PRINT);

Mapping paths to form field names (recommended pattern)

Many frontends use bracket-style names:

  • product[images][2][url]

While validator paths are dot-style:

  • product.images.2.url

A common pattern is to keep a mapping function:

function path_to_field(string $path): string
{
    $parts = explode('.', $path);
    $first = array_shift($parts);
    return $first . implode('', array_map(fn($p) => "[{$p}]", $parts));
}

// "product.images.2.url" => "product[images][2][url]"

Use this to attach errors to specific form controls.

Examples

Example: product.images is required and must have 1..6 items

This is the exact rule: required array, 1..6 items, and each item must be a valid ImageDTO.

<?php
declare(strict_types=1);

namespace App;

use CommitValidator\Attributes\Count;
use CommitValidator\Attributes\Each;
use CommitValidator\Attributes\Required;
use CommitValidator\Attributes\Regex;

final readonly class ImageDTO
{
    public function __construct(
        #[Required]
        #[Regex('/^https?:\/\/.+/u', 'Image URL must be a valid http(s) URL.')]
        public string $url,
    ) {}
}

final readonly class ProductDTO
{
    public function __construct(
        #[Required('Images are required.')]
        #[Count(min: 1, max: 6, message: 'Images must be between 1 and 6.')]
        #[Each(ImageDTO::class)]
        public array $images,
    ) {}

    public static function fromArray(array $data): self
    {
        $images = [];
        foreach (($data['images'] ?? []) as $img) {
            if (!is_array($img)) continue;
            $images[] = new ImageDTO(url: (string)($img['url'] ?? ''));
        }

        return new self(images: $images);
    }
}

Validate:

use CommitValidator\CommitValidator;

$product = \App\ProductDTO::fromArray($final['product'] ?? []);
$result = CommitValidator::for(\App\ProductDTO::class)->validate($product);

if (!$result->isValid()) {
    print_r($result->errors());
}

Example: nested address + items list

use CommitValidator\Attributes\Nested;
use CommitValidator\Attributes\Each;
use CommitValidator\Attributes\Required;

final readonly class AddressDTO
{
    public function __construct(
        #[Required] public string $city,
        #[Required] public string $street,
    ) {}
}

final readonly class ItemDTO
{
    public function __construct(
        #[Required] public string $name,
    ) {}
}

final readonly class OrderDTO
{
    public function __construct(
        #[Nested(AddressDTO::class)]
        public ?AddressDTO $address = null,

        #[Each(ItemDTO::class)]
        public array $items = [],
    ) {}
}

Paths:

  • address.city
  • items.0.name

Example: draft vs commit strictness

Attributes always apply. For draft saves, you may want to accept looser rules and only enforce strict rules at commit.

Recommended pattern:

  • Draft save: use a “draft DTO” with fewer Required/Count constraints, or
  • Commit: overlay stricter constraints via DSL

Overlay example:

use CommitValidator\CommitValidator;

$validator = CommitValidator::for(\App\ProductDTO::class)
    ->with('images', cv()->required('Images are required.')->count(1, 6, 'Images must be 1..6.')->build());

$result = $validator->validate($product);

Performance notes

  • Reflection is cached per DTO class internally.
  • Validation is a simple rule loop over public properties.
  • Nested/Each recursively validate children and produce precise paths.

API reference

CommitValidator::for(class-string $dtoClass): CommitValidator

Create a validator for a DTO class.

CommitValidator->with(string $property, RuleSet $rules): CommitValidator

Return a cloned validator with extra commit-only rules applied to $property.

CommitValidator->validate(object $dto): Result

Validate a DTO instance (plus any overlays).

Result->isValid(): bool

Returns true when no errors exist.

Result->errors(): array

Returns array<string, list<array{code: ErrorCode, message: string}>>

DSL

The package does not ship a global helper. Define cv() locally (optional):

function cv(): CommitValidator\Rules\Builder
{
    return new CommitValidator\Rules\Builder();
}

Then use:

  • cv()->required(message?)
  • cv()->minLen(min, message?)
  • cv()->maxLen(max, message?)
  • cv()->regex(pattern, message?)
  • cv()->inSet(values, message?)
  • cv()->count(min?, max?, message?)
  • ->build()RuleSet

License

MIT