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
Requires
- php: ^8.4
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
- Install
- Concepts
- Quick Start
- Defining DTOs
- Validating
- Multi-step form workflow (session aggregation pattern)
- Error handling
- Examples
- Performance notes
- API reference
- License
Why this library exists
Many validators are request-scoped: they validate a single request.
But multi-step forms often work like this:
- Step 1 validates and stores partial data (session, cache, temp DB row, etc.)
- Step 2 validates and stores more data
- Step N stores the last piece
- Commit: assemble the final payload and validate it holistically
At commit-time you need constraints like:
- "
product.imagesmust exist, and have 1..6 items" - "
shipping.addressmust be present ifshipping.methodisdelivery" - "
itemsmust 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:
titleproduct.imagesproduct.images.0.urladdress.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.cityaddress.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.nameitems.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 aRuleSet
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.cityitems.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