Skip to content

Commit 15621b5

Browse files
committed
BREAKING CHANGE: Implement form-safe empty string coercion and fix critical ObjectValidator bug
Critical Bug Fixes: - Fix ObjectValidator null property handling - isset() was excluding null properties from result objects - ObjectValidator now correctly includes all validated properties, even when null - Maintains consistency with AssociativeValidator behavior BREAKING CHANGE - Form-Safe Coercion: - IntValidator::coerce(): Empty strings ('') now convert to null instead of 0 - FloatValidator::coerce(): Empty strings ('') now convert to null instead of 0.0 - BoolValidator::coerce(): Empty strings ('') now convert to null instead of validation failure - Prevents dangerous zero defaults in form fields (bank balances, quantities, pricing) - Migration: Use explicit ->default(0) if you need zero defaults for empty form fields Enhanced Test Coverage: - Added BoolValidator test suite with comprehensive boolean validation and coercion tests - Updated IntValidator and FloatValidator tests for new empty string → null behavior - Added ObjectValidator test for null property inclusion - Total: 110 tests with 278 assertions Comprehensive Documentation Updates: - Added detailed form-safety rationale in Core Concepts guide - Enhanced Numeric Validation guide with real-world safety examples - Updated Basic Usage guide with form-safety notes and cross-references - Updated README.md coercion examples to show null instead of dangerous zeros - Updated CHANGELOG.md, ROADMAP.md, AGENTS.md, and IDEAS.md to reflect changes This release prioritizes real-world application safety over PHP's default type casting behavior.
1 parent e2a18d6 commit 15621b5

17 files changed

Lines changed: 300 additions & 32 deletions

AGENTS.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ A comprehensive, fluent validation library for PHP inspired by Valibot and Zod.
2424
### Validation Capabilities
2525
- **Static Logical Combinators** - `Validator::allOf()`, `Validator::anyOf()`, `Validator::not()` for complex rule composition and mixed-type validation
2626
- **Instance Logical Combinators** - `allOf()`, `anyOf()`, `not()` instance methods for chaining validation rules
27-
- **Enhanced Numeric Coercion** - Empty strings automatically coerce to 0/0.0 for practical form handling
27+
- **Form-Safe Coercion** - Empty strings convert to `null` (not dangerous `0`/`0.0`/`false`) for real-world form safety
2828
- **Array Filtering** - `filterEmpty()` method removes empty values while maintaining indexed array structure
2929
- **Type-Aware Transformations** - Revolutionary `transform()` and `pipe()` system with intelligent type context switching
3030
- **Context-Aware Validation** - Custom validators receive `(value, key, input)` parameters
@@ -62,7 +62,7 @@ A comprehensive, fluent validation library for PHP inspired by Valibot and Zod.
6262
## 🔧 Development Workflow
6363

6464
### Code Quality
65-
- **Testing** - Pest PHP with organized test suite (9 focused test files, 103 tests, 250 assertions)
65+
- **Testing** - Pest PHP with organized test suite (10 focused test files, 110 tests, 278 assertions)
6666
- **Static Analysis** - PHPStan at maximum level for type safety
6767
- **Code Style** - PHP-CS-Fixer for consistent formatting
6868
- **Performance** - Optimized validation logic with eliminated code duplication
@@ -81,6 +81,7 @@ tests/
8181
├── StringValidatorTest.php # String formats
8282
├── IntValidatorTest.php # Integer constraints
8383
├── FloatValidatorTest.php # Float constraints
84+
├── BoolValidatorTest.php # Boolean validation
8485
├── FieldValidatorTest.php # Base functionality
8586
├── NumericConstraintsTraitTest.php # Shared numeric logic
8687
└── ValidatorStaticCombinatorsTest.php # Static logical combinators
@@ -102,7 +103,7 @@ tests/
102103
- **8 validator types** covering all PHP data types
103104
- **32+ built-in validation methods** including static logical combinators, array filtering, and type-aware transformations
104105
- **4,000+ lines of documentation** with practical examples
105-
- **103 unit tests** with comprehensive coverage (250 assertions)
106+
- **110 unit tests** with comprehensive coverage (278 assertions)
106107
- **Zero technical debt** with modern PHP 8.1+ codebase
107108

108109
## 🔮 Vision & Roadmap

