Skip to content

Commit fdd7ce6

Browse files
Create ShortCircuit validator and ShortCircuitCapable interface
This commit introduces a mechanism for validators to return early once the validation outcome is determined, rather than evaluating all child validators. The ShortCircuit validator evaluates validators sequentially and stops at the first failure, similar to how PHP's && operator works. This is useful when later validators depend on earlier ones passing, or when you want only the first error message. The ShortCircuitCapable interface allows composite validators (AllOf, AnyOf, OneOf, NoneOf, Each, All) to implement their own short-circuit logic: - AllOf: stops at first failure (like &&) - AnyOf: stops at first success (like ||) - OneOf: stops when two validators pass (already invalid) - NoneOf: stops at first success (already invalid) - Each/All: stops at first failing item Why "ShortCircuit" instead of "FailFast": The name "FailFast" was initially considered but proved misleading. While AllOf stops on failure (fail fast), AnyOf stops on success (succeed fast), and OneOf stops on the second success. The common behavior is not about failing quickly, but about returning as soon as the outcome is determined—which is exactly what short-circuit evaluation means. This terminology is familiar to developers from boolean operators (&& and ||), making the behavior immediately understandable. Co-authored-by: Alexandre Gomes Gaigalas <alganet@gmail.com> Assisted-by: Claude Code (Opus 4.5)
1 parent b352f17 commit fdd7ce6

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+732
-70
lines changed

docs/feature-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ Beyond the examples above, Respect\Validation provides specialized validators fo
129129
- **Grouped validation**: Combine validators with AND/OR logic using [AllOf](validators/AllOf.md), [AnyOf](validators/AnyOf.md), [NoneOf](validators/NoneOf.md), [OneOf](validators/OneOf.md).
130130
- **Iteration**: Validate every item in a collection with [Each](validators/Each.md).
131131
- **Length, Min, Max**: Validate derived values with [Length](validators/Length.md), [Min](validators/Min.md), [Max](validators/Max.md).
132-
- **Special cases**: Handle dynamic rules with [Lazy](validators/Lazy.md), short-circuit on first failure with [Circuit](validators/Circuit.md), or transform input before validation with [Call](validators/Call.md).
132+
- **Special cases**: Handle dynamic rules with [Lazy](validators/Lazy.md), short-circuit on first failure with [ShortCircuit](validators/ShortCircuit.md), or transform input before validation with [Call](validators/Call.md).
133133

134134
## Customizing error messages
135135

docs/migrating-from-v2-to-v3.md

Lines changed: 25 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -373,11 +373,10 @@ composer require ramsey/uuid
373373

