Skip to content

Commit 3d4788f

Browse files
committed
fix(validator): isolate shared validator state across clones and schema reuse
- Clone schema field validators at construction time so shared instances do not leak mutations into schemas - Make coerceAll() recursive through nested schemas and array item validators; each field is cloned so shared definitions are not mutated - Clone FieldValidator operands in satisfies(), satisfiesAll(), satisfiesAny(), satisfiesNone(), and contains() at capture time; rebuild on __clone() to isolate pipeline state - Reset transient currentType before and after each tryValidate() run so transform/pipe validators remain repeatable across calls and clones - Track bindable flag per pipeline step so __clone() skips Closure::bindTo() on static closures (e.g. from in()), avoiding PHP warnings - Add defaultUsing(callable) for fresh mutable defaults per validation run; default() returns objects by handle - Simplify FloatValidator coercion (remove redundant ternary)
1 parent 9cfe03d commit 3d4788f

15 files changed

Lines changed: 823 additions & 100 deletions

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,16 @@ All notable changes to this project will be documented in this file.
77
### Fixed
88

99
- `BoolValidator::coerce()` no longer string-casts non-scalar values (arrays triggered `E_WARNING`, objects without `__toString` threw `Error`); non-scalars are passed through to type validation, which raises `ValidationException` as for other invalid types
10+
- `coerceAll()` no longer calls `coerce()` on schema field validators in place (which leaked coercion to every other schema reusing the same `FieldValidator` instance); `coerceAll()` now replaces each field with a clone that has coercion enabled
11+
- `coerceAll()` is now recursive: nested `AssociativeValidator`, `ObjectValidator`, and `ArrayValidator` (with item validators) all propagate coercion to their children; previously only top-level schema fields were coerced
12+
- `FieldValidator::__clone()` skips `Closure::bindTo()` for static closures (for example from `in()`), avoiding PHP warnings when cloning such validators; pipeline steps now carry a `bindable` flag set at insertion time so cloning no longer needs `ReflectionFunction`
13+
- `satisfies()`, `satisfiesAll()`, `satisfiesAny()`, `satisfiesNone()`, and `ArrayValidator::contains()` now clone any `FieldValidator` operand at capture time and rebuild those operands when the outer validator is cloned, so later mutations or clone-local pipeline state do not leak across validator instances
14+
- `FieldValidator::tryValidate()` now resets transient type-tracking before and after every validation run, so validators that use `transform()`/`pipe()` remain repeatable across multiple calls and clones do not inherit stale runtime state
15+
- `default()` no longer deep-copies object values; object defaults are returned as-is (shared by handle). Use `defaultUsing()` when each validation run needs a fresh object instance
1016

1117
### Added
1218

19+
- `defaultUsing(callable $factory)` on `FieldValidator`: invokes the factory whenever validation resolves to null, providing a fresh default per validation run; intended for mutable domain objects or other defaults that should not be shared across validations or clones
1320
- `passthrough()` on `AssociativeValidator` and `ObjectValidator`: copy undeclared keys or public properties from the input to the validated output without validating them; declared schema fields are still validated; values already written from the schema (including `outputKey` targets) are not overwritten by passthrough
1421

1522
## [0.14.0] - 2026-03-23

docs/api-reference/validator-factory.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,8 @@ $strictString = Validator::isString(); // No coerce() call
707707

708708
Sets a default value when validation passes but the value is null.
709709