CHANGELOG.md

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,33 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
### Fixed
8+
- **CRITICAL BUG**: Fixed ObjectValidator null property handling - `isset()` was excluding null properties from result objects
9+
- ObjectValidator now correctly includes all validated properties in the result, even when they are null
10+
- Maintains consistency with AssociativeValidator behavior
11+
- Added comprehensive test coverage to prevent regression
12+
13+
### Changed
14+
- **BREAKING CHANGE**: Enhanced form safety for empty string coercion across all validators
15+
- `IntValidator::coerce()`: Empty strings (`''`) now convert to `null` instead of `0`
16+
- `FloatValidator::coerce()`: Empty strings (`''`) now convert to `null` instead of `0.0`
17+
- `BoolValidator::coerce()`: Empty strings (`''`) now convert to `null` instead of validation failure
18+
- **Rationale**: Prevents dangerous zero defaults in form fields (e.g., bank balances, quantities)
19+
- **Migration**: Use explicit `->default(0)` if you need zero defaults for empty form fields
20+
- Added comprehensive test coverage for new behavior (110 tests, 278 assertions)
21+
22+
### Added
23+
- **BoolValidator Test Suite**: Complete test coverage for boolean validation and coercion
24+
- Tests for string boolean coercion (`'true'`, `'false'`, `'on'`, `'off'`, `'1'`, `'0'`)
25+
- Case-insensitive coercion support
26+
- Empty string safety validation
27+
728
## [0.5.0] - 2025-10-08
829

930
### Added
10-
- **Enhanced Numeric Coercion**: `IntValidator` and `FloatValidator` now coerce empty strings to 0/0.0 respectively
11-
- Addresses real-world HTML form scenarios where empty fields are common
12-
- Maintains backward compatibility for existing numeric coercion behavior
31+
- **Enhanced Numeric Coercion**: `IntValidator` and `FloatValidator` coercion improvements for form handling
32+
- ⚠️ **SUPERSEDED**: This behavior was changed in the next release for safety reasons
33+
- See "Unreleased" section for current empty string coercion behavior
1334
- **Array Filtering with Auto-Reindexing**: New `filterEmpty()` method for `ArrayValidator`
1435
- Removes empty strings (`''`) and `null` values while preserving valid falsy values (`0`, `false`, `[]`)
1536
- Automatically reindexes arrays to maintain indexed array structure (0, 1, 2, 3...)

IDEAS.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@ This document captures innovative ideas and suggestions for potential future enh
88
**Status**: ✅ **IMPLEMENTED**
99
**Concept**: Revolutionary transformation system with intelligent type context switching.
1010
```php
11-
// Enhanced coercion - empty strings to numbers
12-
$age = Validator::isInt()->coerce()->validate(''); // Returns: 0
13-
$price = Validator::isFloat()->coerce()->validate(''); // Returns: 0.0
11+
// Enhanced coercion - empty strings to null for form safety
12+
$age = Validator::isInt()->coerce()->validate(''); // Returns: null (not dangerous 0)
13+
$price = Validator::isFloat()->coerce()->validate(''); // Returns: null (not dangerous 0.0)
1414

1515
// Array filtering with auto-reindexing
1616
$tags = Validator::isArray()->filterEmpty()->validate(['php', '', 'javascript', null]);
@@ -41,15 +41,15 @@ $formatted = Validator::isString()
4141
->validate('hello world'); // Laravel Str integration
4242
```
4343
**Benefits**:
44-
-Seamless HTML form data handling
44+
-Form-safe empty string handling (prevents dangerous zero defaults)
4545
- ✅ Maintains validator type contracts (indexed arrays stay indexed)
4646
- ✅ Preserves valid falsy values (0, false, [])
4747
- ✅ Type-aware transformation methods with intelligent context switching
4848
-`pipe()` maintains type, `transform()` can change type
4949
- ✅ Smart array coercion (indexed arrays auto-reindex, associative keys preserved)
5050
- ✅ Clean variadic syntax with `pipe(...$transformers)`
5151
- ✅ Perfect integration with external libraries (Laravel, Symfony)
52-
- ✅ Comprehensive test coverage (103 tests, 250 assertions)
52+
- ✅ Comprehensive test coverage (110 tests, 278 assertions)
5353

