Skip to content

Commit be3270b

Browse files
committed
feat(validator)!: add structured ValidationError model with stable codes
- Add ValidationError value object and ValidationCode catalog - Emit codes and params from built-in validators; expose getStructuredErrors() on ValidationException - Extend satisfies() with optional code and params arguments - Update docs and tests for structured error handling BREAKING CHANGE: tryValidate() now returns a flat list of ValidationError objects as its third tuple element instead of nested message strings. ValidationException::__construct() now accepts ValidationError[]; getErrors() still returns the legacy nested message array.
1 parent 780ad4b commit be3270b

31 files changed

Lines changed: 788 additions & 233 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Core validation logic lives in `src/Lemmon/Validator/`. Tests in `tests/` follow
1313
## Architecture
1414

1515
- **Namespace:** `Lemmon\Validator` (runtime), `Lemmon\Tests` (tests)
16-
- **Core:** `Validator` static factory; `FieldValidator` base; `ValidationException` for errors
16+
- **Core:** `Validator` static factory; `FieldValidator` base; `ValidationException` for errors; `ValidationError` value object (path/code/message/params) is the structured-error source of truth (`getStructuredErrors()`); `ValidationCode` stable-code catalog
1717
- **Validators:** `isString`, `isInt`, `isFloat`, `isBool`, `isArray`, `isAssociative`, `isObject`
1818
- **Shared:** `NumericConstraintsTrait` (min, max, multipleOf, etc.); `PipelineType` enum; `PipelineStep` value object (internal pipeline entry); `PipelineContext` (internal per-run type context threaded through the pipeline); variant enums `IpVersion`, `Base64Variant`, `UuidVariant` for format methods
1919
- **String formats:** email, URL, UUID, IP, hostname, domain, time, base64, hex, regex, datetime, date

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,15 @@ All notable changes to this project will be documented in this file.
66

77
### Added
88

9+
- Structured error model. `ValidationException::getStructuredErrors()` returns a flat list of new `ValidationError` value objects, each with `getPath()` (dotted, `''` at root), `getCode()` (a stable code from the new `ValidationCode` catalog, e.g. `STRING_TOO_SHORT`, `INVALID_TYPE`, `REQUIRED`), `getMessage()`, and `getParams()` (e.g. `['min' => 5]`). `ValidationError` is `JsonSerializable`. All built-in validators now emit codes and params; messages support `{name}` placeholder substitution from params
10+
- `satisfies()` accepts two optional trailing arguments: `?string $code` (defaults to `CUSTOM`) and `array $params` for structured error codes and message placeholders on custom rules
911
- `enum()` on `FieldValidator` now accepts PHP `UnitEnum` (non-backed enums): the value must be an instance of the enum or a string equal to one of the case names; `BackedEnum` behavior is unchanged (int or string backed values via `tryFrom()`)
1012

1113
### Changed
1214

15+
- **BREAKING:** the third element of the `tryValidate()` tuple is now a flat list of `ValidationError` objects (previously a nested array of message strings). `ValidationException::flattenErrors()` now accepts this list. `ValidationException::getErrors()` still returns the legacy nested message array (back-compatible) and `getFlattenedErrors()` is unchanged
16+
- **BREAKING:** `ValidationException::__construct()` now takes a list of `ValidationError` objects instead of a raw error array (affects only code that constructs the exception directly)
17+
1318
- Internal refactor (no public API or behavior change): `Validator::allOf()`, `anyOf()`, and `not()` now build on a new concrete `MixedValidator` base instead of repeating an inline anonymous class; validator pipeline steps are now a typed `PipelineStep` value object (internal) instead of an associative array, simplifying `FieldValidator::__clone()`
1419
- Internal refactor (no public API or behavior change): the `transform()`/`pipe()` type context now lives in a per-run `PipelineContext` object created in `tryValidate()` instead of a mutable `currentType` property on the validator; pipeline operations are now stateless static closures, so cloning no longer rebinds them and validator instances hold no transient state during a run (safe for reuse and reentrancy)
1520

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
4343
- **Type-safe validation** for strings, integers, floats, arrays, and objects
4444
- **Fluent, chainable API** with guaranteed execution order -- methods execute exactly as written in the chain
4545
- **Schema-level error aggregation** with fail-fast behavior per field for clear, early feedback
46+
- **Structured errors** - `getStructuredErrors()` returns `ValidationError` objects with a stable `code` (see `ValidationCode`), dotted `path`, `message`, and `params` for programmatic handling and i18n; match on codes rather than message text
4647
- **API-friendly flattened errors** with field paths for easy frontend integration (`getFlattenedErrors()`, `ValidationException::flattenErrors()`)
4748
- **Intuitive custom validation** with `satisfies()` method and optional error messages
4849
- **Single-value validation** with `const()` for exact value matching (available on all validators)