710+
Scalars and flat arrays (no nested objects) are safe because PHP copies them by value. Object defaults and arrays containing objects are returned as-is, sharing the same object handles across runs; use `defaultUsing()` when the default contains any mutable objects.
711+
710712
```php
711713
$validator = Validator::isString()
712714
->nullifyEmpty() // Empty strings become null
@@ -717,6 +719,22 @@ $result = $validator->validate(''); // Returns: 'N/A'
717719

718720
---
719721

722+
### `defaultUsing(callable $factory): self`
723+
724+
Invokes the factory whenever validation resolves to null, so each validation run receives a fresh default value.
725+
726+
```php
727+
$validator = Validator::isObject()->defaultUsing(
728+
static fn() => (object) ['active' => true],
729+
);
730+
731+
$result = $validator->validate(null); // Returns a new stdClass each time
732+
```
733+
734+
Use this whenever the default contains mutable objects -- whether a plain object or an array with nested objects. Each call to `validate()` will invoke the factory and receive a fresh instance.
735+
736+
---
737+
720738
### `outputKey(string $key): self`
721739

722740
**Schema fields only.** Emits the validated value under a different key than the input field. Only applies when the validator is used inside `Validator::isAssociative()` or `Validator::isObject()`.

docs/getting-started/core-concepts.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ All validators extend `FieldValidator`, which provides:
4646

4747
- `required(): static` - Makes the field mandatory
4848
- `default(mixed $value): static` - Sets default value for null inputs
49+
- `defaultUsing(callable $factory): static` - Builds a fresh default for each null input
4950
- `coerce(): static` - Enables automatic type conversion
5051
- `in(array $values): static` - Restricts to specific values (primitive validators only)
5152

docs/guides/object-validation.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ $result = $schema->validate($input);
283283
$schema = Validator::isAssociative([
284284
'level' => Validator::isInt()->in([3, 5, 8]),
285285
'override' => Validator::isBool()
286-
])->coerceAll(); // Enables coercion for all fields
286+
])->coerceAll(); // Recursively enables coercion for all fields, nested schemas, and array items
287287

288288
$input = [
289289
'level' => '5', // String coerced to int
@@ -294,6 +294,8 @@ $result = $schema->validate($input);
294294
// Result: ['level' => 5, 'override' => false]
295295
```
296296

297+
> **Scope:** `coerceAll()` propagates through schema fields, nested schemas, and array item validators. It does not reach into assertion operands -- validators passed to `satisfies()`, `satisfiesAll()`, `satisfiesAny()`, `satisfiesNone()`, or `contains()`. Call `coerce()` on those validators individually when needed.
298+
297299
### Object-Array Coercion
298300

299301
```php

llms.txt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- `pipe(callable)`: Skips `null` values (type-preserving, expects specific type).
1212
- `transform(callable, bool $skipNull = true)`: Skips `null` by default (most type conversions don't need null). Set `$skipNull = false` to process null values.
1313
- **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+
- **Mutable Defaults:** `default()` stores the value as-is; scalars and flat arrays are copied by value in PHP, but objects (and arrays containing objects) share handles across runs. Use `defaultUsing()` when the default contains any mutable objects.
1415
- **Transformation Types:**
1516
- `pipe(callable)`: Intended for sanitization. Preserves type context and applies type-coercion to result.
1617
- `transform(callable)`: Intended for type conversion. Updates type context; no coercion.
@@ -54,6 +55,7 @@ _Available on all validators_
5455
```php
5556
required(?string $message = null): static
5657
default(mixed $value): static
58+
defaultUsing(callable(): mixed $factory): static
5759
coerce(): static
5860
outputKey(string $key): static (schema fields only: output under different key, e.g. input service_id -> output service)
5961
nullifyEmpty(): static (transforms '' or [] to null)
@@ -147,7 +149,7 @@ _Schema validation_
147149

148150
```php
149151
// Constructor accepts array<string, FieldValidator>
150-
coerceAll(): static // Recursively enables coercion on schema fields
152+
coerceAll(): static // Recursively enables coercion on schema fields, nested schemas, and array item validators (each validator is cloned so shared instances are not mutated); does not propagate into assertion operands (satisfies, contains, etc.) -- call coerce() on those individually
151153
passthrough(): static // Copy undeclared keys/properties from input to output without validating them; schema fields still validated; does not overwrite keys already set from the schema (including outputKey targets)
152154
```
153155