5454
### Static Logical Combinators (v0.4.0)
5555
**Status**: ✅ **IMPLEMENTED**

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,16 @@ $age = Validator::isInt()
9696
->max(120)
9797
->validate(25);
9898

99-
// Enhanced coercion for form data
99+
// Form-safe coercion (BREAKING CHANGE in latest version)
100100
$quantity = Validator::isInt()
101-
->coerce() // Empty strings become 0
102-
->validate(''); // Returns: 0
101+
->coerce() // Empty strings become null (not dangerous 0)
102+
->validate(''); // Returns: null
103103

104104
// Float with precision
105105
$price = Validator::isFloat()
106106
->positive()
107107
->multipleOf(0.01) // Cents precision
108-
->coerce() // Empty strings become 0.0
108+
->coerce() // Empty strings become null (not dangerous 0.0)
109109
->validate(19.99);
110110
```
111111

ROADMAP.md

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,23 @@ This roadmap outlines the strategic development plan for future releases, priori
2929
- [ ] **Update all documentation** - Replace `addValidation()` with `validate()` across all guides and examples
3030

3131
### Critical Bug Fixes
32-
- [ ] **Fix ObjectValidator null property handling** - `isset($validatedFieldValue)` excludes null properties from result object
33-
- Should return object with null properties, not empty object
34-
- AssociativeValidator works correctly, ObjectValidator has the bug
35-
- [ ] **Fix dangerous empty string coercion across all validators** - Make `coerce()` convert `''``null` (not 0/false) for form safety
32+
- [x]**Fix ObjectValidator null property handling** - `isset($validatedFieldValue)` excluded null properties from result object
33+
- Fixed: ObjectValidator now correctly includes all validated properties, even when null
34+
- Maintains consistency with AssociativeValidator behavior
35+
- Added comprehensive test coverage to prevent regression
36+
- [x]**Fix dangerous empty string coercion across all validators** - **BREAKING CHANGE**: `coerce()` now converts `''``null` for form safety
3637
- IntValidator: `''``null` (not 0) - prevents dangerous zero defaults in forms
3738
- FloatValidator: `''``null` (not 0.0) - prevents dangerous zero defaults in forms
3839
- BoolValidator: `''``null` (not false) - empty query params should be null
39-
- This is a **BREAKING CHANGE** but critical for real-world form/API safety
40-
- [ ] **Add comprehensive tests for empty string handling** - Ensure all validators treat empty strings as "no value provided"
41-
- [ ] **Update documentation** - Explain the form-safety rationale behind empty string → null coercion
40+
- Critical for real-world form/API safety (prevents accidental zero bank balances, etc.)
41+
- [x]**Add comprehensive tests for empty string handling** - Added BoolValidator test suite and updated existing tests
42+
- 110 tests total with 278 assertions
43+
- Complete coverage for new empty string → null coercion behavior
44+
- [x]**Update documentation** - Explain the form-safety rationale behind empty string → null coercion
45+
- Added comprehensive form-safety sections to Core Concepts and Numeric Validation guides
46+
- Included real-world examples showing dangerous scenarios prevented
47+
- Added migration guidance for users who need explicit zero defaults
48+
- Added brief form-safety note in Basic Usage guide with cross-references
4249

4350
## 📋 Next Release (v0.5.0) - Utility Features
4451

@@ -144,7 +151,7 @@ $formValidator = Validator::isAssociative([
144151
->transform(fn($v) => array_values($v)) // Re-index array
145152

146153
// After (clean and discoverable):
147-
->coerce() // ✅ Enhanced: empty strings → 0!
154+
->coerce() // ✅ Enhanced: empty strings → null (form-safe)!
148155
->filterEmpty() // ✅ Clean array method + auto-reindex!
149156

150157
// Type-aware transformation chains (NEW!):
@@ -188,7 +195,7 @@ $stringValidator = Validator::isString()
188195

189196
### Numeric Transformations
190197
- [ ] **`clamp(min, max)`** - Restrict numbers to range (not obvious: max(min, min(max, value)))
191-
- [x]**Enhanced `coerce()`** - Empty strings → 0 for numeric types (already implemented!)
198+
- [x]**Enhanced `coerce()`** - Empty strings → `null` for form safety (BREAKING CHANGE implemented!)
192199
- [x]**`nullifyEmpty()`** - Convert empty strings to null (already implemented!)
193200
- [x]**`transform()`** / **`pipe()`** - Generic transformation methods (already implemented!)
194201

docs/getting-started/basic-usage.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,11 @@ $withDefault = Validator::isString()->default('Hello World');
108108
$coercing = Validator::isInt()->coerce();
109109

110110
$result = $coercing->validate('123'); // Returns: 123 (integer)
111+
$result = $coercing->validate(''); // Returns: null (form-safe!)
111112
```
112113