docs/api-reference/validator-factory.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,8 @@ try {
293293
if ($valid) {
294294
echo 'Valid email: ' . $data;
295295
} else {
296-
echo 'Errors: ' . implode(', ', $errors);
296+
// $errors is a list of ValidationError objects
297+
echo 'Errors: ' . implode(', ', array_map(fn($e) => $e->getMessage(), $errors));
297298
}
298299
```
299300

docs/examples/form-validation.md

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
101101
}
102102
?>
103103

104-
<!-- Display errors in template -->
104+
<!-- Display errors in template ($errors is a list of ValidationError objects) -->
105105
<?php if (isset($errors)): ?>
106106
<div class="alert alert-danger">
107-
<?php foreach ($errors as $field => $fieldErrors): ?>
108-
<?php foreach ($fieldErrors as $error): ?>
109-
<div><?= htmlspecialchars($error) ?></div>
110-
<?php endforeach; ?>
107+
<?php foreach ($errors as $error): ?>
108+
<div><?= htmlspecialchars($error->getMessage()) ?></div>
111109
<?php endforeach; ?>
112110
</div>
113111
<?php endif; ?>
@@ -844,12 +842,9 @@ function formatErrorsForDisplay(array $errors): array
844842
{
845843
$formatted = [];
846844

847-
foreach ($errors as $field => $fieldErrors) {
848-
$fieldName = ucfirst(str_replace('_', ' ', $field));
849-
850-
foreach ($fieldErrors as $error) {
851-
$formatted[] = "{$fieldName}: {$error}";
852-
}
845+
foreach ($errors as $error) {
846+
$fieldName = ucfirst(str_replace(['_', '.'], [' ', ' '], $error->getPath()));
847+
$formatted[] = "{$fieldName}: {$error->getMessage()}";
853848
}
854849

855850
return $formatted;

docs/getting-started/basic-usage.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ try {
6262

6363
### `tryValidate()` - Tuple-based
6464

65-
Returns a tuple `[bool $valid, mixed $data, array $errors]`:
65+
Returns a tuple `[bool $valid, mixed $data, ?array $errors]`, where `$errors` is a flat list of
66+
`ValidationError` objects (or `null` on success):
6667

6768
```php
6869
$validator = Validator::isString()->email();
@@ -72,7 +73,8 @@ $validator = Validator::isString()->email();
7273
if ($valid) {
7374
echo "Valid email: " . $data;
7475
} else {
75-
echo "Errors: " . implode(', ', $errors);
76+
echo "Errors: " . implode(', ', array_map(fn($e) => $e->getMessage(), $errors));
77+
// Each error also exposes getPath(), getCode(), and getParams()
7678
}
7779
```
7880

@@ -277,9 +279,9 @@ $validator = Validator::isString()
277279

278280
[$valid, $data, $errors] = $validator->tryValidate('ab');
279281

280-
// $errors might contain:
282+
// $errors is a flat list of ValidationError objects:
281283
// [
282-
// 'Value must be at least 5 characters long'
284+
// ValidationError(path: '', code: 'STRING_TOO_SHORT', message: 'Value must be at least 5 characters long'),
283285
// ]
284286
```
285287

docs/getting-started/core-concepts.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ All validators extend `FieldValidator`, which provides:
4040
### Core Validation Methods
4141

4242
- `validate(mixed $value): mixed` - Throws exception on failure
43-
- `tryValidate(mixed $value): array` - Returns `[bool, mixed, array]`
43+
- `tryValidate(mixed $value): array` - Returns `[bool $valid, mixed $data, ?array $errors]`, where `$errors` is a list of `ValidationError` objects
4444

4545
### Common Configuration
4646

@@ -342,7 +342,7 @@ $validator = Validator::isString()
342342
// Stops at the first failing rule in this chain:
343343
[$valid, $data, $errors] = $validator->tryValidate('ab');
344344
// $errors = [
345-
// 'Value must be at least 5 characters long'
345+
// ValidationError(path: '', code: 'STRING_TOO_SHORT', message: 'Value must be at least 5 characters long'),
346346
// ]
347347
```
348348

docs/guides/array-validation.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -428,10 +428,9 @@ $validator = Validator::isArray()->items(Validator::isInt()->coerce());
428428

429429
if (!$valid) {
430430
echo "Validation failed:\n";
431-
print_r($errors);
432-
// Output:
431+
// $errors is a flat list of ValidationError objects:
433432
// [
434-
// '1' => ['Value must be an integer']
433+
// ValidationError(path: '1', code: 'INVALID_TYPE', message: 'Value must be an integer'),
435434
// ]
436435

437436
// Flattened errors preserve array indices
@@ -519,7 +518,7 @@ Custom message:
519518

520519
### Custom Cross-Item Validation with satisfies()
521520

522-
For validations beyond uniqueness (ordering, dependencies, custom logic), use `satisfies()` on the array validator. Structure errors as `[arrayIndex => [fieldName => [errorMessage]]]` to get field-level paths.
521+
For validations beyond uniqueness (ordering, dependencies, custom logic), use `satisfies()` on the array validator. Throw a `ValidationException` of `ValidationError` objects whose `path` is `"{index}.{field}"` to get field-level paths.
523522

524523
### Simple Uniqueness Check (Array-Level Error)
525524

docs/guides/custom-validation.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,12 +264,34 @@ $validator = Validator::isString()
264264

265265
[$valid, $data, $errors] = $validator->tryValidate('short');
266266

267-
// $errors will contain:
267+
// $errors is a flat list of ValidationError objects; the chain stops at the first failure:
268268
// [
269-
// 'Value must be at least 8 characters long'
269+
// ValidationError(path: '', code: 'STRING_TOO_SHORT', message: 'Value must be at least 8 characters long'),
270270
// ]
271271
```
272272

273+
## Custom Error Codes and Placeholders
274+
275+
`satisfies()` accepts two optional trailing arguments: a stable `$code` and a `$params` map. The
276+
code lets integrators handle the error programmatically (it defaults to `CUSTOM`), and params double
277+
as `{name}` placeholders substituted into the message:
278+
279+
```php
280+
$validator = Validator::isString()->satisfies(
281+
fn($value) => strlen($value) >= 12,
282+
'Password must be at least {min} characters',
283+
'PASSWORD_TOO_SHORT',
284+
['min' => 12],
285+
);
286+
287+
[$valid, $data, $errors] = $validator->tryValidate('weak');
288+
// $errors[0]->getCode() === 'PASSWORD_TOO_SHORT'
289+
// $errors[0]->getMessage() === 'Password must be at least 12 characters'
290+
// $errors[0]->getParams() === ['min' => 12]
291+
```
292+
293+
See the [Error Handling Guide](error-handling.md#structured-errors) for the full structured-error model.
294+
273295
## Advanced Patterns
274296

275297
### Using External Validation Libraries

0 commit comments

Comments
 (0)