Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,16 @@ Full documentation is at

## Stability

Pre-1.0. The builder surface — `FluentRule`, `FluentSchema`, `RuleSet` and
`Contracts\FluentRuleContract` — is settled and is what the documentation describes. The
extended rule library under `Rules\` is newer; its rules are stable in behaviour but their
constructor signatures may still gain parameters.
Pre-1.0. The **stable surface** — what 1.0 will cover under SemVer — is: `FluentRule`,
`FluentSchema`, `RuleSet` (including its events and `before()`/`after()` hooks), the rule
classes and their constructor signatures, the contracts (`ClientCheckable`,
`PrecognitionSkippable`, `TermList`, `FluentRuleContract`), `Check`, `Regex`,
`Validation::fake()`, `RuleRegistrar`, the console commands, and the `laranail.validation.*`
config keys.

Everything marked `@internal` — the fast-check compiler, the optimizer validators, the batch
machinery, everything under `Internal\` — may change in a minor, and an arch test enforces the
boundary. Build on the stable list; the optimizer is an implementation detail behind it.

Constrain to `^0.1` and read [UPGRADING.md](UPGRADING.md) before moving between versions: this
package fixes divergences from Laravel's own validator, and a fix can mean input an application
Expand Down
22 changes: 22 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ Breaking changes, and what to do about them. Versions not listed here need no ac

## Unreleased

### `ArrayRule::getEachRules()` is removed

It was an exact alias of `getEachKeyedRules()` — same body, wider advertised type. Replace
calls one-for-one:

| Before | After |
|---|---|
| `$rule->getEachRules()` | `$rule->getEachKeyedRules()` |

The narrow getters carry the whole surface: `getEachKeyedRules(): ?array` for the keyed form,
`getEachListRule(): ?ValidationRule` for the list form.

### The Laravel-11 compatibility branches are gone

`contains()` always constructs `Rules\Contains` (the pipe-string fallback and its serializer
are removed), `doesntContain()` no longer throws a `RuntimeException` that could not occur on
the supported floor, and `requiredUnless()`/`prohibitedUnless()` with a closure or bool now
construct `RequiredUnless`/`ProhibitedUnless` directly instead of inverting into the `If`
twins. Verdicts are unchanged; only unreachable code and misleading comments went away. If an
application type-checked against the inverted `RequiredIf` instance, match on
`RequiredUnless` instead.

### The optimized pipeline now agrees with Laravel where it silently did not (P1–P5)

Five fast-path defects made `RuleSet::validate()` (and FormRequests using the optimizer) return a
Expand Down
19 changes: 13 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,14 @@
{
"type": "vcs",
"url": "https://github.com/laranail/enumerator"
},
{
"type": "vcs",
"url": "https://github.com/laranail/validation-js"
}
],
"name": "laranail/validation",
"description": "Fluent validation rule builders for Laravel \u2014 type-aware rule objects, structured array validation, and an optimized wildcard validator.",
"description": "Fluent validation rule builders for Laravel — type-aware rule objects, structured array validation, and an optimized wildcard validator.",
"keywords": [
"laravel",
"laranail",
Expand Down Expand Up @@ -65,7 +69,9 @@
"illuminate/http": "^13.0",
"illuminate/support": "^13.0",
"illuminate/validation": "^13.0",
"laranail/package-tools": "^0.1"
"laranail/package-tools": "^0.1",
"laranail/console": "^0.1",
"symfony/process": "^7.4 || ^8.0"
},
"require-dev": {
"driftingly/rector-laravel": "^2.3",
Expand All @@ -90,12 +96,13 @@
"symplify/phpstan-extensions": "^12.0.2",
"tomasvotruba/cognitive-complexity": "^1.1",
"tomasvotruba/type-coverage": "^2.3.4",
"laranail/phone": "^0.1@dev"
"laranail/phone": "^0.1@dev",
"laranail/validation-js": "dev-main"
},
"suggest": {
"laranail/email": "^0.1 \u2014 maintained disposable-domain and role-account lists plus a production DNS resolver, bound over the Contracts\\Email\\* fallbacks this package ships. Nothing you call changes.",
"laranail/phone": "^0.1 \u2014 required by FluentRule::phone() and Rules\\Telecom\\Phone. Suggested rather than required because it carries libphonenumber's numbering-plan metadata, which a project validating only strings and dates should not have to install.",
"nikic/php-parser": "^5.0 \u2014 required by Simtabi\\Laranail\\Validation\\Testing\\Arch\\BansFieldRuleTypeMethods, an opt-in Pest/PHPUnit arch helper that bans type-specific method calls on the untyped FluentRule::field() builder."
"laranail/email": "^0.1 — maintained disposable-domain and role-account lists plus a production DNS resolver, bound over the Contracts\\Email\\* fallbacks this package ships. Nothing you call changes.",
"laranail/phone": "^0.1 — required by FluentRule::phone() and Rules\\Telecom\\Phone. Suggested rather than required because it carries libphonenumber's numbering-plan metadata, which a project validating only strings and dates should not have to install.",
"nikic/php-parser": "^5.0 — required by Simtabi\\Laranail\\Validation\\Testing\\Arch\\BansFieldRuleTypeMethods, an opt-in Pest/PHPUnit arch helper that bans type-specific method calls on the untyped FluentRule::field() builder."
},
"autoload": {
"psr-4": {
Expand Down
7 changes: 6 additions & 1 deletion phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,13 @@ parameters:
# Dropped 145 -> 135 by the php84 Rector sweep, which replaced several
# foreach loops with array_any()/array_all(). validateInternal() fell
# below 20 in the same pass and its entry is gone entirely.
# Raised 135 -> 141 by the Phase-1 extension seams: the §5.6 event
# lifecycle (compiling mutation, starting/completed/failed), the
# before()/after() hooks in runValidateInternal(), and toSchema()'s
# missing-dependency guard. The after() route itself lives in
# Internal\VanillaAfterRoute, not here.
-
rawMessage: 'Class cognitive complexity is 135, keep it under 80'
rawMessage: 'Class cognitive complexity is 141, keep it under 80'
identifier: complexity.classLike
count: 1
path: src/RuleSet.php
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ Don't use `mergeRecursive` — it deconstructs objects.

#### Extending a parent's `each([...])` / `children([...])` shape

When the parent defines a keyed `each()` or `children()` map and the child needs to add one sub-rule, use the extend helpers instead of `getEachRules()` + `Arr::wrap()` round-trips:
When the parent defines a keyed `each()` or `children()` map and the child needs to add one sub-rule, use the extend helpers instead of read-modify-write round-trips over the raw map:

```php
// Parent: SaveQuestionRequest
Expand Down Expand Up @@ -235,7 +235,7 @@ public function rules(): RuleSet
- `mergeEachRules($rules)` / `mergeChildRules($rules)` — later-wins merge primitive (what `modifyEach` / `modifyChildren` wrap).
- Base constraints (`nullable`, `max:20`, `required`, etc.) on the ArrayRule/FieldRule survive every call.
- `addEachRule` / `mergeEachRules` / `modifyEach` throw `CannotExtendListShapedEach` when invoked on a rule whose `each()` was set to a single `ValidationRule` (list form, e.g. `each(FluentRule::string())`). Convert to keyed form first: `each(['key' => FluentRule::…])`.
- Reading the stored shape: prefer the narrow getters `ArrayRule::getEachKeyedRules(): ?array<string, ValidationRule>` + `ArrayRule::getEachListRule(): ?ValidationRule`. The legacy union-returning `getEachRules()` is `@deprecated` since 1.24.0 — its list-form branch is scheduled for removal in 1.25.0.
- Reading the stored shape: the narrow getters `ArrayRule::getEachKeyedRules(): ?array<string, ValidationRule>` + `ArrayRule::getEachListRule(): ?ValidationRule` are the only getters. (A `getEachRules()` alias existed pre-1.0 and was removed for it; a stale note here once invented a 1.24/1.25 deprecation history for a package that had never passed v0.1 — corrected.)