114+
> **Form Safety Note**: Empty strings convert to `null` (not `0`/`0.0`/`false`) to prevent dangerous defaults in form handling. See [Core Concepts - Form-Safe Empty String Handling](core-concepts.md#form-safe-empty-string-handling) for details.
115+
113116
### Allowed Values
114117

115118
```php

docs/getting-started/core-concepts.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,82 @@ $obj = new stdClass(); $obj->key = 'value';
173173
$result = $arrayValidator->validate($obj); // Returns: ['key' => 'value']
174174
```
175175

176+
### Form-Safe Empty String Handling
177+
178+
**BREAKING CHANGE (v0.6.0)**: The library now prioritizes real-world form safety over PHP's default type casting behavior.
179+
180+
#### The Problem with Traditional Coercion
181+
182+
In traditional PHP type casting, empty strings convert to "falsy" defaults:
183+
- `(int) ''``0`
184+
- `(float) ''``0.0`
185+
- `(bool) ''``false`
186+
187+
This creates **dangerous scenarios** in real-world applications:
188+
189+
```php
190+
// ❌ DANGEROUS: Traditional PHP behavior
191+
$bankBalance = (int) $_POST['balance']; // Empty field becomes 0!
192+
$itemQuantity = (int) $_POST['quantity']; // Empty field becomes 0!
193+
$isActive = (bool) $_POST['active']; // Empty checkbox becomes false!
194+
```
195+
196+
#### Form-Safe Solution
197+
198+
The Lemmon Validator treats empty strings as **"no value provided"** (`null`) rather than converting to potentially dangerous defaults:
199+
200+
```php
201+
// ✅ SAFE: Lemmon Validator behavior
202+
$validator = Validator::isInt()->coerce();
203+
$bankBalance = $validator->validate(''); // Returns: null (not dangerous 0)
204+
205+
$validator = Validator::isFloat()->coerce();
206+
$price = $validator->validate(''); // Returns: null (not dangerous 0.0)
207+
208+
$validator = Validator::isBool()->coerce();
209+
$isActive = $validator->validate(''); // Returns: null (not dangerous false)
210+
```
211+
212+
#### Real-World Form Scenarios
213+
214+
```php
215+
// Form validation with safe empty string handling
216+
$formValidator = Validator::isAssociative([
217+
'name' => Validator::isString()->required(), // Must be provided
218+
'age' => Validator::isInt()->coerce(), // Empty → null (optional)
219+
'salary' => Validator::isFloat()->coerce(), // Empty → null (safe!)
220+
'active' => Validator::isBool()->coerce(), // Empty → null (optional)
221+
]);
222+
223+
// Safe handling of empty form fields
224+
$formData = [
225+
'name' => 'John Doe',
226+
'age' => '', // Empty form field
227+
'salary' => '', // Empty form field
228+
'active' => '', // Empty checkbox
229+
];
230+
231+
[$valid, $result, $errors] = $formValidator->tryValidate($formData);
232+
// Result: ['name' => 'John Doe', 'age' => null, 'salary' => null, 'active' => null]
233+
```
234+
235+
#### Migration Guide
236+
237+
If you need explicit zero defaults for empty fields, use `default()`:
238+
239+
```php
240+
// If you need zero defaults (rare cases)
241+
$quantity = Validator::isInt()
242+
->coerce()
243+
->default(0) // Explicit zero default
244+
->validate(''); // Returns: 0
245+
246+
// Better: Use nullifyEmpty() for explicit null conversion
247+
$optional = Validator::isInt()
248+
->nullifyEmpty() // Explicit empty → null
249+
->validate(''); // Returns: null
250+
```
251+
176252
## Schema Validation Deep Dive
177253

178254
Schema validation works recursively:

docs/guides/numeric-validation.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,78 @@ $result = $coercingFloat->validate('123'); // Returns: 123.0 (float)
144144
$result = $coercingFloat->validate(42); // Returns: 42.0 (float)
145145
```
146146

147+
### Form-Safe Empty String Handling
148+
149+
**BREAKING CHANGE (v0.6.0)**: Empty strings now convert to `null` instead of `0`/`0.0` for real-world form safety.
150+
151+
#### Why This Change Was Critical
152+
153+
Traditional PHP type casting creates dangerous scenarios in form handling:
154+
155+
```php
156+
// ❌ DANGEROUS: Old behavior (PHP default)
157+
$balance = (int) $_POST['balance']; // Empty field → 0 (dangerous!)
158+
$price = (float) $_POST['price']; // Empty field → 0.0 (dangerous!)
159+
$quantity = (int) $_POST['quantity']; // Empty field → 0 (dangerous!)
160+
```
161+
162+
**Real-world problems:**
163+
- Bank balance field left empty → account balance becomes $0
164+
- Product quantity field empty → order quantity becomes 0 items
165+
- Price field empty → product becomes free ($0.00)
166+
167+
#### Safe Coercion Behavior
168+
169+
The Lemmon Validator now treats empty strings as "no value provided":
170+
171+
```php
172+
// ✅ SAFE: New behavior
173+
$intValidator = Validator::isInt()->coerce();
174+
$balance = $intValidator->validate(''); // Returns: null (safe!)
175+
176+
$floatValidator = Validator::isFloat()->coerce();
177+
$price = $floatValidator->validate(''); // Returns: null (safe!)
178+
```
179+
180+
#### Practical Form Validation
181+
182+
```php
183+
// Safe form validation schema
184+
$orderValidator = Validator::isAssociative([
185+
'customer_id' => Validator::isInt()->required(),
186+
'quantity' => Validator::isInt()->coerce()->min(1), // Empty → null → validation fails (safe!)
187+
'unit_price' => Validator::isFloat()->coerce()->positive(), // Empty → null → validation fails (safe!)
188+
'discount' => Validator::isFloat()->coerce()->default(0.0), // Empty → null → 0.0 default (explicit)
189+
]);
190+
191+
$formData = [
192+
'customer_id' => '123',
193+
'quantity' => '', // Empty field - safely handled
194+
'unit_price' => '', // Empty field - safely handled
195+
'discount' => '', // Empty field - gets default
196+
];
197+
198+
[$valid, $result, $errors] = $orderValidator->tryValidate($formData);
199+
// quantity and unit_price will fail validation (safe!)
200+
// discount will use default value (explicit choice)
201+
```
202+
203+
#### Migration Guide
204+
205+
If you need zero defaults for empty fields, be explicit:
206+
207+
```php
208+
// If you genuinely need zero for empty fields (rare)
209+
$explicitZero = Validator::isInt()
210+
->coerce()
211+
->default(0) // Explicit zero default
212+
->validate(''); // Returns: 0
213+
214+
// Better: Handle empty values explicitly in your business logic
215+
$quantity = Validator::isInt()->coerce()->validate($input['quantity']);
216+
$finalQuantity = $quantity ?? 1; // Default to 1 if empty, not 0
217+
```
218+
147219
## Real-World Examples
148220

149221
### Age Validation

src/Lemmon/BoolValidator.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ class BoolValidator extends FieldValidator
99
*/
1010
protected function coerceValue(mixed $value): mixed
1111
{
12+
if ($value === '') {
13+
return null; // Empty string to null for form safety
14+
}
1215
if (in_array(strtolower((string) $value), ['true', 'on', '1'], true)) {
1316
return true;
1417
}

src/Lemmon/FieldValidator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,11 @@ public function tryValidate(mixed $value, string $key = '', mixed $input = null)
243243

244244
$value = $this->coerce ? $this->coerceValue($value) : $value;
245245

246+
// Handle null values after coercion (e.g., empty strings coerced to null)
247+
if (is_null($value)) {
248+
return $this->hasDefault ? [true, $this->default, null] : ($this->required ? [false, $value, ['Value is required']] : [true, null, null]);
249+
}
250+
246251
if ($this->oneOf && !in_array($value, $this->oneOf, true)) {
247252
return [false, $value, ['Value must be one of: ' . json_encode($this->oneOf)]];
248253
}

0 commit comments

Comments
 (0)