374374
In 3.0, `Min` and `Max` validators exist but have different semantics. They extract the minimum/maximum value from a collection and validate it (see [Result composition](#result-composition)).
375375

376-
377-
| Validator | 2.x | 3.x |
378-
| ---------- | --------------- | --------------------------------------------- |
379-
| `Min` | Single value >= | Pick minimum value from iterable and validate |
380-
| `Max` | Single value <= | Pick minimum value from iterable and validate |
376+
| Validator | 2.x | 3.x |
377+
| --------- | --------------- | --------------------------------------------- |
378+
| `Min` | Single value >= | Pick minimum value from iterable and validate |
379+
| `Max` | Single value <= | Pick minimum value from iterable and validate |
381380

382381
##### `NotBlank` logic inverted
383382

@@ -572,9 +571,9 @@ Version 3.0 introduces several new validators:
572571
| `All` | Validates that every item in an iterable passes validation |
573572
| `Attributes` | Validates object properties using PHP attributes |
574573
| `BetweenExclusive` | Validates that a value is between two bounds (exclusive) |
575-
| `Circuit` | Short-circuit validation, stops at first failure |
576574
| `ContainsCount` | Validates the count of occurrences in a value |
577575
| `DateTimeDiff` | Validates date/time differences (replaces Age validators) |
576+
| `ShortCircuit` | Stops at first failure instead of collecting all errors |
578577
| `Hetu` | Validates Finnish personal identity codes (henkilötunnus) |
579578
| `KeyExists` | Checks if an array key exists |
580579
| `KeyOptional` | Validates an array key only if it exists |
@@ -630,26 +629,6 @@ v::betweenExclusive(1, 10)->assert(1); // fails (1 is not > 1)
630629
v::betweenExclusive(1, 10)->assert(10); // fails (10 is not < 10)
631630
```
632631

633-
#### Circuit
634-
635-
Validates input against a series of validators, stopping at the first failure. Useful for dependent validations:
636-
637-
```php
638-
$validator = v::circuit(
639-
v::key('countryCode', v::countryCode()),
640-
v::lazy(
641-
fn($input) => v::key(
642-
'subdivisionCode',
643-
v::subdivisionCode($input['countryCode'])
644-
)
645-
),
646-
);
647-
648-
$validator->assert([]); // → `.countryCode` must be present
649-
$validator->assert(['countryCode' => 'US']); // → `.subdivisionCode` must be present
650-
$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); // passes
651-
```
652-
653632
#### ContainsCount
654633

655634
Validates the count of occurrences of a value:
@@ -668,6 +647,26 @@ v::dateTimeDiff('years', v::greaterThanOrEqual(18))->assert('2000-01-01'); // pa
668647
v::dateTimeDiff('days', v::lessThan(30))->assert('2024-01-15'); // passes if less than 30 days ago
669648
```
670649

650+
#### ShortCircuit
651+
652+
Validates input against a series of validators, stopping at the first failure. Useful for dependent validations:
653+
654+
```php
655+
$validator = v::shortCircuit(
656+
v::key('countryCode', v::countryCode()),
657+
v::lazy(
658+
fn($input) => v::key(
659+
'subdivisionCode',
660+
v::subdivisionCode($input['countryCode'])
661+
)
662+
),
663+
);
664+
665+
$validator->assert([]); // → `.countryCode` must be present
666+
$validator->assert(['countryCode' => 'US']); // → `.subdivisionCode` must be present
667+
$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']); // passes
668+
```
669+
671670
#### Hetu
672671

673672
Validates Finnish personal identity codes (henkilötunnus):

docs/validators.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,9 @@ In this page you will find a list of validators by their category.
1717

1818
**Comparisons**: [All][] - [Between][] - [BetweenExclusive][] - [Equals][] - [Equivalent][] - [GreaterThan][] - [GreaterThanOrEqual][] - [Identical][] - [In][] - [Length][] - [LessThan][] - [LessThanOrEqual][] - [Max][] - [Min][]
1919

20-
**Composite**: [AllOf][] - [AnyOf][] - [Circuit][] - [NoneOf][] - [OneOf][]
20+
**Composite**: [AllOf][] - [AnyOf][] - [Circuit][] - [NoneOf][] - [OneOf][] - [ShortCircuit][]
2121

22-
**Conditions**: [Circuit][] - [Not][] - [When][]
22+
**Conditions**: [Circuit][] - [Not][] - [ShortCircuit][] - [When][]
2323

2424
**Core**: [Named][] - [Not][] - [Templated][]
2525

@@ -41,7 +41,7 @@ In this page you will find a list of validators by their category.
4141

4242
**Miscellaneous**: [Blank][] - [Falsy][] - [Masked][] - [Named][] - [Templated][] - [Undef][]
4343

44-
**Nesting**: [AllOf][] - [AnyOf][] - [Call][] - [Circuit][] - [Each][] - [Key][] - [KeySet][] - [Lazy][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [UndefOr][] - [When][]
44+
**Nesting**: [AllOf][] - [AnyOf][] - [Call][] - [Circuit][] - [Each][] - [Key][] - [KeySet][] - [Lazy][] - [NoneOf][] - [Not][] - [NullOr][] - [OneOf][] - [Property][] - [PropertyOptional][] - [ShortCircuit][] - [UndefOr][] - [When][]
4545

4646
**Numbers**: [Base][] - [Decimal][] - [Digit][] - [Even][] - [Factor][] - [Finite][] - [FloatType][] - [FloatVal][] - [Infinite][] - [IntType][] - [IntVal][] - [Multiple][] - [Negative][] - [Number][] - [NumericVal][] - [Odd][] - [Positive][] - [Roman][]
4747

@@ -186,6 +186,7 @@ In this page you will find a list of validators by their category.
186186
- [ResourceType][] - `v::resourceType()->assert(fopen('/path/to/file.txt', 'r'));`
187187
- [Roman][] - `v::roman()->assert('IV');`
188188
- [ScalarVal][] - `v::scalarVal()->assert(135.0);`
189+
- [ShortCircuit][] - `v::shortCircuit(v::intVal(), v::positive())->assert(15);`
189190
- [Size][] - `v::size('KB', v::greaterThan(1))->assert('/path/to/file');`
190191
- [Slug][] - `v::slug()->assert('my-wordpress-title');`
191192
- [Sorted][] - `v::sorted('ASC')->assert([1, 2, 3]);`
@@ -342,6 +343,7 @@ In this page you will find a list of validators by their category.
342343
[ResourceType]: validators/ResourceType.md "Validates whether the input is a resource."
343344
[Roman]: validators/Roman.md "Validates if the input is a Roman numeral."
344345
[ScalarVal]: validators/ScalarVal.md "Validates whether the input is a scalar value or not."
346+
[ShortCircuit]: validators/ShortCircuit.md "Validates the input against a series of validators, stopping at the first failure."
345347
[Size]: validators/Size.md "Validates whether the input is a file that is of a certain size or not."
346348
[Slug]: validators/Slug.md "Validates whether the input is a valid slug."
347349
[Sorted]: validators/Sorted.md "Validates whether the input is sorted in a certain order or not."

docs/validators/AllOf.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ Used when all validators have failed.
5656
## See Also
5757

5858
- [AnyOf](AnyOf.md)
59-
- [Circuit](Circuit.md)
6059
- [NoneOf](NoneOf.md)
6160
- [OneOf](OneOf.md)
61+
- [ShortCircuit](ShortCircuit.md)
6262
- [When](When.md)

docs/validators/AnyOf.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ so `AnyOf()` returns true.
5050
## See Also
5151

5252
- [AllOf](AllOf.md)
53-
- [Circuit](Circuit.md)
5453
- [ContainsAny](ContainsAny.md)
5554
- [NoneOf](NoneOf.md)
5655
- [OneOf](OneOf.md)
56+
- [ShortCircuit](ShortCircuit.md)
5757
- [When](When.md)

docs/validators/Call.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,17 +53,17 @@ v::call(
5353
```
5454

5555
Call does not handle possible errors (type mismatches). If you need to
56-
ensure that your callback is of a certain type, use [Circuit](Circuit.md) or
56+
ensure that your callback is of a certain type, use [ShortCircuit](ShortCircuit.md) or
5757
handle it using a closure:
5858

5959
```php
6060
v::call('strtolower', v::equals('ABC'))->assert(123);
6161
// 𝙭 strtolower(): Argument #1 ($string) must be of type string, int given
6262

63-
v::circuit(v::stringType(), v::call('strtolower', v::equals('abc')))->assert(123);
63+
v::shortCircuit(v::stringType(), v::call('strtolower', v::equals('abc')))->assert(123);
6464
// → 123 must be a string
6565

66-
v::circuit(v::stringType(), v::call('strtolower', v::equals('abc')))->assert('ABC');
66+
v::shortCircuit(v::stringType(), v::call('strtolower', v::equals('abc')))->assert('ABC');
6767
// Validation passes successfully
6868
```
6969

docs/validators/Lazy.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,4 @@ on the input itself (`$_POST`), but it will use any input that’s given to the
5858

5959
- [Call](Call.md)
6060
- [CallableType](CallableType.md)
61-
- [Circuit](Circuit.md)
61+
- [ShortCircuit](ShortCircuit.md)

docs/validators/NoneOf.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Used when all validators have passed.
5959

6060
- [AllOf](AllOf.md)
6161
- [AnyOf](AnyOf.md)
62-
- [Circuit](Circuit.md)
6362
- [Not](Not.md)
6463
- [OneOf](OneOf.md)
64+
- [ShortCircuit](ShortCircuit.md)
6565
- [When](When.md)

docs/validators/OneOf.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,6 @@ Used when more than one validator has passed.
7373

7474
- [AllOf](AllOf.md)
7575
- [AnyOf](AnyOf.md)
76-
- [Circuit](Circuit.md)
7776
- [NoneOf](NoneOf.md)
77+
- [ShortCircuit](ShortCircuit.md)
7878
- [When](When.md)

docs/validators/ShortCircuit.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<!--
2+
SPDX-FileCopyrightText: (c) Respect Project Contributors
3+
SPDX-License-Identifier: MIT
4+
-->
5+
6+
# ShortCircuit
7+
8+
- `ShortCircuit()`
9+
- `ShortCircuit(Validator ...$validators)`
10+
11+
Validates the input against a series of validators, stopping at the first failure.
12+
13+
Like PHP's `&&` operator, it uses short-circuit evaluation: once the outcome is determined, remaining validators are
14+
skipped. Unlike [AllOf](AllOf.md), which evaluates all validators and collects all failures, `ShortCircuit` returns
15+
immediately.
16+
17+
```php
18+
v::shortCircuit(v::intVal(), v::positive())->assert(15);
19+
// Validation passes successfully
20+
```
21+
22+
This is useful when:
23+
24+
- You want only the first error message instead of all of them
25+
- Later validators depend on earlier ones passing (e.g., checking a format before checking a value)
26+
- You want to avoid unnecessary validation work
27+
28+
This validator is particularly useful in combination with [Lazy](Lazy.md) when later validations depend on earlier
29+
results. For example, validating a subdivision code that depends on a valid country code:
30+
31+
```php
32+
$validator = v::shortCircuit(
33+
v::key('countryCode', v::countryCode()),
34+
v::lazy(static fn($input) => v::key('subdivisionCode', v::subdivisionCode($input['countryCode']))),
35+
);
36+
37+
$validator->assert([]);
38+
// → `.countryCode` must be present
39+
40+
$validator->assert(['countryCode' => 'US']);
41+
// → `.subdivisionCode` must be present
42+
43+
$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'ZZ']);
44+
// → `.subdivisionCode` must be a subdivision code of United States
45+
46+
$validator->assert(['countryCode' => 'US', 'subdivisionCode' => 'CA']);
47+
// Validation passes successfully
48+
```
49+
50+
Because [SubdivisionCode](SubdivisionCode.md) requires a valid country code, it only makes sense to validate the
51+
subdivision after the country code passes. You could achieve this with [When](When.md), but you would have to repeat
52+
`v::key('countryCode', v::countryCode())` twice.
53+
54+
## Templates
55+
56+
This validator does not have templates of its own. It returns the result of the first failing validator, or the result
57+
of the last validator when all pass.
58+
59+
## Categorization
60+
61+
- Composite
62+
- Conditions
63+
- Nesting
64+
65+
## Changelog
66+
67+
| Version | Description |
68+
| ------: | :---------- |
69+
| 3.0.0 | Created |
70+
71+
## See Also
72+
73+
- [AllOf](AllOf.md)
74+
- [AnyOf](AnyOf.md)
75+
- [Lazy](Lazy.md)
76+
- [NoneOf](NoneOf.md)
77+
- [OneOf](OneOf.md)
78+
- [SubdivisionCode](SubdivisionCode.md)
79+
- [When](When.md)

0 commit comments

Comments
 (0)