### Password rules trait (Fortify/Breeze)

Expand Down
27 changes: 12 additions & 15 deletions src/Builder/Concerns/HasFieldModifiers.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,6 @@ protected function addRule(array|string|object $rules, ?string $message = null):

if (is_object($rules)) {
$this->rules[] = $rules;
// Compared by string class-name to avoid autoload-fatal on
// Laravel versions that lack a given rule class (Contains /
// DoesntContain are L12+; instanceof would trigger autoload
// on L11 and throw "class not found").
$rulesClass = $rules::class;

$this->lastConstraint = match (true) {
$rules instanceof RequiredIf => 'required',
Expand All @@ -142,8 +137,8 @@ protected function addRule(array|string|object $rules, ?string $message = null):
$rules instanceof NotIn => 'not_in',
$rules instanceof Unique => 'unique',
$rules instanceof Exists => 'exists',
$rulesClass === Contains::class => 'contains',
$rulesClass === DoesntContain::class => 'doesnt_contain',
$rules instanceof Contains => 'contains',
$rules instanceof DoesntContain => 'doesnt_contain',
default => lcfirst(class_basename($rules)),
};
} else {
Expand Down Expand Up @@ -274,10 +269,12 @@ public function requiredIf(Closure|bool|string $field, string|int|bool|BackedEnu
public function requiredUnless(Closure|bool|string $field, string|int|bool|BackedEnum ...$values): static
{
if ($field instanceof Closure || is_bool($field)) {
// RequiredUnless class only exists in Laravel 12+. Invert to RequiredIf.
$inverted = $field instanceof Closure ? static fn (): bool => ! $field() : ! $field;

return $this->addRule(new RequiredIf($inverted));
// A closure/bool has no field name to build `required_unless:f,v`
// from, so the string form cannot express it — the RULE OBJECT is
// the only spelling. (An earlier comment blamed a Laravel-version
// gap and inverted into RequiredIf; the class takes the
// condition directly.)
return $this->addRule(new RequiredUnless($field));
}

return $this->addRule('required_unless:' . $field . ',' . self::serializeValues($values));
Expand Down Expand Up @@ -367,10 +364,10 @@ public function prohibitedIf(Closure|bool|string $field, string|int|bool|BackedE
public function prohibitedUnless(Closure|bool|string $field, string|int|bool|BackedEnum ...$values): static
{
if ($field instanceof Closure || is_bool($field)) {
// ProhibitedUnless only exists in Laravel 12+. Invert to ProhibitedIf.
$inverted = $field instanceof Closure ? static fn (): bool => ! $field() : ! $field;

return $this->addRule(new ProhibitedIf($inverted));
// Same shape as requiredUnless(): no field name, so the rule
// object is the only spelling — and the class takes the
// condition directly.
return $this->addRule(new ProhibitedUnless($field));
}

return $this->addRule('prohibited_unless:' . $field . ',' . self::serializeValues($values));
Expand Down
58 changes: 4 additions & 54 deletions src/Builder/Nodes/ArrayRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
use Illuminate\Validation\Rules\DoesntContain;
use InvalidArgumentException;
use LogicException;
use RuntimeException;
use Simtabi\Laranail\Validation\Builder\Concerns\HasFieldModifiers;
use Simtabi\Laranail\Validation\Builder\Concerns\SelfValidates;
use Simtabi\Laranail\Validation\Contracts\FluentRuleContract;
Expand Down Expand Up @@ -84,25 +83,12 @@ public function each(ValidationRule|array $rules): static
return $this;
}

/**
* Keyed sub-rule map set via `each(['key' => $rule, ...])`. Alias of
* `getEachKeyedRules()`. Returns null when `each()` was never called or
* the current state is list-shaped — list-form retrieval lives on
* `getEachListRule()`.
*
* @return array<string, ValidationRule>|null
*/
public function getEachRules(): ?array
{
return $this->eachRules;
}

/**
* Keyed sub-rule map set via `each(['key' => $rule, ...])`.
*
* Returns null when `each()` was never called OR the current state is
* list-shaped (set via `each(VR)`). Narrow-typed replacement for
* `getEachRules()` in the keyed-form case.
* list-shaped (set via `each(VR)`) — list-form retrieval lives on
* `getEachListRule()`.
*
* @return array<string, ValidationRule>|null
*/
Expand All @@ -116,8 +102,7 @@ public function getEachKeyedRules(): ?array
* item is validated as a scalar against this rule.
*
* Returns null when `each()` was never called OR the current state is
* keyed (set via `each([...])`). Narrow-typed replacement for the
* `ValidationRule` branch of `getEachRules()`.
* keyed (set via `each([...])`).
*/
public function getEachListRule(): ?ValidationRule
{
Expand Down Expand Up @@ -270,50 +255,15 @@ public function distinct(?string $mode = null, ?string $message = null): static
*/
public function contains(Arrayable|UnitEnum|array|string|int ...$values): static
{
$resolved = $this->flattenContainsValues($values);

if (class_exists(Contains::class)) {
return $this->addRule(new Contains($resolved));
}

// Laravel 11: `Rules\Contains` class shipped in L12. Fall back to
// the pipe-string form with CSV-quoting that mirrors Contains::__toString.
return $this->addRule('contains:' . $this->serializeContainsValues($resolved));
return $this->addRule(new Contains($this->flattenContainsValues($values)));
}

/** @param Arrayable<array-key, mixed>|UnitEnum|array<int, mixed>|string|int ...$values */
public function doesntContain(Arrayable|UnitEnum|array|string|int ...$values): static
{
if (! class_exists(DoesntContain::class)) {
throw new RuntimeException('doesntContain() requires Laravel 12+.');
}

return $this->addRule(new DoesntContain($this->flattenContainsValues($values)));
}

/**
* CSV-quote + escape each value. Mirrors upstream `Rules\Contains::__toString()`
* for the Laravel 11 fallback path.
*
* @param array<int, mixed> $values
*/
private function serializeContainsValues(array $values): string
{
$serialized = array_map(static function (mixed $value): string {
// Mirror Laravel's enum_value(): BackedEnum → value, UnitEnum → name.
if ($value instanceof BackedEnum) {
$value = $value->value;
} elseif ($value instanceof UnitEnum) {
$value = $value->name;
}

/** @var scalar $value */
return '"' . str_replace('"', '""', (string) $value) . '"';
}, $values);

return implode(',', $serialized);
}

/**
* Unwrap a single `Arrayable` or `array` varargs entry so `->contains([...])`
* and `->contains(...$iter)` behave identically. Matches Laravel's
Expand Down
49 changes: 47 additions & 2 deletions src/Builder/Nodes/StringRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Simtabi\Laranail\Validation\Builder\Nodes;

use Closure;
use Illuminate\Contracts\Validation\DataAwareRule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Support\Traits\Conditionable;
Expand All @@ -10,6 +11,7 @@
use Simtabi\Laranail\Validation\Builder\Concerns\HasFieldModifiers;
use Simtabi\Laranail\Validation\Builder\Concerns\SelfValidates;
use Simtabi\Laranail\Validation\Contracts\FluentRuleContract;
use Simtabi\Laranail\Validation\Regex;

class StringRule implements DataAwareRule, FluentRuleContract, ValidatorAwareRule
{
Expand Down Expand Up @@ -147,9 +149,52 @@ public function macAddress(?string $message = null): static
return $this->addRule('mac_address', $message);
}

public function regex(string $pattern, ?string $message = null): static
/**
* The Laravel-style entry: a string pattern is used exactly as given
* (delimiters, flags and all — your pattern, your responsibility). Also
* accepts a {@see Regex} or a builder closure; see {@see matches()} for
* the raw-string spelling that adds delimiters and `D` for you.
*
* @param string|Regex|Closure(Regex): Regex $pattern
*/
public function regex(string|Regex|Closure $pattern, ?string $message = null): static
{
return $this->addRule('regex:' . $pattern, $message);
return $this->addRule('regex:' . (is_string($pattern) ? $pattern : $this->compileRegex($pattern)), $message);
}

/**
* Match against a pattern in whichever spelling reads best — all of
* these produce the same rule for `^\d{3}-[A-Za-z]{2}$`:
*
* ->matches('^\d{3}-[A-Za-z]{2}$') // raw, UNDELIMITED — delimiters + D added
* ->matches('/^\d{3}-[A-Za-z]{2}$/') // raw, DELIMITED — used verbatim
* ->matches(Regex::of('^\d{3}-[A-Za-z]{2}$')) // a pre-built Regex
* ->matches(fn (Regex $r) => $r->digits(3)->literal('-')->letters(2))
*
* The builder is never required — a team that already has a pattern
* just uses it.
*
* @param string|Regex|Closure(Regex): Regex $pattern
*/
public function matches(string|Regex|Closure $pattern, ?string $message = null): static
{
$compiled = is_string($pattern)
? Regex::of($pattern)->compile()
: $this->compileRegex($pattern);

return $this->addRule('regex:' . $compiled, $message);
}

/** @param Regex|Closure(Regex): Regex $pattern */
private function compileRegex(Regex|Closure $pattern): string
{
if ($pattern instanceof Closure) {
$built = $pattern(Regex::build());

return $built->compile();
}

return $pattern->compile();
}

public function notRegex(string $pattern, ?string $message = null): static
Expand Down
Loading