src/Lemmon/Validator/ArrayValidator.php

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class ArrayValidator extends FieldValidator
88
{
99
private ?FieldValidator $itemValidator = null;
1010
private bool $filterEmpty = false;
11+
private bool $coerceAll = false;
1112

1213
/**
1314
* @inheritDoc
@@ -25,7 +26,7 @@ protected function getValidatorType(): string
2526
*/
2627
public function items(FieldValidator $validator): self
2728
{
28-
$this->itemValidator = $validator;
29+
$this->itemValidator = $this->coerceAll ? $validator->clone()->coerceAll() : $validator->clone();
2930
return $this;
3031
}
3132

@@ -166,24 +167,47 @@ static function ($value, $key = null, $input = null) use ($fieldName, $message)
166167
*/
167168
public function contains(mixed $valueOrValidator, ?string $message = null): static
168169
{
169-
return $this->satisfies(
170-
static function ($value, $key = null, $input = null) use ($valueOrValidator) {
171-
if ($valueOrValidator instanceof FieldValidator) {
172-
// Check if any item matches the validator
173-
foreach ($value as $item) {
174-
[$valid] = $valueOrValidator->tryValidate($item);
175-
if ($valid) {
176-
return true;
177-
}
170+
$message ??= 'Value must contain the required item';
171+
172+
if ($valueOrValidator instanceof FieldValidator) {
173+
$valueOrValidator = $valueOrValidator->clone();
174+
175+
$this->addValidationStep(
176+
self::buildContainsRule($valueOrValidator),
177+
$message,
178+
static fn(): \Closure => self::buildValidationOperation(
179+
self::buildContainsRule($valueOrValidator->clone()),
180+
$message,
181+
),
182+
);
183+
184+
return $this;
185+
}
186+
187+
$this->addValidationStep(
188+
self::buildContainsRule($valueOrValidator),
189+
$message,
190+
);
191+
192+
return $this;
193+
}
194+
195+
private static function buildContainsRule(mixed $valueOrValidator): \Closure
196+
{
197+
return static function ($value, $key = null, $input = null) use ($valueOrValidator): bool {
198+
if ($valueOrValidator instanceof FieldValidator) {
199+
foreach ($value as $item) {
200+
[$valid] = $valueOrValidator->tryValidate($item);
201+
if ($valid) {
202+
return true;
178203
}
179-
return false;
180204
}
181205

182-
// Check if array contains the specific value (strict comparison)
183-
return in_array($valueOrValidator, $value, true);
184-
},
185-
$message ?? 'Value must contain the required item',
186-
);
206+
return false;
207+
}
208+
209+
return in_array($valueOrValidator, $value, true);
210+
};
187211
}
188212

189213
/**
@@ -268,6 +292,19 @@ protected function validateType(mixed $value, string $key): mixed
268292
return $value;
269293
}
270294

295+
public function coerceAll(): static
296+
{
297+
if ($this->coerceAll) {
298+
return $this;
299+
}
300+
$this->coerce = true;
301+
$this->coerceAll = true;
302+
if ($this->itemValidator !== null) {
303+
$this->itemValidator = $this->itemValidator->coerceAll();
304+
}
305+
return $this;
306+
}
307+
271308
public function __clone()
272309
{
273310
parent::__clone();

src/Lemmon/Validator/AssociativeValidator.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ class AssociativeValidator extends FieldValidator
1313
*/
1414
public function __construct(
1515
private array $schema,
16-
) {}
16+
) {
17+
foreach ($this->schema as $key => $validator) {
18+
$this->schema[$key] = $validator->clone();
19+
}
20+
}
1721

1822
/**
1923
* @inheritDoc
@@ -55,10 +59,6 @@ protected function validateType(mixed $value, string $key): mixed
5559
$errors = [];
5660

5761
foreach ($this->schema as $fieldKey => $validator) {
58-
if ($this->coerceAll) {
59-
$validator->coerce();
60-
}
61-
6262
// Get field value (null if not present)
6363
$fieldValue = array_key_exists($fieldKey, $value) ? $value[$fieldKey] : null;
6464

0 commit comments

Comments
 (0)