You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(FieldValidator): simplify tryValidate() and fix default/required interaction
- Fix critical bug where required() rejected null before default() could apply its fallback
- Simplify tryValidate() from 67-line multi-branch flow to 33-line linear pipeline
- Remove duplicated early returns, mid-loop default checks, and mid-loop required checks
- default() now applies in exactly one place: after the pipeline, as last-resort fallback
- required() now checks in exactly one place: at the very end, after default
- Update docs (CHANGELOG, README, core-concepts, basic-usage, llms.txt) to reflect new flow
Copy file name to clipboardExpand all lines: CHANGELOG.md
+19Lines changed: 19 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8,6 +8,25 @@ All notable changes to this project will be documented in this file.
8
8
9
9
-`uniqueField(string $fieldName, ?string $message = null)` on `ArrayValidator` for cross-item uniqueness validation of a nested field; convenience wrapper around `satisfies()` that produces field-level error paths by structuring errors as `[index => [fieldName => [message]]]`; all members of a duplicate group receive errors (e.g., both `symlinks.0.destination` and `symlinks.2.destination`); skips items where the field is null or missing; works with both associative arrays and objects; uses strict comparison via `serialize()` to distinguish types
10
10
11
+
### Fixed
12
+
13
+
-**CRITICAL BUG**: Fixed `default()` and `required()` interaction -- `required()` was rejecting null values before `default()` had a chance to provide a fallback
14
+
-**Issue**: Chaining `->default($value)->required()` always threw "Value is required" for null inputs because `required` checked for null before `default` was applied
15
+
-**Root Cause**: `tryValidate()` had a scattered, multi-branch architecture with `default` and `required` checks duplicated across 5+ locations (early returns, pre-pipeline, mid-loop, post-loop), making the interaction order unpredictable
16
+
-**Fix**: Simplified `tryValidate()` to a clean linear 4-step flow: coerce, type check, pipeline, then default-before-required at the end
17
+
-**Impact**: `default()` and `required()` now work cooperatively -- `default()` is the last-resort fallback for null, `required()` only fails if the value is still null after default
18
+
-**Real-World Benefit**: Enables patterns like `->pipe(fn($x) => null)->default($fallback)->required()` where a pipeline step intentionally nullifies a value and `default()` catches it before `required()` enforces presence
19
+
20
+
### Changed
21
+
22
+
-**Simplified `tryValidate()` pipeline architecture** -- reduced from 67 lines with 5+ branch points to 33 lines with a linear flow
23
+
- Removed 2 duplicated early returns for null + no-pipeline + not-required
Copy file name to clipboardExpand all lines: README.md
+1Lines changed: 1 addition & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -29,6 +29,7 @@ Rather than reimplementing every possible transformation or validation rule, Lem
29
29
-**Smart Null Handling**: Validations skip `null` unless `required()`. `transform()` runs on `null`, while `pipe()` and `nullifyEmpty()` skip `null` for type safety.
30
30
-**Form Safety First**: Empty strings coerce to `null` (not dangerous `0`/`false`) to prevent real-world issues like accidental zero bank balances
31
31
-**Fluent API with Execution Order Guarantee**: Validation rules read like natural language and execute in the exact order written -- `Validator::isString()->pipe('trim')->nullifyEmpty()->required()`
32
+
-**Last-Resort Default**: `default()` is a flag applied after the pipeline -- fills in null only as a last resort, before `required()` enforces presence
32
33
-**Fail-Fast Per Field**: Each validator stops at the first failing rule, while schema validation still aggregates errors across fields
33
34
-**API-Friendly Error Format**: Flattened errors with field paths (`'_root'` for root-level, dot notation for nested) perfect for frontend consumption
34
35
-**Type-Aware Transformations**: Intelligent transformation system that maintains type context and handles coercion automatically
**Critical**: Pipeline steps execute in the **exact order written**. This matters for `pipe()`, `transform()`, and `nullifyEmpty()`. `required()`is a flag, so its position does not change execution order.
155
+
**Critical**: Pipeline steps execute in the **exact order written**. This matters for `pipe()`, `transform()`, and `nullifyEmpty()`. Both `required()`and `default()` are flags -- their position does not change execution order. `default()` fills in null after the pipeline as a last resort; `required()` enforces presence at the very end.
Copy file name to clipboardExpand all lines: docs/getting-started/core-concepts.md
+9-10Lines changed: 9 additions & 10 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -121,14 +121,13 @@ Validator::isObject($schema) // stdClass object with schema
121
121
122
122
Understanding the validation flow helps debug and optimize your validators:
123
123
124
-
1.**Input Received** - Raw value passed to validator
125
-
2.**Type Coercion** - If enabled, attempt type conversion
126
-
3.**Required Check** - If required and value is null → error
127
-
4.**Type Validation** - Check value type (skipped for null when the pipeline is present)
128
-
5.**Pipeline Execution** - Validations and transformations run in the order written (fail-fast per field)
129
-
6.**Required Re-Check** - If transformations produce null on a required field → error
130
-
7.**Default Application** - If final result is null and a default exists → apply default
131
-
8.**Return Result** - Return validated/coerced/transformed value or errors
124
+
1.**Type Coercion** - If enabled, attempt type conversion (empty strings become `null` for primitives)
125
+
2.**Type Validation** - Check value type (skipped for null -- lets the pipeline and default/required handle it)
126
+
3.**Pipeline Execution** - Validations and transformations run in the order written (fail-fast per field)
127
+
4.**Default** - Last-resort fallback: if the result is null and a default exists, apply it
128
+
5.**Required** - Single check, always last: if the value is still null after default, fail
129
+
130
+
`default()` and `required()` are both flags -- their position in the fluent chain does not change when they are evaluated. `default()` always applies after the pipeline as the last-resort fallback for null. `required()` always enforces presence at the very end, after default has had its chance.
Pipeline methods (`pipe()`, `transform()`, `nullifyEmpty()`, `satisfies()`, etc.) execute in their written order. `required()`is a flag, so its position does not change execution order:
259
+
Pipeline methods (`pipe()`, `transform()`, `nullifyEmpty()`, `satisfies()`, etc.) execute in their written order. `required()`and `default()` are flags -- their position does not change execution order:
261
260
262
261
```php
263
262
// Execution order: trim → nullifyEmpty
@@ -316,7 +315,7 @@ $nameValidator->validate(' '); // ❌ "Name is required" (trimmed to empt
316
315
->pipe('trim')->nullifyEmpty()->required()
317
316
```
318
317
319
-
You expect: trim → nullify → required check, and that's exactly what happens. `required()`is a flag and is enforced after the pipeline regardless of where it appears in the chain.
318
+
You expect: trim → nullify → required check, and that's exactly what happens. Both `required()`and `default()` are flags evaluated after the pipeline, regardless of where they appear in the chain. `default()` fills in null as a last resort, then `required()` enforces presence.
320
319
321
320
**Flexibility**: Different orders enable different behaviors for different use cases:
Copy file name to clipboardExpand all lines: llms.txt
+4-4Lines changed: 4 additions & 4 deletions
Original file line number
Diff line number
Diff line change
@@ -10,7 +10,7 @@
10
10
- **Transformations**:
11
11
- `pipe(callable)`: Skips `null` values (type-preserving, expects specific type).
12
12
- `transform(callable, bool $skipNull = true)`: Skips `null` by default (most type conversions don't need null). Set `$skipNull = false` to process null values.
13
-
- **Pipeline Order:** Pipeline steps execute in the exact order defined; `required()` is a flag enforced after the pipeline regardless of position.
13
+
- **Pipeline Order:** Pipeline steps execute in the exact order defined. `default()` and `required()` are flags evaluated after the pipeline regardless of position: `default()` is the last-resort fallback for null, `required()` enforces presence after default has had its chance.
14
14
- **Transformation Types:**
15
15
- `pipe(callable)`: Intended for sanitization. Preserves type context and applies type-coercion to result.
16
16
- `transform(callable)`: Intended for type conversion. Updates type context; no coercion.
- **Fail-Fast:** Each validator chain stops at the first failing rule; schema validators aggregate errors across fields.
27
27
- **Error Structure:** `ValidationException` contains nested array of error messages. Use `getFlattenedErrors()` for API format `[{path: string, message: string}]`.
- **Pipeline:** Supports both validation (`satisfies`) and transformation (`transform`, `pipe`).
28
+
- **Null Handling:** Validators skip `null` values unless `required()` is called. `default()` fills in null as a last-resort fallback after the pipeline; `required()` enforces presence at the very end.
29
+
- **Pipeline:** Supports both validation (`satisfies`) and transformation (`transform`, `pipe`). Flow: coerce -> type check -> pipeline -> default -> required.
30
30
- **Coercion:** `coerce()` enables smart type conversion (e.g., "true" -> `true`). Non-coercible values pass through unchanged.
31
31
- **Form Safety:** Empty strings `''` are treated as `null` for primitives if coerced.
32
32
@@ -183,7 +183,7 @@ oneOf(array $values, ?string $message = null): static // Deprecated alias for in
0 commit comments