diff --git a/benchmark.php b/benchmark.php
index cd78be8..236fd1e 100644
--- a/benchmark.php
+++ b/benchmark.php
@@ -1,4 +1,6 @@
-createApplication();
+$app = (new Application)->createApplication();
// =========================================================================
// Data generators
@@ -35,13 +37,13 @@
function makeProducts(int $count): array
{
return array_map(fn (int $i): array => [
- 'sku' => "SKU-{$i}",
- 'name' => "Product {$i}",
- 'price' => round(9.99 + ($i % 100) * 0.5, 2),
+ 'sku' => "SKU-{$i}",
+ 'name' => "Product {$i}",
+ 'price' => round(9.99 + ($i % 100) * 0.5, 2),
'quantity' => $i % 1000,
'category' => ['electronics', 'clothing', 'food', 'books', 'toys'][$i % 5],
- 'active' => $i % 2 === 0,
- 'tags' => 'tag-' . ($i % 20),
+ 'active' => $i % 2 === 0,
+ 'tags' => 'tag-' . ($i % 20),
], range(1, $count));
}
@@ -49,11 +51,11 @@ function makeOrders(int $orders, int $lineItemsPerOrder): array
{
return array_map(fn (int $i): array => [
'order_number' => "ORD-{$i}",
- 'status' => ['pending', 'processing', 'shipped'][$i % 3],
- 'line_items' => array_map(fn (int $j): array => [
+ 'status' => ['pending', 'processing', 'shipped'][$i % 3],
+ 'line_items' => array_map(fn (int $j): array => [
'product_id' => ($i * 10 + $j) % 9999 + 1,
- 'quantity' => $j + 1,
- 'price' => round(10.00 + $j * 2.5, 2),
+ 'quantity' => $j + 1,
+ 'price' => round(10.00 + $j * 2.5, 2),
], range(1, $lineItemsPerOrder)),
], range(1, $orders));
}
@@ -61,9 +63,9 @@ function makeOrders(int $orders, int $lineItemsPerOrder): array
function makeEvents(int $count): array
{
return array_map(fn (int $i): array => [
- 'name' => "Event {$i}",
- 'start_date' => '2025-06-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT),
- 'end_date' => '2025-07-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT),
+ 'name' => "Event {$i}",
+ 'start_date' => '2025-06-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT),
+ 'end_date' => '2025-07-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT),
'registration_deadline' => '2025-05-' . str_pad((string) ($i % 28 + 1), 2, '0', STR_PAD_LEFT),
], range(1, $count));
}
@@ -71,9 +73,9 @@ function makeEvents(int $count): array
function makeArticles(int $count): array
{
return array_map(fn (int $i): array => [
- 'title' => "Article {$i}",
- 'slug' => "article-{$i}",
- 'content' => str_repeat('Word ', 120),
+ 'title' => "Article {$i}",
+ 'slug' => "article-{$i}",
+ 'content' => str_repeat('Word ', 120),
'category' => ['tech', 'science', 'art'][$i % 3],
'priority' => $i % 10,
], range(1, $count));
@@ -133,7 +135,8 @@ function benchNativeLaravel(array $rules, array $data): float
$events100 = makeEvents(100);
$articles50 = makeArticles(50);
-$minWordCount = new class implements ValidationRule {
+$minWordCount = new class implements ValidationRule
+{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || str_word_count($value) < 100) {
@@ -142,7 +145,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
};
-$validCategory = new class implements ValidationRule {
+$validCategory = new class implements ValidationRule
+{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! in_array($value, ['tech', 'science', 'art', 'music'], true)) {
@@ -151,7 +155,8 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
}
};
-$validPriority = new class implements ValidationRule {
+$validPriority = new class implements ValidationRule
+{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_int($value) || $value < 0 || $value > 10) {
@@ -165,7 +170,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 1. Product import — 500 items, all fast-checkable ---
$scenarios['Product import — 500 items, simple rules'] = [
'optimizations' => 'Wildcard, fast-check',
- 'native' => [
+ 'native' => [
['products' => 'required|array', 'products.*.sku' => 'required|string|max:50|regex:/^SKU-/', 'products.*.name' => 'required|string|min:2|max:255', 'products.*.price' => 'required|numeric|min:0', 'products.*.quantity' => 'required|numeric|integer|min:0', 'products.*.category' => ['required', 'string', Rule::in(['electronics', 'clothing', 'food', 'books', 'toys'])], 'products.*.active' => 'required|boolean', 'products.*.tags' => 'nullable|string|max:50'],
['products' => $products500],
],
@@ -178,7 +183,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 2. Nested order lines — 1000 orders × 5 line items ---
$scenarios['Nested order lines — 1000 orders × 5 line items'] = [
'optimizations' => 'Wildcard, fast-check (nested)',
- 'native' => [
+ 'native' => [
['orders' => 'required|array', 'orders.*.order_number' => 'required|string|alpha_dash|min:5', 'orders.*.status' => ['required', 'string', Rule::in(['pending', 'processing', 'shipped'])], 'orders.*.line_items' => 'required|array|min:1', 'orders.*.line_items.*.product_id' => 'required|integer|min:1', 'orders.*.line_items.*.quantity' => 'required|integer|min:1', 'orders.*.line_items.*.price' => 'required|numeric|min:0.01'],
['orders' => $orders1000],
],
@@ -191,7 +196,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 3. Event scheduling — field-reference date comparisons (partial fast-check) ---
$scenarios['Event scheduling — 100 items, field-ref dates'] = [
'optimizations' => 'Wildcard, partial fast-check',
- 'native' => [
+ 'native' => [
['events' => 'required|array', 'events.*.name' => 'required|string|min:3|max:255', 'events.*.start_date' => 'required|date|after:2025-01-01', 'events.*.end_date' => 'required|date|after:events.*.start_date', 'events.*.registration_deadline' => 'required|date|before:events.*.start_date'],
['events' => $events100],
],
@@ -204,7 +209,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 4. Article submission — custom Rule objects (wildcard expansion only) ---
$scenarios['Article submission — 50 items, custom Rule objects'] = [
'optimizations' => 'Wildcard only',
- 'native' => [
+ 'native' => [
['articles' => 'required|array', 'articles.*.title' => 'required|string|min:3|max:255', 'articles.*.slug' => 'required|string|alpha_dash|max:255', 'articles.*.content' => ['required', 'string', $minWordCount], 'articles.*.category' => ['required', $validCategory], 'articles.*.priority' => ['required', $validPriority]],
['articles' => $articles50],
],
@@ -217,95 +222,95 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 5. Conditional import — 100 items, ~47 wildcard patterns with exclude_unless ---
$interactionTypes = ['button', 'hotspot', 'scroll_area', 'image', 'chapter', 'menu', 'frame', 'text', 'iframe'];
$interactions100 = array_map(fn (int $i): array => [
- 'type' => $interactionTypes[$i % count($interactionTypes)],
- 'title' => "Interaction {$i}",
- 'start_time' => $i * 10,
- 'end_time' => $i * 10 + 5,
- 'position' => 'bottom',
- 'should_start_collapsed' => false,
+ 'type' => $interactionTypes[$i % count($interactionTypes)],
+ 'title' => "Interaction {$i}",
+ 'start_time' => $i * 10,
+ 'end_time' => $i * 10 + 5,
+ 'position' => 'bottom',
+ 'should_start_collapsed' => false,
'should_collapse_after_menu_item_click' => true,
- 'should_pause_when_shown' => false,
- 'should_not_use_time' => false,
- 'should_use_menu_layout' => false,
- 'text' => '
Sample text
',
- 'text_stroke' => null,
- 'should_fade_volume' => false,
- 'sound_url' => null,
- 'should_enable_sound' => false,
- 'should_fade_in' => true,
- 'should_fade_out' => true,
- 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null,
- 'style' => [
- 'top' => '10%', 'left' => '20%', 'height' => '30%', 'width' => '40%',
+ 'should_pause_when_shown' => false,
+ 'should_not_use_time' => false,
+ 'should_use_menu_layout' => false,
+ 'text' => 'Sample text
',
+ 'text_stroke' => null,
+ 'should_fade_volume' => false,
+ 'sound_url' => null,
+ 'should_enable_sound' => false,
+ 'should_fade_in' => true,
+ 'should_fade_out' => true,
+ 'image_url' => $interactionTypes[$i % count($interactionTypes)] === 'image' ? 'https://example.com/image.png' : null,
+ 'style' => [
+ 'top' => '10%', 'left' => '20%', 'height' => '30%', 'width' => '40%',
'background_color' => '#ff0000', 'border_radius' => 5,
- 'padding_top' => 10, 'padding_bottom' => 10,
- 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'],
+ 'padding_top' => 10, 'padding_bottom' => 10,
+ 'border' => ['width' => 1, 'style' => 'solid', 'color' => '#000000'],
],
- 'action' => ['type' => 'link', 'link' => 'https://example.com', 'time' => 0],
+ 'action' => ['type' => 'link', 'link' => 'https://example.com', 'time' => 0],
'attributes' => [
'show_indicator' => true, 'indicator_color' => '#00ff00', 'blinking_speed' => 'normal',
- 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'],
+ 'options' => ['menu_button_location' => 'top', 'menu_button_name' => 'Menu'],
],
'chapters' => $i % count($interactionTypes) === 4 ? array_map(fn (int $j): array => [
- 'title' => "Chapter {$j}", 'title_short' => null,
+ 'title' => "Chapter {$j}", 'title_short' => null,
'start_time' => $j * 5, 'end_time' => $j * 5 + 4, 'sort_order' => $j,
], range(1, 4)) : [],
], range(1, 100));
$conditionalRules = [
- 'interactions' => 'required|array|min:1',
- 'interactions.*.type' => ['required', 'string', Rule::in($interactionTypes)],
- 'interactions.*.title' => ['nullable', 'string'],
- 'interactions.*.start_time' => ['required', 'numeric', 'min:0'],
- 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'],
- 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'],
- 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'],
- 'interactions.*.should_collapse_after_menu_item_click' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'],
- 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'],
- 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'],
- 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'],
- 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'],
- 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'],
- 'interactions.*.should_fade_volume' => ['boolean'],
- 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'],
- 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'],
- 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'],
- 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'],
- 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'],
- 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
- 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
- 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
- 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
- 'interactions.*.style.background_color' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'string'],
- 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'],
- 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'],
- 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'],
- 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'],
- 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'],
- 'interactions.*.style.border.style' => ['nullable', 'string', Rule::in(['solid', 'dashed', 'dotted', 'none'])],
- 'interactions.*.style.border.color' => ['nullable', 'string'],
- 'interactions.*.action' => ['nullable', 'array'],
- 'interactions.*.action.type' => ['nullable', 'string', Rule::in(['link', 'time', 'video', 'none'])],
- 'interactions.*.action.link' => ['nullable', 'string'],
- 'interactions.*.action.time' => ['nullable', 'numeric'],
- 'interactions.*.attributes' => ['nullable', 'array'],
- 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'],
- 'interactions.*.attributes.indicator_color' => ['nullable', 'string'],
- 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', Rule::in(['slow', 'normal', 'fast', 'none'])],
- 'interactions.*.attributes.options' => ['nullable', 'array'],
+ 'interactions' => 'required|array|min:1',
+ 'interactions.*.type' => ['required', 'string', Rule::in($interactionTypes)],
+ 'interactions.*.title' => ['nullable', 'string'],
+ 'interactions.*.start_time' => ['required', 'numeric', 'min:0'],
+ 'interactions.*.end_time' => ['required', 'numeric', 'gte:interactions.*.start_time'],
+ 'interactions.*.position' => ['bail', ['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'string'],
+ 'interactions.*.should_start_collapsed' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'],
+ 'interactions.*.should_collapse_after_menu_item_click' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'boolean'],
+ 'interactions.*.should_pause_when_shown' => [['exclude_unless', 'interactions.*.type', 'chapter', 'menu'], 'nullable', 'boolean'],
+ 'interactions.*.should_not_use_time' => [['exclude_unless', 'interactions.*.type', 'menu'], 'nullable', 'boolean'],
+ 'interactions.*.should_use_menu_layout' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'boolean'],
+ 'interactions.*.text' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'string'],
+ 'interactions.*.text_stroke' => [['exclude_unless', 'interactions.*.type', 'text'], 'nullable', 'string'],
+ 'interactions.*.should_fade_volume' => ['boolean'],
+ 'interactions.*.sound_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'nullable', 'string'],
+ 'interactions.*.should_enable_sound' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image'], 'boolean'],
+ 'interactions.*.should_fade_in' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'],
+ 'interactions.*.should_fade_out' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text'], 'boolean'],
+ 'interactions.*.image_url' => ['bail', ['exclude_unless', 'interactions.*.type', 'image', 'hotspot'], ['required_if', 'interactions.*.type', 'image'], 'nullable', 'string'],
+ 'interactions.*.style.top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
+ 'interactions.*.style.left' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
+ 'interactions.*.style.height' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
+ 'interactions.*.style.width' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'image', 'text', 'iframe'], 'string'],
+ 'interactions.*.style.background_color' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'string'],
+ 'interactions.*.style.border_radius' => ['bail', ['exclude_unless', 'interactions.*.type', 'button', 'hotspot'], 'nullable', 'numeric', 'integer', 'between:0,360'],
+ 'interactions.*.style.padding_top' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'],
+ 'interactions.*.style.padding_bottom' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'numeric', 'integer'],
+ 'interactions.*.style.border' => [['exclude_unless', 'interactions.*.type', 'button', 'hotspot', 'text'], 'nullable', 'array'],
+ 'interactions.*.style.border.width' => ['nullable', 'numeric', 'integer', 'min:0'],
+ 'interactions.*.style.border.style' => ['nullable', 'string', Rule::in(['solid', 'dashed', 'dotted', 'none'])],
+ 'interactions.*.style.border.color' => ['nullable', 'string'],
+ 'interactions.*.action' => ['nullable', 'array'],
+ 'interactions.*.action.type' => ['nullable', 'string', Rule::in(['link', 'time', 'video', 'none'])],
+ 'interactions.*.action.link' => ['nullable', 'string'],
+ 'interactions.*.action.time' => ['nullable', 'numeric'],
+ 'interactions.*.attributes' => ['nullable', 'array'],
+ 'interactions.*.attributes.show_indicator' => ['nullable', 'boolean'],
+ 'interactions.*.attributes.indicator_color' => ['nullable', 'string'],
+ 'interactions.*.attributes.blinking_speed' => ['nullable', 'string', Rule::in(['slow', 'normal', 'fast', 'none'])],
+ 'interactions.*.attributes.options' => ['nullable', 'array'],
'interactions.*.attributes.options.menu_button_location' => ['nullable', 'string'],
- 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'],
- 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'],
- 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'],
- 'interactions.*.chapters.*.title_short' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'string'],
- 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'],
- 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'],
- 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'],
+ 'interactions.*.attributes.options.menu_button_name' => ['nullable', 'string'],
+ 'interactions.*.chapters' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'array', 'min:1', 'max:16'],
+ 'interactions.*.chapters.*.title' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'string'],
+ 'interactions.*.chapters.*.title_short' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'nullable', 'string'],
+ 'interactions.*.chapters.*.start_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric', 'min:0'],
+ 'interactions.*.chapters.*.end_time' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'numeric'],
+ 'interactions.*.chapters.*.sort_order' => [['exclude_unless', 'interactions.*.type', 'chapter'], 'required', 'integer', 'min:0'],
];
$scenarios['Conditional import — 100 items, 47 conditional fields'] = [
'optimizations' => 'Wildcard, pre-evaluation',
- 'native' => [
+ 'native' => [
$conditionalRules,
['interactions' => $interactions100],
],
@@ -318,7 +323,7 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
// --- 6. Login form — no wildcards, minimal rules ---
$scenarios['Login form — 3 fields, no wildcards'] = [
'optimizations' => 'Fast-check (flat)',
- 'native' => [
+ 'native' => [
['email' => 'required|string|email|max:255', 'password' => 'required|string|min:8', 'remember' => 'nullable|boolean'],
['email' => 'user@example.com', 'password' => 'secure-password-123', 'remember' => true],
],
@@ -345,9 +350,9 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$results[$label] = [
'optimizations' => $config['optimizations'],
- 'native_ms' => round($native, 1),
- 'optimized_ms' => round($ruleSet, 1),
- 'speedup' => round($speedup, 1),
+ 'native_ms' => round($native, 1),
+ 'optimized_ms' => round($ruleSet, 1),
+ 'speedup' => round($speedup, 1),
];
}
diff --git a/composer.json b/composer.json
index 8b04b62..60a5926 100644
--- a/composer.json
+++ b/composer.json
@@ -130,7 +130,7 @@
"phpstan-simplified": "vendor/bin/phpstan analyse --memory-limit=2G --error-format symplify",
"phpstan-clear-cache": "vendor/bin/phpstan clear-result-cache",
"tag-currency": "bash scripts/verify-tag-currency.sh",
- "format": "vendor/bin/pint",
+ "format": "@pint-fix",
"rector": "vendor/bin/rector process",
"test": "vendor/bin/pest",
"test-coverage": "vendor/bin/pest --coverage",
@@ -139,6 +139,13 @@
"@rector",
"@format",
"@phpstan-simplified"
+ ],
+ "pint": "vendor/bin/pint --test --config vendor/laranail/package-tools/pint.json",
+ "pint-fix": "vendor/bin/pint --parallel --config vendor/laranail/package-tools/pint.json",
+ "lint": [
+ "@pint",
+ "@phpstan",
+ "@rector"
]
},
"extra": {
diff --git a/config/laranail-validation.php b/config/laranail-validation.php
index ab53497..5d92d6a 100644
--- a/config/laranail-validation.php
+++ b/config/laranail-validation.php
@@ -1,4 +1,6 @@
- [
'enabled' => env('LARANAIL_VALIDATION_ALIASES', false),
- 'prefix' => 'laranail_',
+ 'prefix' => 'laranail_',
],
/*
diff --git a/pint.json b/pint.json
deleted file mode 100644
index e2409f2..0000000
--- a/pint.json
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "preset": "laravel",
- "cache-file": ".cache/pint/pint.json",
- "rules": {
- "backtick_to_shell_exec": true,
- "blank_line_after_opening_tag": false,
- "class_attributes_separation": {
- "elements": {
- "const": "one",
- "method": "one"
- }
- },
- "class_definition": {
- "inline_constructor_arguments": false,
- "multi_line_extends_each_single_line": false,
- "single_item_single_line": false,
- "single_line": false,
- "space_before_parenthesis": true
- },
- "concat_space": {
- "spacing": "one"
- },
- "constant_case": false,
- "curly_braces_position": {
- "control_structures_opening_brace": "same_line",
- "functions_opening_brace": "next_line_unless_newline_at_signature_end",
- "anonymous_functions_opening_brace": "same_line",
- "classes_opening_brace": "next_line_unless_newline_at_signature_end",
- "anonymous_classes_opening_brace": "same_line",
- "allow_single_line_empty_anonymous_classes": false,
- "allow_single_line_anonymous_functions": false
- },
- "declare_strict_types": true,
- "function_typehint_space": false,
- "increment_style": false,
- "linebreak_after_opening_tag": false,
- "new_with_parentheses": {
- "anonymous_class": false,
- "named_class": true
- },
- "nullable_type_declaration": false,
- "nullable_type_declaration_for_default_null_value": false,
- "phpdoc_separation": false,
- "phpdoc_align": false,
- "single_class_element_per_statement": {
- "elements": {}
- },
- "single_space_around_construct": false,
- "type_declaration_spaces": false,
- "unary_operator_spaces": false,
- "whitespace_after_comma_in_array": false
- },
- "exclude": [
- ".cache",
- ".github",
- "vendor"
- ]
-}
diff --git a/rector-migrate-1.0.php b/rector-migrate-1.0.php
index 16edec2..6703c8e 100644
--- a/rector-migrate-1.0.php
+++ b/rector-migrate-1.0.php
@@ -3,9 +3,9 @@
declare(strict_types=1);
use Rector\Config\RectorConfig;
-use Rector\Renaming\Rector\MethodCall\RenameMethodRector;
use Rector\Renaming\ValueObject\MethodCallRename;
use Simtabi\Laranail\Validation\Builder\Nodes\ArrayRule;
+use Rector\Renaming\Rector\MethodCall\RenameMethodRector;
/**
* The 0.x → 1.0 migration set (UPGRADING.md, v1.0.0). Run it against an
diff --git a/rector.php b/rector.php
index 1833846..1fb4731 100644
--- a/rector.php
+++ b/rector.php
@@ -1,20 +1,22 @@
-withCache(
diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php
index 94c5218..0fb18f6 100644
--- a/resources/lang/en/validation.php
+++ b/resources/lang/en/validation.php
@@ -1,4 +1,6 @@
- 'The :attribute must be a valid BIC/SWIFT code.',
+ 'bic' => 'The :attribute must be a valid BIC/SWIFT code.',
'bsb_number' => 'The :attribute must be a valid BSB number.',
- 'iban' => 'The :attribute must be a valid IBAN.',
- 'isin' => 'The :attribute must be a valid ISIN.',
- 'luhn' => 'The :attribute must pass the Luhn checksum.',
+ 'iban' => 'The :attribute must be a valid IBAN.',
+ 'isin' => 'The :attribute must be a valid ISIN.',
+ 'luhn' => 'The :attribute must pass the Luhn checksum.',
// Codes
- 'asin' => 'The :attribute must be a valid ASIN.',
- 'ean' => 'The :attribute must be a valid EAN barcode.',
- 'gtin' => 'The :attribute must be a valid GTIN.',
- 'isbn' => 'The :attribute must be a valid ISBN.',
- 'ismn' => 'The :attribute must be a valid ISMN.',
+ 'asin' => 'The :attribute must be a valid ASIN.',
+ 'ean' => 'The :attribute must be a valid EAN barcode.',
+ 'gtin' => 'The :attribute must be a valid GTIN.',
+ 'isbn' => 'The :attribute must be a valid ISBN.',
+ 'ismn' => 'The :attribute must be a valid ISMN.',
'upc_e' => 'The :attribute must be a valid UPC-E barcode.',
- 'issn' => 'The :attribute must be a valid ISSN.',
+ 'issn' => 'The :attribute must be a valid ISSN.',
// Identifiers
'hash_digest' => 'The :attribute must be a :length-character hex digest.',
- 'imei' => 'The :attribute must be a valid IMEI.',
- 'jwt' => 'The :attribute must be a valid JSON Web Token.',
- 'semver' => 'The :attribute must be a valid semantic version.',
- 'vin' => 'The :attribute must be a valid vehicle identification number.',
+ 'imei' => 'The :attribute must be a valid IMEI.',
+ 'jwt' => 'The :attribute must be a valid JSON Web Token.',
+ 'semver' => 'The :attribute must be a valid semantic version.',
+ 'vin' => 'The :attribute must be a valid vehicle identification number.',
// Telecom
//
@@ -43,21 +45,21 @@
// nothing they can act on; "must be a mobile number" and "must be a number from KE" each name
// the thing to change. None of the three upstream Filament phone packages ships any message at
// all — each tells you to add one to your own validation.php.
- 'phone' => 'The :attribute must be a valid phone number.',
- 'phone_possible' => 'The :attribute is not a possible phone number.',
- 'phone_country' => 'The :attribute must be a phone number from :country.',
- 'phone_type' => 'The :attribute must be a :type number.',
- 'phone_extension' => 'The :attribute must not include an extension.',
+ 'phone' => 'The :attribute must be a valid phone number.',
+ 'phone_possible' => 'The :attribute is not a possible phone number.',
+ 'phone_country' => 'The :attribute must be a phone number from :country.',
+ 'phone_type' => 'The :attribute must be a :type number.',
+ 'phone_extension' => 'The :attribute must not include an extension.',
'phone_short_code' => 'The :attribute must be a full phone number, not a short code.',
- 'phone_emergency' => 'The :attribute must not be an emergency number.',
- 'phone_unique' => 'The :attribute has already been taken.',
+ 'phone_emergency' => 'The :attribute must not be an emergency number.',
+ 'phone_unique' => 'The :attribute has already been taken.',
// Net
- 'cidr' => 'The :attribute must be a valid CIDR network.',
+ 'cidr' => 'The :attribute must be a valid CIDR network.',
'domain_name' => 'The :attribute must be a valid domain name.',
- 'private_ip' => 'The :attribute must be a private or reserved IP address.',
- 'public_ip' => 'The :attribute must be a publicly routable IP address.',
- 'subdomain' => 'The :attribute must be a valid subdomain.',
+ 'private_ip' => 'The :attribute must be a private or reserved IP address.',
+ 'public_ip' => 'The :attribute must be a publicly routable IP address.',
+ 'subdomain' => 'The :attribute must be a valid subdomain.',
//
// Several keys rather than one, because "that is not a URL" and "that
@@ -65,48 +67,48 @@
// single message for both makes a working link look broken for no
// stated reason.
'url' => [
- 'credentials' => 'The :attribute must not contain a username or password.',
- 'fragment' => 'The :attribute must not contain a fragment.',
- 'host' => 'The :attribute must have a valid host.',
- 'host_is' => 'The :attribute must be at one of: :hosts.',
- 'host_is_not' => 'The :attribute must not be at any of: :hosts.',
- 'malformed' => 'The :attribute must be a valid URL.',
- 'port' => 'The :attribute must use one of these ports: :ports.',
+ 'credentials' => 'The :attribute must not contain a username or password.',
+ 'fragment' => 'The :attribute must not contain a fragment.',
+ 'host' => 'The :attribute must have a valid host.',
+ 'host_is' => 'The :attribute must be at one of: :hosts.',
+ 'host_is_not' => 'The :attribute must not be at any of: :hosts.',
+ 'malformed' => 'The :attribute must be a valid URL.',
+ 'port' => 'The :attribute must use one of these ports: :ports.',
'private_host' => 'The :attribute must point at a public address.',
- 'query' => 'The :attribute must not contain a query string.',
- 'scheme' => 'The :attribute must use one of these schemes: :schemes.',
+ 'query' => 'The :attribute must not contain a query string.',
+ 'scheme' => 'The :attribute must use one of these schemes: :schemes.',
],
'mac_address' => [
'broadcast' => 'The :attribute must not be the broadcast address.',
- 'format' => 'The :attribute must be written in one of these notations: :formats.',
- 'length' => 'The :attribute must be :bytes bytes.',
- 'local' => 'The :attribute must be a manufacturer-assigned address, not a randomised one.',
+ 'format' => 'The :attribute must be written in one of these notations: :formats.',
+ 'length' => 'The :attribute must be :bytes bytes.',
+ 'local' => 'The :attribute must be a manufacturer-assigned address, not a randomised one.',
'malformed' => 'The :attribute must be a valid MAC address.',
'multicast' => 'The :attribute must be a unicast address.',
- 'null' => 'The :attribute must not be the null address.',
- 'oui' => 'The :attribute must begin with one of: :ouis.',
+ 'null' => 'The :attribute must not be the null address.',
+ 'oui' => 'The :attribute must begin with one of: :ouis.',
],
'in_cidr_range' => 'The :attribute must be within one of these networks: :networks.',
'slug' => 'The :attribute must be a URL slug: lowercase letters, digits and single hyphens, with no hyphen at the start or end.',
- 'username' => 'The :attribute may contain letters, digits, and single dots, hyphens or underscores between them — not at the start, at the end, or doubled.',
+ 'username' => 'The :attribute may contain letters, digits, and single dots, hyphens or underscores between them — not at the start, at the end, or doubled.',
'username_reserved' => 'The :attribute is reserved. Please choose another.',
// Three keys rather than one. A field whose characters were fine and whose count was not gets
// sent looking for a bad character that is not there.
- 'person_name' => 'The :attribute must be a name: letters, marks, spaces, apostrophes and hyphens.',
- 'person_name_min' => 'The :attribute must contain at least :min names.',
- 'person_name_max' => 'The :attribute must not contain more than :max names.',
+ 'person_name' => 'The :attribute must be a name: letters, marks, spaces, apostrophes and hyphens.',
+ 'person_name_min' => 'The :attribute must contain at least :min names.',
+ 'person_name_max' => 'The :attribute must not contain more than :max names.',
'person_name_required' => 'Please provide at least one of :values.',
'contains_html' => 'The :attribute must contain HTML markup.',
- 'html_clean' => 'The :attribute must not contain HTML tags.',
- 'max_words' => 'The :attribute must not contain more than :max words.',
- 'min_words' => 'The :attribute must contain at least :min words.',
- 'salutation' => 'The :attribute must be a recognised salutation.',
+ 'html_clean' => 'The :attribute must not contain HTML tags.',
+ 'max_words' => 'The :attribute must not contain more than :max words.',
+ 'min_words' => 'The :attribute must contain at least :min words.',
+ 'salutation' => 'The :attribute must be a recognised salutation.',
'without_spaces' => 'The :attribute must not contain any spaces, including non-breaking and zero-width ones.',
@@ -115,63 +117,63 @@
// Networking probes
'has_gravatar' => 'The :attribute must have a Gravatar.',
- 'image_url' => 'The :attribute must be a URL serving an image.',
+ 'image_url' => 'The :attribute must be a URL serving an image.',
// Payment
- 'card_cvc' => 'The :attribute must be a valid security code.',
- 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.',
- 'card_number' => 'The :attribute is not a recognised card number.',
- 'card_number_brand' => 'The :attribute must be one of these card brands: :brands.',
+ 'card_cvc' => 'The :attribute must be a valid security code.',
+ 'card_expiry' => 'The :attribute must be a valid expiry date that has not passed.',
+ 'card_number' => 'The :attribute is not a recognised card number.',
+ 'card_number_brand' => 'The :attribute must be one of these card brands: :brands.',
'card_number_checksum' => 'The :attribute fails the card number check digit.',
- 'card_number_length' => 'The :attribute must be :lengths digits for :brand.',
+ 'card_number_length' => 'The :attribute must be :lengths digits for :brand.',
// Chrono
- 'date_interval' => 'The :attribute must be an ISO 8601 duration.',
+ 'date_interval' => 'The :attribute must be an ISO 8601 duration.',
'date_interval_positive' => 'The :attribute must be a non-zero ISO 8601 duration.',
- 'max_date_difference' => 'The :attribute must be within :hours hours of the reference date.',
- 'minimum_age' => 'The :attribute must be a date of birth at least :years years ago.',
- 'minute_in' => 'The :attribute must be a time whose minutes are one of: :minutes.',
- 'rfc3339' => 'The :attribute must be an RFC 3339 timestamp.',
- 'time_of_day' => 'The :attribute must be a valid time of day.',
- 'timezone_abbreviation' => 'The :attribute must be a timezone abbreviation.',
- 'unix_timestamp' => 'The :attribute must be a Unix timestamp.',
+ 'max_date_difference' => 'The :attribute must be within :hours hours of the reference date.',
+ 'minimum_age' => 'The :attribute must be a date of birth at least :years years ago.',
+ 'minute_in' => 'The :attribute must be a time whose minutes are one of: :minutes.',
+ 'rfc3339' => 'The :attribute must be an RFC 3339 timestamp.',
+ 'time_of_day' => 'The :attribute must be a valid time of day.',
+ 'timezone_abbreviation' => 'The :attribute must be a timezone abbreviation.',
+ 'unix_timestamp' => 'The :attribute must be a Unix timestamp.',
// Encoding
- 'base64' => 'The :attribute must be a base64-encoded value.',
- 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.',
+ 'base64' => 'The :attribute must be a base64-encoded value.',
+ 'base64_image' => 'The :attribute must be a base64-encoded image of an accepted type.',
'base64_image_size' => 'The :attribute must be an image no larger than :max.',
- 'data_uri' => 'The :attribute must be a valid data URI.',
+ 'data_uri' => 'The :attribute must be a valid data URI.',
// I18n
- 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.',
- 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.',
+ 'country_code' => 'The :attribute must be a valid ISO 3166-1 country code.',
+ 'currency_code' => 'The :attribute must be a valid ISO 4217 currency code.',
'currency_code_numeric' => 'The :attribute must be a valid ISO 4217 numeric currency code.',
- 'currency_symbol' => 'The :attribute must be a recognised currency symbol.',
- 'language_code' => 'The :attribute must be a valid ISO 639-1 language code.',
+ 'currency_symbol' => 'The :attribute must be a recognised currency symbol.',
+ 'language_code' => 'The :attribute must be a valid ISO 639-1 language code.',
// Geo
'ca_province' => 'The :attribute must be a Canadian province or territory.',
- 'lat_lng' => 'The :attribute must be a latitude,longitude pair.',
- 'latitude' => 'The :attribute must be a latitude between -90 and 90.',
- 'longitude' => 'The :attribute must be a longitude between -180 and 180.',
- 'us_state' => 'The :attribute must be a US state.',
+ 'lat_lng' => 'The :attribute must be a latitude,longitude pair.',
+ 'latitude' => 'The :attribute must be a latitude between -90 and 90.',
+ 'longitude' => 'The :attribute must be a longitude between -180 and 180.',
+ 'us_state' => 'The :attribute must be a US state.',
// Email
'email' => [
- 'disposable' => 'The :attribute must not be a disposable email address.',
- 'domain_is' => 'The :attribute must be at one of: :domains.',
+ 'disposable' => 'The :attribute must not be a disposable email address.',
+ 'domain_is' => 'The :attribute must be at one of: :domains.',
'domain_is_not' => 'The :attribute must not be at any of: :domains.',
- 'malformed' => 'The :attribute must be a valid email address.',
- 'role' => 'The :attribute must belong to a person, not a shared mailbox.',
- 'subaddress' => 'The :attribute must not contain a plus tag.',
+ 'malformed' => 'The :attribute must be a valid email address.',
+ 'role' => 'The :attribute must belong to a person, not a shared mailbox.',
+ 'subaddress' => 'The :attribute must not contain a plus tag.',
'undeliverable' => 'The :attribute is at a domain that cannot receive mail.',
],
// Database
- 'authorized' => 'The selected :attribute is invalid.',
+ 'authorized' => 'The selected :attribute is invalid.',
'compare_to_column' => 'The :attribute is outside the allowed bound.',
- 'models_exist' => [
- 'array' => 'The :attribute must be a list of identifiers.',
+ 'models_exist' => [
+ 'array' => 'The :attribute must be a list of identifiers.',
'missing' => 'The :attribute contains values that do not exist: :values.',
],
@@ -181,15 +183,15 @@
// the field holds a dozen values. Each key names what to change.
'delimited' => [
'distinct' => 'The :attribute must not repeat an entry.',
- 'empty' => 'Entry :position of :attribute is empty — check for a stray separator.',
- 'invalid' => 'The :attribute must be a delimited list.',
- 'item' => 'Entry :position of :attribute is not valid.',
- 'max' => 'The :attribute must not have more than :max entries.',
- 'min' => 'The :attribute must have at least :min entries.',
+ 'empty' => 'Entry :position of :attribute is empty — check for a stray separator.',
+ 'invalid' => 'The :attribute must be a delimited list.',
+ 'item' => 'Entry :position of :attribute is not valid.',
+ 'max' => 'The :attribute must not have more than :max entries.',
+ 'min' => 'The :attribute must have at least :min entries.',
],
// Crypto
- 'bitcoin_address' => 'The :attribute must be a valid Bitcoin address.',
+ 'bitcoin_address' => 'The :attribute must be a valid Bitcoin address.',
'ethereum_address' => 'The :attribute must be a valid Ethereum address.',
// Postal
@@ -198,7 +200,7 @@
// Numbers
'parity' => [
'even' => 'The :attribute must be an even number.',
- 'odd' => 'The :attribute must be an odd number.',
+ 'odd' => 'The :attribute must be an odd number.',
],
'monetary_amount' => 'The :attribute must be an amount with at most :decimals decimal places.',
@@ -206,17 +208,17 @@
'css_color' => 'The :attribute must be a colour in one of these notations: :notations.',
// Anti-spam
- 'honeypot' => 'The :attribute could not be submitted.',
+ 'honeypot' => 'The :attribute could not be submitted.',
'submission_timing' => [
'too_fast' => 'The form was submitted too quickly. Please try again.',
- 'expired' => 'The form has expired. Please reload and try again.',
+ 'expired' => 'The form has expired. Please reload and try again.',
],
// Vendor identifiers
'vendor_identifier' => 'The :attribute must be a valid :vendor identifier.',
// Fiscal
- 'vat_number' => 'The :attribute must be a valid VAT number.',
+ 'vat_number' => 'The :attribute must be a valid VAT number.',
'national_identifier' => 'The :attribute must be a valid :country national identification number.',
// Profanity
@@ -224,8 +226,8 @@
// Markup
'xml' => [
- 'malformed' => 'The :attribute must be well-formed XML.',
- 'schema' => 'The :attribute does not match the required schema.',
+ 'malformed' => 'The :attribute must be well-formed XML.',
+ 'schema' => 'The :attribute does not match the required schema.',
'schema_missing' => 'The :attribute could not be checked: the schema is unavailable.',
],
@@ -234,11 +236,11 @@
| parameter rather than five classes differing by a pattern and a message.
*/
'case_style' => [
- 'camel' => 'The :attribute must be camelCase.',
- 'kebab' => 'The :attribute must be kebab-case.',
+ 'camel' => 'The :attribute must be camelCase.',
+ 'kebab' => 'The :attribute must be kebab-case.',
'pascal' => 'The :attribute must be PascalCase.',
- 'snake' => 'The :attribute must be snake_case.',
- 'title' => 'The :attribute must be Title Case.',
+ 'snake' => 'The :attribute must be snake_case.',
+ 'title' => 'The :attribute must be Title Case.',
],
];
diff --git a/src/Actions/CachedDnsResolver.php b/src/Actions/CachedDnsResolver.php
index de08893..a150859 100644
--- a/src/Actions/CachedDnsResolver.php
+++ b/src/Actions/CachedDnsResolver.php
@@ -1,11 +1,13 @@
- $values Deduplicated, non-null values to check
- * @return array Values that exist in the DB
+ * @param array $values Deduplicated, non-null values to check
+ *
+ * @return array Values that exist in the DB
*/
public static function fetchExisting(array $values, Exists $rule): array
{
@@ -99,8 +102,9 @@ public static function fetchExisting(array $values, Exists $rule): array
/**
* Batch-query which values are already taken for a Unique rule.
*
- * @param array $values Deduplicated, non-null values to check
- * @return array Values that already exist (taken)
+ * @param array $values Deduplicated, non-null values to check
+ *
+ * @return array Values that already exist (taken)
*/
public static function fetchTaken(array $values, Unique $rule): array
{
@@ -164,8 +168,9 @@ public static function batchableSignature(Exists|Unique $rule, string $table, st
/**
* Find batchable Exists/Unique rules in a set of compiled rules.
*
- * @param array $rules
- * @return array Field name → rule object
+ * @param array $rules
+ *
+ * @return array Field name → rule object
*/
public static function findBatchableRules(array $rules): array
{
@@ -190,9 +195,10 @@ public static function findBatchableRules(array $rules): array
/**
* Collect values from items for batchable rules and group by table:column.
*
- * @param array $batchableFields
- * @param array $items
- * @param array $fieldRules Per-field rule array used to derive type predicates.
+ * @param array $batchableFields
+ * @param array $items
+ * @param array $fieldRules Per-field rule array used to derive type predicates.
+ *
* @return array}>
*/
public static function collectValues(array $batchableFields, array $items, bool $isScalar, array $fieldRules = []): array
@@ -261,8 +267,9 @@ public static function collectValues(array $batchableFields, array $items, bool
* Unlike collectValues() which iterates items, this iterates expanded
* rules like 'items.0.email', 'items.1.email' and uses data_get().
*
- * @param array $preparedRules Expanded rules with concrete paths
- * @param array $data
+ * @param array $preparedRules Expanded rules with concrete paths
+ * @param array $data
+ *
* @return array}>
*/
public static function collectExpandedValues(array $preparedRules, array $data): array
@@ -319,18 +326,6 @@ public static function collectExpandedValues(array $preparedRules, array $data):
return $groups;
}
- /**
- * Build the grouping key for a batched rule. Disambiguates exists / unique
- * against the same (table, column) so a validator carrying both rules
- * does not conflate them.
- */
- private static function groupKey(string $table, string $column, Exists|Unique $rule): string
- {
- return $table . ':' . $column . ':'
- . ($rule instanceof Unique ? 'unique' : 'exists') . ':'
- . BatchRuleShape::of(self::extractMeta($rule));
- }
-
/**
* Filter values against per-item string-form type rules (integer, numeric,
* uuid, ulid, string). Values that would never pass per-item validation
@@ -341,8 +336,9 @@ private static function groupKey(string $table, string $column, Exists|Unique $r
* Object-form rules fall through unchanged; if no known type rule is
* present, values are returned as-is (matches previous behaviour).
*
- * @param array $values
- * @param array|string $itemRules Either a pipe-delimited string or an array of rules.
+ * @param array $values
+ * @param array|string $itemRules Either a pipe-delimited string or an array of rules.
+ *
* @return array
*/
public static function filterValuesByType(array $values, array|string $itemRules): array
@@ -356,9 +352,9 @@ public static function filterValuesByType(array $values, array|string $itemRules
* Groups arrive already filtered (Phase 1) and deduplicated (collectors
* own the dedup). Canonical order is filter → dedup → cap check → query.
*
- * @param array}> $groups
- * @param array $poisonedTableColumns Extra table:columns that must not
- * register a lookup ({@see findPoisonedTableColumns})
+ * @param array}> $groups
+ * @param array $poisonedTableColumns Extra table:columns that must not
+ * register a lookup ({@see findPoisonedTableColumns})
*
* @throws BatchLimitExceededException When any group's value count exceeds `$maxValuesPerGroup`.
*/
@@ -388,35 +384,6 @@ public static function buildVerifier(array $groups, array $poisonedTableColumns
return $verifier->hasLookups() ? $verifier : null;
}
- /**
- * Throw `BatchLimitExceededException` if any group exceeds the hard cap.
- * Called after filter/dedup so the cap sees the same normalised set the
- * query will.
- *
- * @param array}> $groups
- */
- private static function assertWithinCap(array $groups): void
- {
- foreach ($groups as $key => $group) {
- $count = count($group['values']);
-
- if ($count <= self::$maxValuesPerGroup) {
- continue;
- }
-
- [$table, $column, $ruleType] = self::parseGroupKey($key);
-
- throw new BatchLimitExceededException(
- table: $table,
- column: $column,
- ruleType: $ruleType,
- reason: BatchLimitExceededException::REASON_HARD_CAP,
- valueCount: $count,
- limit: self::$maxValuesPerGroup,
- );
- }
- }
-
/**
* Batch-query and register lookups on a verifier for a set of grouped rules.
*
@@ -432,13 +399,14 @@ private static function assertWithinCap(array $groups): void
* handles each rule correctly via per-item queries. Rare case, small
* perf hit — exists and unique on the same (table, column) is unusual.
*
- * @param array}> $groups Keyed by "table:column:ruleType"
- * @param array $poisonedTableColumns Table:columns claimed by rules the
- * collectors never saw (a field's second DB rule,
- * non-batchable or string-form rules) — treated
- * exactly like a detected group conflict.
- * @return bool False if any group was skipped, so the caller knows a
- * fallback verifier is required to answer it.
+ * @param array}> $groups Keyed by "table:column:ruleType"
+ * @param array $poisonedTableColumns Table:columns claimed by rules the
+ * collectors never saw (a field's second DB rule,
+ * non-batchable or string-form rules) — treated
+ * exactly like a detected group conflict.
+ *
+ * @return bool False if any group was skipped, so the caller knows a
+ * fallback verifier is required to answer it.
*/
public static function registerLookups(PrecomputedPresenceVerifier $verifier, array $groups, array $poisonedTableColumns = []): bool
{
@@ -476,12 +444,72 @@ public static function registerLookups(PrecomputedPresenceVerifier $verifier, ar
return $complete;
}
+ /**
+ * Deduplicate and cast values to strings for batch queries.
+ *
+ * @param array $values
+ *
+ * @return list
+ */
+ public static function uniqueStringValues(array $values): array
+ {
+ return array_values(array_unique(
+ array_map(
+ static fn (mixed $v): string => is_scalar($v) || $v instanceof Stringable ? (string) $v : '',
+ $values,
+ ),
+ SORT_STRING,
+ ));
+ }
+
+ /**
+ * Build the grouping key for a batched rule. Disambiguates exists / unique
+ * against the same (table, column) so a validator carrying both rules
+ * does not conflate them.
+ */
+ private static function groupKey(string $table, string $column, Exists|Unique $rule): string
+ {
+ return $table . ':' . $column . ':'
+ . ($rule instanceof Unique ? 'unique' : 'exists') . ':'
+ . BatchRuleShape::of(self::extractMeta($rule));
+ }
+
+ /**
+ * Throw `BatchLimitExceededException` if any group exceeds the hard cap.
+ * Called after filter/dedup so the cap sees the same normalised set the
+ * query will.
+ *
+ * @param array}> $groups
+ */
+ private static function assertWithinCap(array $groups): void
+ {
+ foreach ($groups as $key => $group) {
+ $count = count($group['values']);
+
+ if ($count <= self::$maxValuesPerGroup) {
+ continue;
+ }
+
+ [$table, $column, $ruleType] = self::parseGroupKey($key);
+
+ throw new BatchLimitExceededException(
+ table: $table,
+ column: $column,
+ ruleType: $ruleType,
+ reason: BatchLimitExceededException::REASON_HARD_CAP,
+ valueCount: $count,
+ limit: self::$maxValuesPerGroup,
+ );
+ }
+ }
+
/**
* Return the set of "table:column" strings that appear under more than
* one rule type in the grouped rules (i.e. both an exists and a unique
* group exist for the same physical column).
*
- * @param array}> $groups
+ * @param array}> $groups
+ *
* @return array
*/
private static function findConflictingTableColumns(array $groups): array
@@ -565,34 +593,17 @@ private static function extractMeta(Exists|Unique $rule): ?array
return [
'connection' => $connection,
- 'table' => $table,
- 'column' => $column === 'NULL' ? '' : $column,
- 'wheres' => $typedWheres,
- 'ignore' => $ignore,
- 'idColumn' => $idColumn,
+ 'table' => $table,
+ 'column' => $column === 'NULL' ? '' : $column,
+ 'wheres' => $typedWheres,
+ 'ignore' => $ignore,
+ 'idColumn' => $idColumn,
];
} catch (ReflectionException) {
return null;
}
}
- /**
- * Deduplicate and cast values to strings for batch queries.
- *
- * @param array $values
- * @return list
- */
- public static function uniqueStringValues(array $values): array
- {
- return array_values(array_unique(
- array_map(
- static fn (mixed $v): string => is_scalar($v) || $v instanceof Stringable ? (string) $v : '',
- $values,
- ),
- SORT_STRING,
- ));
- }
-
private static function readProperty(object $object, string $property): mixed
{
return new ReflectionProperty($object, $property)->getValue($object);
diff --git a/src/Builder/Concerns/HasEmbeddedRules.php b/src/Builder/Concerns/HasEmbeddedRules.php
index 218fa1e..2485cb7 100644
--- a/src/Builder/Concerns/HasEmbeddedRules.php
+++ b/src/Builder/Concerns/HasEmbeddedRules.php
@@ -1,11 +1,13 @@
- */
private array $customMessages = [];
- /**
- * Seed $lastConstraint for rule classes whose defining constraint
- * is property-initialised (e.g. `protected array $constraints = ['string']`)
- * rather than added via addRule(). Called from factory constructors so
- * `FluentRule::string()->message('...')` binds to 'string' without the
- * caller knowing the initialisation internals.
- */
- protected function seedLastConstraint(string $name): void
- {
- $this->lastConstraint = $name;
- }
-
/**
* Set the human-readable label for this field.
* Used as the :attribute replacement in error messages.
@@ -110,68 +100,6 @@ public function getCustomMessages(): array
return $this->customMessages;
}
- /**
- * Strings are appended to $constraints. Objects are appended to $rules.
- *
- * When $message is non-null, writes it to customMessages under the
- * resolved $lastConstraint key. Throws LogicException if $message is
- * set but no rule was added (e.g. an early-return branch in a caller).
- *
- * @param array|string|object $rules
- */
- protected function addRule(array|string|object $rules, ?string $message = null): static
- {
- $this->compiledCache = null;
-
- if (is_object($rules)) {
- $this->rules[] = $rules;
-
- $this->lastConstraint = match (true) {
- $rules instanceof RequiredIf => 'required',
- $rules instanceof RequiredUnless => 'required',
- $rules instanceof ProhibitedIf => 'prohibited',
- $rules instanceof ProhibitedUnless => 'prohibited',
- $rules instanceof ExcludeIf => 'exclude',
- $rules instanceof ExcludeUnless => 'exclude',
- $rules instanceof In => 'in',
- $rules instanceof NotIn => 'not_in',
- $rules instanceof Unique => 'unique',
- $rules instanceof Exists => 'exists',
- $rules instanceof Contains => 'contains',
- $rules instanceof DoesntContain => 'doesnt_contain',
- default => lcfirst(class_basename($rules)),
- };
- } else {
- $this->constraints = array_merge($this->constraints, Arr::wrap($rules));
- // Track last constraint name: 'min:2' → 'min', 'required' → 'required'
- $last = is_array($rules) ? end($rules) : $rules;
- $this->lastConstraint = is_string($last) ? explode(':', $last, 2)[0] : null;
- }
-
- if ($message !== null) {
- if ($this->lastConstraint === null) {
- throw new LogicException('message parameter has no rule to bind to');
- }
-
- $this->customMessages[$this->lastConstraint] = $message;
- }
-
- return $this;
- }
-
- /** @param array $values */
- private static function serializeValues(array $values): string
- {
- return implode(',', array_map(
- static fn (string|int|bool|BackedEnum $v): string|int => match (true) {
- $v instanceof BackedEnum => $v->value,
- is_bool($v) => $v ? '1' : '0',
- default => $v,
- },
- $values,
- ));
- }
-
public function bail(): static
{
return $this->addRule('bail');
@@ -388,39 +316,6 @@ public function prohibitedIfDeclined(string $field, ?string $message = null): st
return $this->addRule('prohibited_if_declined:' . $field, $message);
}
- /**
- * Reorder constraints so that presence modifiers (required, nullable, bail, etc.)
- * appear before type and size constraints ("required" must come
- * before "must be a string").
- *
- * @param list $constraints
- * @return list
- */
- protected function reorderConstraints(array $constraints): array
- {
- $presence = [];
- $rest = [];
-
- foreach ($constraints as $constraint) {
- if ($this->isPresenceConstraint($constraint)) {
- $presence[] = $constraint;
- } else {
- $rest[] = $constraint;
- }
- }
-
- return [...$presence, ...$rest];
- }
-
- private function isPresenceConstraint(string $constraint): bool
- {
- return in_array($constraint, ['required', 'nullable', 'sometimes', 'filled', 'present', 'missing', 'bail', 'exclude', 'prohibited'], true)
- || str_starts_with($constraint, 'required_')
- || str_starts_with($constraint, 'missing_')
- || str_starts_with($constraint, 'exclude_')
- || str_starts_with($constraint, 'prohibited_');
- }
-
/**
* Add any Laravel validation rule — string, object, or array tuple.
*
@@ -433,7 +328,7 @@ private function isPresenceConstraint(string $constraint): bool
* instance — there is no defensive copy. Clone first if you need
* isolation: `(clone $ruleSet->get($field))->rule($extra)`.
*
- * @param object|string|array $rule
+ * @param object|string|array $rule
*/
public function rule(object|string|array $rule, ?string $message = null): static
{
@@ -458,9 +353,9 @@ public function rule(object|string|array $rule, ?string $message = null): static
* fn ($r) => $r->sometimes()->max(100),
* )
*
- * @param Closure(Fluent): bool $condition
- * @param Closure(static): static|string|list $rules
- * @param Closure(static): static|string|list $defaultRules
+ * @param Closure(Fluent): bool $condition
+ * @param Closure(static): static|string|list $rules
+ * @param Closure(static): static|string|list $defaultRules
*/
public function whenInput(Closure $condition, Closure|string|array $rules, Closure|string|array $defaultRules = []): static
{
@@ -472,7 +367,116 @@ public function whenInput(Closure $condition, Closure|string|array $rules, Closu
}
/**
- * @param Closure(static): static|string|list $rules
+ * Seed $lastConstraint for rule classes whose defining constraint
+ * is property-initialised (e.g. `protected array $constraints = ['string']`)
+ * rather than added via addRule(). Called from factory constructors so
+ * `FluentRule::string()->message('...')` binds to 'string' without the
+ * caller knowing the initialisation internals.
+ */
+ protected function seedLastConstraint(string $name): void
+ {
+ $this->lastConstraint = $name;
+ }
+
+ /**
+ * Strings are appended to $constraints. Objects are appended to $rules.
+ *
+ * When $message is non-null, writes it to customMessages under the
+ * resolved $lastConstraint key. Throws LogicException if $message is
+ * set but no rule was added (e.g. an early-return branch in a caller).
+ *
+ * @param array|string|object $rules
+ */
+ protected function addRule(array|string|object $rules, ?string $message = null): static
+ {
+ $this->compiledCache = null;
+
+ if (is_object($rules)) {
+ $this->rules[] = $rules;
+
+ $this->lastConstraint = match (true) {
+ $rules instanceof RequiredIf => 'required',
+ $rules instanceof RequiredUnless => 'required',
+ $rules instanceof ProhibitedIf => 'prohibited',
+ $rules instanceof ProhibitedUnless => 'prohibited',
+ $rules instanceof ExcludeIf => 'exclude',
+ $rules instanceof ExcludeUnless => 'exclude',
+ $rules instanceof In => 'in',
+ $rules instanceof NotIn => 'not_in',
+ $rules instanceof Unique => 'unique',
+ $rules instanceof Exists => 'exists',
+ $rules instanceof Contains => 'contains',
+ $rules instanceof DoesntContain => 'doesnt_contain',
+ default => lcfirst(class_basename($rules)),
+ };
+ } else {
+ $this->constraints = array_merge($this->constraints, Arr::wrap($rules));
+ // Track last constraint name: 'min:2' → 'min', 'required' → 'required'
+ $last = is_array($rules) ? end($rules) : $rules;
+ $this->lastConstraint = is_string($last) ? explode(':', $last, 2)[0] : null;
+ }
+
+ if ($message !== null) {
+ if ($this->lastConstraint === null) {
+ throw new LogicException('message parameter has no rule to bind to');
+ }
+
+ $this->customMessages[$this->lastConstraint] = $message;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Reorder constraints so that presence modifiers (required, nullable, bail, etc.)
+ * appear before type and size constraints ("required" must come
+ * before "must be a string").
+ *
+ * @param list $constraints
+ *
+ * @return list
+ */
+ protected function reorderConstraints(array $constraints): array
+ {
+ $presence = [];
+ $rest = [];
+
+ foreach ($constraints as $constraint) {
+ if ($this->isPresenceConstraint($constraint)) {
+ $presence[] = $constraint;
+ } else {
+ $rest[] = $constraint;
+ }
+ }
+
+ return [...$presence, ...$rest];
+ }
+
+ /** @param array $values */
+ private static function serializeValues(array $values): string
+ {
+ return implode(',', array_map(
+ static fn (string|int|bool|BackedEnum $v): string|int => match (true) {
+ $v instanceof BackedEnum => $v->value,
+ is_bool($v) => $v ? '1' : '0',
+ default => $v,
+ },
+ $values,
+ ));
+ }
+
+ private function isPresenceConstraint(string $constraint): bool
+ {
+ return in_array($constraint, ['required', 'nullable', 'sometimes', 'filled', 'present', 'missing', 'bail', 'exclude', 'prohibited'], true)
+ || str_starts_with($constraint, 'required_')
+ || str_starts_with($constraint, 'missing_')
+ || str_starts_with($constraint, 'exclude_')
+ || str_starts_with($constraint, 'prohibited_');
+ }
+
+ /**
+ * @param Closure(static): static|string|list $rules
+ *
* @return string|list
*/
private function compileConditionalBranch(Closure|string|array $rules): string|array
diff --git a/src/Builder/Concerns/SelfValidates.php b/src/Builder/Concerns/SelfValidates.php
index 85a683a..4183987 100644
--- a/src/Builder/Concerns/SelfValidates.php
+++ b/src/Builder/Concerns/SelfValidates.php
@@ -1,23 +1,42 @@
- */
protected array $data = [];
+ /** @var string|list|null */
+ protected string|array|null $compiledCache = null;
+
+ public function __clone(): void
+ {
+ $this->compiledCache = null;
+ }
+
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! $this->hasPresenceModifier() && ! Arr::has($this->data, $attribute)) {
@@ -63,6 +90,98 @@ public function validate(string $attribute, mixed $value, Closure $fail): void
$this->forwardErrors($innerValidator, $attribute, $nestedRules !== [], $fail);
}
+ public function canCompile(): bool
+ {
+ return $this->rules === [];
+ }
+
+ /**
+ * Compile to native Laravel format. Returns a pipe-joined string
+ * when possible (faster for Laravel to parse), or an array when
+ * non-stringable object rules are present. Memoized since rule
+ * objects are immutable after construction.
+ *
+ * @return string|list
+ */
+ public function compiledRules(): string|array
+ {
+ return $this->compiledCache ??= $this->buildCompiledRules();
+ }
+
+ /**
+ * Get the compiled rules as an array. Useful for debugging and testing.
+ *
+ * @return list
+ */
+ public function toArray(): array
+ {
+ $compiled = $this->compiledRules();
+
+ if (is_array($compiled)) {
+ return $compiled;
+ }
+
+ return $compiled === '' ? [] : explode('|', $compiled);
+ }
+
+ /**
+ * Dump the compiled rules and terminate execution.
+ */
+ public function dd(mixed ...$args): never
+ {
+ dd($this->toArray(), ...$args);
+ }
+
+ /**
+ * Dump the compiled rules.
+ */
+ public function dump(mixed ...$args): static
+ {
+ dump($this->toArray(), ...$args);
+
+ return $this;
+ }
+
+ /** @return array */
+ public function buildNestedRules(string $attribute): array
+ {
+ return [];
+ }
+
+ public function setValidator(\Illuminate\Contracts\Validation\Validator $validator): static
+ {
+ $this->validator = $validator;
+
+ return $this;
+ }
+
+ /** @param array $data */
+ public function setData(array $data): static
+ {
+ $this->data = $data;
+
+ return $this;
+ }
+
+ protected function hasPresenceModifier(): bool
+ {
+ if (array_intersect($this->constraints, ['required', 'sometimes', 'nullable', 'present']) !== []) {
+ return true;
+ }
+
+ if ($this->hasPresenceConstraint()) {
+ return true;
+ }
+
+ return $this->hasPresenceRule();
+ }
+
+ /** @return list */
+ protected function buildValidationRules(): array
+ {
+ return [...$this->rules, ...$this->constraints];
+ }
+
/** @return array */
private function buildMessages(string $attribute): array
{
@@ -146,23 +265,6 @@ private function isNullable(mixed $value): bool
&& in_array('nullable', $this->constraints, true);
}
- /**
- * Exact rule names (the part before any `:`) whose rules must be evaluated
- * against a null/absent value rather than skipped by `nullable`. Matched
- * exactly — not by prefix — so a non-presence rule that merely shares a
- * stem (most notably `required_array_keys`) never counts, and no entry is
- * silently subsumed by another (`required_with` vs `required_without`).
- */
- private const PRESENCE_FORCING_RULES = [
- 'required', 'filled', 'present', 'missing',
- 'required_if', 'required_unless',
- 'required_with', 'required_with_all',
- 'required_without', 'required_without_all',
- 'required_if_accepted', 'required_if_declined',
- 'present_if', 'present_unless', 'present_with', 'present_with_all',
- 'missing_if', 'missing_unless', 'missing_with', 'missing_with_all',
- ];
-
/**
* Whether the chain carries a modifier that forces evaluation of a
* null/absent value, in which case `nullable` must NOT short-circuit
@@ -208,19 +310,6 @@ private function hasPresenceForcingModifier(): bool
return false;
}
- protected function hasPresenceModifier(): bool
- {
- if (array_intersect($this->constraints, ['required', 'sometimes', 'nullable', 'present']) !== []) {
- return true;
- }
-
- if ($this->hasPresenceConstraint()) {
- return true;
- }
-
- return $this->hasPresenceRule();
- }
-
private function hasPresenceConstraint(): bool
{
foreach ($this->constraints as $constraint) {
@@ -281,66 +370,6 @@ private function shouldExclude(string $attribute): bool
return false;
}
- public function canCompile(): bool
- {
- return $this->rules === [];
- }
-
- /** @var string|list|null */
- protected string|array|null $compiledCache = null;
-
- public function __clone(): void
- {
- $this->compiledCache = null;
- }
-
- /**
- * Compile to native Laravel format. Returns a pipe-joined string
- * when possible (faster for Laravel to parse), or an array when
- * non-stringable object rules are present. Memoized since rule
- * objects are immutable after construction.
- *
- * @return string|list
- */
- public function compiledRules(): string|array
- {
- return $this->compiledCache ??= $this->buildCompiledRules();
- }
-
- /**
- * Get the compiled rules as an array. Useful for debugging and testing.
- *
- * @return list
- */
- public function toArray(): array
- {
- $compiled = $this->compiledRules();
-
- if (is_array($compiled)) {
- return $compiled;
- }
-
- return $compiled === '' ? [] : explode('|', $compiled);
- }
-
- /**
- * Dump the compiled rules and terminate execution.
- */
- public function dd(mixed ...$args): never
- {
- dd($this->toArray(), ...$args);
- }
-
- /**
- * Dump the compiled rules.
- */
- public function dump(mixed ...$args): static
- {
- dump($this->toArray(), ...$args);
-
- return $this;
- }
-
/** @return string|list */
private function buildCompiledRules(): string|array
{
@@ -398,7 +427,7 @@ private function buildCompiledRules(): string|array
* `regex:/^(a|b)$/` pattern) cannot be safely pipe-joined — Laravel's parser
* would split it. Rules with one fall back to array form instead.
*
- * @param list $rules
+ * @param list $rules
*/
private function anyContainsPipe(array $rules): bool
{
@@ -422,31 +451,4 @@ private function isPresenceModifier(object $rule): bool
|| $rule instanceof RequiredIf || $rule instanceof RequiredUnless
|| $rule instanceof ProhibitedIf || $rule instanceof ProhibitedUnless;
}
-
- /** @return list */
- protected function buildValidationRules(): array
- {
- return [...$this->rules, ...$this->constraints];
- }
-
- /** @return array */
- public function buildNestedRules(string $attribute): array
- {
- return [];
- }
-
- public function setValidator(\Illuminate\Contracts\Validation\Validator $validator): static
- {
- $this->validator = $validator;
-
- return $this;
- }
-
- /** @param array $data */
- public function setData(array $data): static
- {
- $this->data = $data;
-
- return $this;
- }
}
diff --git a/src/Builder/Nodes/AcceptedRule.php b/src/Builder/Nodes/AcceptedRule.php
index 0434996..3b98c5a 100644
--- a/src/Builder/Nodes/AcceptedRule.php
+++ b/src/Builder/Nodes/AcceptedRule.php
@@ -1,14 +1,16 @@
- $rules
+ * @param array $rules
*
* @throws CannotExtendListShapedEach when `eachRules` is list-shaped.
*/
@@ -169,7 +171,7 @@ public function mergeEachRules(array $rules): static
if (array_key_exists('', $rules)) {
throw new InvalidArgumentException(
- 'mergeEachRules() requires non-empty keys — empty keys expand to malformed wildcard paths (items.*.).'
+ 'mergeEachRules() requires non-empty keys — empty keys expand to malformed wildcard paths (items.*.).',
);
}
@@ -185,7 +187,7 @@ public function mergeEachRules(array $rules): static
* Unlike each() which produces wildcard paths (items.*.name),
* children() produces fixed paths (search.value, search.regex).
*
- * @param array $rules
+ * @param array $rules
*/
public function children(array $rules): static
{
@@ -264,6 +266,37 @@ public function doesntContain(Arrayable|UnitEnum|array|string|int ...$values): s
return $this->addRule(new DoesntContain($this->flattenContainsValues($values)));
}
+ /** @return array */
+ public function buildNestedRules(string $attribute): array
+ {
+ $rules = $this->buildEachNestedRules($attribute);
+
+ return array_merge($rules, $this->buildChildNestedRules($attribute));
+ }
+
+ protected function buildArrayRule(): string
+ {
+ if ($this->keys === []) {
+ return 'array';
+ }
+
+ $keys = array_map(
+ static fn (string|BackedEnum $key): string|int => $key instanceof BackedEnum ? $key->value : $key,
+ $this->keys,
+ );
+
+ return 'array:' . implode(',', $keys);
+ }
+
+ /** @return list */
+ protected function buildValidationRules(): array
+ {
+ return [
+ ...$this->rules,
+ ...$this->reorderConstraints([$this->buildArrayRule(), ...$this->constraints]),
+ ];
+ }
+
/**
* Unwrap a single `Arrayable` or `array` varargs entry so `->contains([...])`
* and `->contains(...$iter)` behave identically. Matches Laravel's
@@ -273,7 +306,8 @@ public function doesntContain(Arrayable|UnitEnum|array|string|int ...$values): s
* Laravel's Rule::contains silently ignores extras, and leaving nested
* arrays/Arrayables in the value list would crash Contains::__toString.
*
- * @param array $values
+ * @param array $values
+ *
* @return array
*/
private function flattenContainsValues(array $values): array
@@ -294,7 +328,7 @@ private function flattenContainsValues(array $values): array
if (is_array($value) || $value instanceof Arrayable) {
throw new InvalidArgumentException(
'contains()/doesntContain() does not accept multiple array or Arrayable arguments. '
- . 'Pass either a single iterable (->contains($values)) or variadic scalars (->contains($a, $b, $c)).'
+ . 'Pass either a single iterable (->contains($values)) or variadic scalars (->contains($a, $b, $c)).',
);
}
}
@@ -302,28 +336,6 @@ private function flattenContainsValues(array $values): array
return array_values($values);
}
- protected function buildArrayRule(): string
- {
- if ($this->keys === []) {
- return 'array';
- }
-
- $keys = array_map(
- static fn (string|BackedEnum $key): string|int => $key instanceof BackedEnum ? $key->value : $key,
- $this->keys,
- );
-
- return 'array:' . implode(',', $keys);
- }
-
- /** @return array */
- public function buildNestedRules(string $attribute): array
- {
- $rules = $this->buildEachNestedRules($attribute);
-
- return array_merge($rules, $this->buildChildNestedRules($attribute));
- }
-
/** @return array */
private function buildEachNestedRules(string $attribute): array
{
@@ -374,13 +386,4 @@ private function buildChildNestedRules(string $attribute): array
return $nested === [] ? $rules : array_merge($rules, ...$nested);
}
-
- /** @return list */
- protected function buildValidationRules(): array
- {
- return [
- ...$this->rules,
- ...$this->reorderConstraints([$this->buildArrayRule(), ...$this->constraints]),
- ];
- }
}
diff --git a/src/Builder/Nodes/BooleanRule.php b/src/Builder/Nodes/BooleanRule.php
index 67d3174..cae9fd0 100644
--- a/src/Builder/Nodes/BooleanRule.php
+++ b/src/Builder/Nodes/BooleanRule.php
@@ -1,14 +1,16 @@
-rule(new NotDisposableEmail(), $message);
+ return $this->rule(new NotDisposableEmail, $message);
}
/**
@@ -88,7 +90,7 @@ public function notDisposable(?string $message = null): static
*/
public function notRole(?string $message = null): static
{
- return $this->rule(new NotRoleEmail(), $message);
+ return $this->rule(new NotRoleEmail, $message);
}
/**
@@ -97,7 +99,7 @@ public function notRole(?string $message = null): static
* `*.example.com` matches any subdomain and NOT the bare domain; list
* both when both are wanted. See EmailDomainIs for why that is strict.
*
- * @param list|string $domains
+ * @param list|string $domains
*/
public function domainIs(array|string $domains, ?string $message = null): static
{
@@ -107,7 +109,7 @@ public function domainIs(array|string $domains, ?string $message = null): static
/**
* Bar the address from the given domains, same pattern syntax.
*
- * @param list|string $domains
+ * @param list|string $domains
*/
public function domainIsNot(array|string $domains, ?string $message = null): static
{
@@ -133,7 +135,7 @@ public function domainIsNot(array|string $domains, ?string $message = null): sta
*/
public function deliverable(?string $message = null): static
{
- return $this->rule(new DeliverableEmail(), $message);
+ return $this->rule(new DeliverableEmail, $message);
}
/**
@@ -151,7 +153,7 @@ public function deliverable(?string $message = null): static
*/
public function withoutSubaddressing(?string $message = null): static
{
- return $this->rule(new NoSubaddressing(), $message);
+ return $this->rule(new NoSubaddressing, $message);
}
/**
diff --git a/src/Builder/Nodes/FieldRule.php b/src/Builder/Nodes/FieldRule.php
index 11b7281..921e605 100644
--- a/src/Builder/Nodes/FieldRule.php
+++ b/src/Builder/Nodes/FieldRule.php
@@ -1,19 +1,21 @@
-|null */
protected ?array $childRules = null;
+ /**
+ * Mirrors `Macroable::__call` dispatch exactly except for the
+ * "no macro registered" branch, which throws a typed exception
+ * pointing at the correct typed builder instead of a bare
+ * `BadMethodCallException`. See `UnknownFluentRuleMethod`.
+ *
+ * @param array $parameters
+ */
+ public function __call(string $method, array $parameters): mixed
+ {
+ if (! static::hasMacro($method)) {
+ throw UnknownFluentRuleMethod::on($method);
+ }
+
+ $macro = static::$macros[$method];
+
+ if ($macro instanceof Closure) {
+ $macro = $macro->bindTo($this, static::class);
+ }
+
+ if (! is_callable($macro)) {
+ throw UnknownFluentRuleMethod::on($method);
+ }
+
+ return $macro(...$parameters);
+ }
+
+ /** @param array $parameters */
+ public static function __callStatic(string $method, array $parameters): mixed
+ {
+ if (! static::hasMacro($method)) {
+ throw UnknownFluentRuleMethod::on($method);
+ }
+
+ $macro = static::$macros[$method];
+
+ if ($macro instanceof Closure) {
+ $macro = $macro->bindTo(null, static::class);
+ }
+
+ if (! is_callable($macro)) {
+ throw UnknownFluentRuleMethod::on($method);
+ }
+
+ return $macro(...$parameters);
+ }
+
/**
* Define rules for fixed-key children of this field.
*
* Produces fixed paths (answer.email_address) without wildcards.
*
- * @param array $rules
+ * @param array $rules
*/
public function children(array $rules): static
{
@@ -71,15 +120,15 @@ public function getChildRules(): ?array
* survive untouched.
*
* @throws LogicException when $key already exists — silent override
- * would hide the "parent already defines this"
- * mistake. Use mergeChildRules() for
- * intentional replacement.
+ * would hide the "parent already defines this"
+ * mistake. Use mergeChildRules() for
+ * intentional replacement.
*/
public function addChildRule(string $key, ValidationRule $rule): static
{
if ($key === '') {
throw new InvalidArgumentException(
- 'addChildRule() requires a non-empty key — empty keys expand to malformed dotted paths (parent.).'
+ 'addChildRule() requires a non-empty key — empty keys expand to malformed dotted paths (parent.).',
);
}
@@ -103,13 +152,13 @@ public function addChildRule(string $key, ValidationRule $rule): static
/**
* Merge multiple keyed child rules, later-wins on collision.
*
- * @param array $rules
+ * @param array $rules
*/
public function mergeChildRules(array $rules): static
{
if (array_key_exists('', $rules)) {
throw new InvalidArgumentException(
- 'mergeChildRules() requires non-empty keys — empty keys expand to malformed dotted paths (parent.).'
+ 'mergeChildRules() requires non-empty keys — empty keys expand to malformed dotted paths (parent.).',
);
}
@@ -159,51 +208,4 @@ protected function buildValidationRules(): array
{
return [...$this->reorderConstraints($this->constraints), ...$this->rules];
}
-
- /**
- * Mirrors `Macroable::__call` dispatch exactly except for the
- * "no macro registered" branch, which throws a typed exception
- * pointing at the correct typed builder instead of a bare
- * `BadMethodCallException`. See `UnknownFluentRuleMethod`.
- *
- * @param array $parameters
- */
- public function __call(string $method, array $parameters): mixed
- {
- if (! static::hasMacro($method)) {
- throw UnknownFluentRuleMethod::on($method);
- }
-
- $macro = static::$macros[$method];
-
- if ($macro instanceof Closure) {
- $macro = $macro->bindTo($this, static::class);
- }
-
- if (! is_callable($macro)) {
- throw UnknownFluentRuleMethod::on($method);
- }
-
- return $macro(...$parameters);
- }
-
- /** @param array $parameters */
- public static function __callStatic(string $method, array $parameters): mixed
- {
- if (! static::hasMacro($method)) {
- throw UnknownFluentRuleMethod::on($method);
- }
-
- $macro = static::$macros[$method];
-
- if ($macro instanceof Closure) {
- $macro = $macro->bindTo(null, static::class);
- }
-
- if (! is_callable($macro)) {
- throw UnknownFluentRuleMethod::on($method);
- }
-
- return $macro(...$parameters);
- }
}
diff --git a/src/Builder/Nodes/FileRule.php b/src/Builder/Nodes/FileRule.php
index 596c919..c29b764 100644
--- a/src/Builder/Nodes/FileRule.php
+++ b/src/Builder/Nodes/FileRule.php
@@ -1,14 +1,16 @@
-seedLastConstraint($this->defaultConstraintName());
}
- /**
- * Hook for subclasses (ImageRule) to override which constraint name
- * seeds $lastConstraint without re-implementing __construct.
- */
- protected function defaultConstraintName(): string
- {
- return 'file';
- }
-
public function min(int|string $size, ?string $message = null): static
{
return $this->addRule('min:' . $this->toKilobytes($size), $message);
@@ -69,6 +62,15 @@ public function mimetypes(string ...$mimetypes): static
return $this->addRule('mimetypes:' . implode(',', $mimetypes));
}
+ /**
+ * Hook for subclasses (ImageRule) to override which constraint name
+ * seeds $lastConstraint without re-implementing __construct.
+ */
+ protected function defaultConstraintName(): string
+ {
+ return 'file';
+ }
+
protected function toKilobytes(int|string $size): int
{
if (is_int($size)) {
@@ -80,10 +82,10 @@ protected function toKilobytes(int|string $size): int
$unit = strtolower($matches[2]);
return (int) round(match ($unit) {
- 'kb' => $value,
- 'mb' => $value * 1_000,
- 'gb' => $value * 1_000_000,
- 'tb' => $value * 1_000_000_000,
+ 'kb' => $value,
+ 'mb' => $value * 1_000,
+ 'gb' => $value * 1_000_000,
+ 'tb' => $value * 1_000_000_000,
default => $value,
});
}
diff --git a/src/Builder/Nodes/ImageRule.php b/src/Builder/Nodes/ImageRule.php
index 449a960..b90d72a 100644
--- a/src/Builder/Nodes/ImageRule.php
+++ b/src/Builder/Nodes/ImageRule.php
@@ -1,4 +1,6 @@
- */
protected array $constraints = ['image'];
- protected function defaultConstraintName(): string
- {
- return 'image';
- }
-
public function allowSvg(): static
{
$this->constraints = array_values(array_map(
@@ -63,4 +60,9 @@ public function ratio(float|string $value, ?string $message = null): static
{
return $this->dimensions(new Dimensions(['ratio' => $value]), $message);
}
+
+ protected function defaultConstraintName(): string
+ {
+ return 'image';
+ }
}
diff --git a/src/Builder/Nodes/IpAddressRule.php b/src/Builder/Nodes/IpAddressRule.php
index 2164392..81b1eb0 100644
--- a/src/Builder/Nodes/IpAddressRule.php
+++ b/src/Builder/Nodes/IpAddressRule.php
@@ -1,18 +1,20 @@
-rule(new PublicIp(), $message);
+ return $this->rule(new PublicIp, $message);
}
/**
@@ -89,7 +91,7 @@ public function public(?string $message = null): static
*/
public function private(?string $message = null): static
{
- return $this->rule(new PrivateIp(), $message);
+ return $this->rule(new PrivateIp, $message);
}
/**
@@ -100,13 +102,25 @@ public function private(?string $message = null): static
* FluentRule::ip()->inRange(['203.0.113.0/24', '2001:db8::/32'])
* ```
*
- * @param list|string $networks
+ * @param list|string $networks
*/
public function inRange(array|string $networks, ?string $message = null): static
{
return $this->rule(new InCidrRange(array_values((array) $networks)), $message);
}
+ /**
+ * @return list
+ */
+ protected function buildValidationRules(): array
+ {
+ return [
+ ...$this->reorderConstraints($this->constraints),
+ $this->family,
+ ...$this->rules,
+ ];
+ }
+
private function family(string $rule, ?string $message): static
{
$this->family = $rule;
@@ -120,16 +134,4 @@ private function family(string $rule, ?string $message): static
return $this;
}
-
- /**
- * @return list
- */
- protected function buildValidationRules(): array
- {
- return [
- ...$this->reorderConstraints($this->constraints),
- $this->family,
- ...$this->rules,
- ];
- }
}
diff --git a/src/Builder/Nodes/MacAddressRule.php b/src/Builder/Nodes/MacAddressRule.php
index f646988..59b9286 100644
--- a/src/Builder/Nodes/MacAddressRule.php
+++ b/src/Builder/Nodes/MacAddressRule.php
@@ -1,16 +1,18 @@
-|string $formats {@see MacAddress::COLON} and friends.
+ * @param list|string $formats {@see MacAddress::COLON} and friends.
*/
public function format(array|string $formats): static
{
@@ -139,7 +141,7 @@ public function universal(): static
* Written in any notation and any length — a 24-bit OUI, or a longer
* MA-M/MA-S assignment.
*
- * @param list|string $ouis
+ * @param list|string $ouis
*/
public function oui(array|string $ouis): static
{
diff --git a/src/Builder/Nodes/NumericRule.php b/src/Builder/Nodes/NumericRule.php
index ec37807..1332407 100644
--- a/src/Builder/Nodes/NumericRule.php
+++ b/src/Builder/Nodes/NumericRule.php
@@ -1,15 +1,17 @@
-
- */
- protected function buildValidationRules(): array
- {
- return [
- ...$this->reorderConstraints($this->constraints),
- new Phone(
- countries: $this->countries,
- countryField: $this->countryField,
- types: $this->types,
- possibleOnly: $this->possibleOnly,
- allowExtension: $this->allowExtension,
- rejectShortNumbers: $this->rejectShortNumbers,
- rejectEmergency: $this->rejectEmergency,
- ),
- ...$this->rules,
- ];
- }
-
/**
* Uniqueness compared in E.164 rather than as typed.
*
@@ -237,4 +219,24 @@ public function unique(string $table, ?string $column = null, ?Closure $callback
return $this->addRule($rule, $message);
}
+
+ /**
+ * @return list
+ */
+ protected function buildValidationRules(): array
+ {
+ return [
+ ...$this->reorderConstraints($this->constraints),
+ new Phone(
+ countries: $this->countries,
+ countryField: $this->countryField,
+ types: $this->types,
+ possibleOnly: $this->possibleOnly,
+ allowExtension: $this->allowExtension,
+ rejectShortNumbers: $this->rejectShortNumbers,
+ rejectEmergency: $this->rejectEmergency,
+ ),
+ ...$this->rules,
+ ];
+ }
}
diff --git a/src/Builder/Nodes/StringRule.php b/src/Builder/Nodes/StringRule.php
index 8cfaadd..669cd75 100644
--- a/src/Builder/Nodes/StringRule.php
+++ b/src/Builder/Nodes/StringRule.php
@@ -1,17 +1,19 @@
-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
{
return $this->addRule('not_regex:' . $pattern, $message);
@@ -267,4 +257,16 @@ protected function buildValidationRules(): array
{
return [...$this->reorderConstraints($this->constraints), ...$this->rules];
}
+
+ /** @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();
+ }
}
diff --git a/src/Builder/Nodes/UrlRule.php b/src/Builder/Nodes/UrlRule.php
index 441617f..7bb6ac4 100644
--- a/src/Builder/Nodes/UrlRule.php
+++ b/src/Builder/Nodes/UrlRule.php
@@ -1,17 +1,19 @@
-|string $schemes
+ * @param list|string $schemes
*/
public function scheme(array|string $schemes): static
{
@@ -95,7 +97,7 @@ public function secure(): static
* bare domain. List both when both are wanted — see the note on
* {@see HostPattern}.
*
- * @param list|string $hosts
+ * @param list|string $hosts
*/
public function hostIs(array|string $hosts): static
{
@@ -107,7 +109,7 @@ public function hostIs(array|string $hosts): static
/**
* Bar these hosts, same syntax. Applied after the allow-list.
*
- * @param list|string $hosts
+ * @param list|string $hosts
*/
public function hostIsNot(array|string $hosts): static
{
@@ -120,7 +122,7 @@ public function hostIsNot(array|string $hosts): static
* Accept only these ports. A URL with no port is accepted only if the
* default for its scheme is in the list — state 80 or 443 explicitly.
*
- * @param list|int $ports
+ * @param list|int $ports
*/
public function port(array|int $ports): static
{
diff --git a/src/Builder/Nodes/UsernameRule.php b/src/Builder/Nodes/UsernameRule.php
index cadf1fe..4bcf78d 100644
--- a/src/Builder/Nodes/UsernameRule.php
+++ b/src/Builder/Nodes/UsernameRule.php
@@ -1,16 +1,18 @@
-reserved([...Username::DEFAULT_RESERVED, 'acme', 'enterprise'])
* ```
*
- * @param list $names
+ * @param list $names
*/
public function reserved(array $names): static
{
@@ -130,7 +132,7 @@ public function reserved(array $names): static
/**
* Add to the shipped list rather than replacing it.
*
- * @param list|string $names
+ * @param list|string $names
*/
public function alsoReserved(array|string $names): static
{
diff --git a/src/Check.php b/src/Check.php
index 86e494c..564d30f 100644
--- a/src/Check.php
+++ b/src/Check.php
@@ -1,34 +1,36 @@
- ['OK', resolve(RoleAccountList::class)::class]],
- ['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]],
+ ['dns resolver', fn (): array => ['OK', resolve(DnsResolver::class)::class]],
['batch query cap', function (): array {
$limit = BatchDatabaseChecker::$maxValuesPerGroup;
diff --git a/src/Commands/RulesCommand.php b/src/Commands/RulesCommand.php
index 9502a23..abd442c 100644
--- a/src/Commands/RulesCommand.php
+++ b/src/Commands/RulesCommand.php
@@ -1,13 +1,15 @@
- [User::class],
- 'authorized' => ['view', User::class],
- 'parity' => ['even'],
- 'vendor_identifier' => ['aws_region'],
+ 'models_exist' => [User::class],
+ 'authorized' => ['view', User::class],
+ 'parity' => ['even'],
+ 'vendor_identifier' => ['aws_region'],
'national_identifier' => ['nl'],
- 'minute_in' => ['0', '15', '30', '45'],
+ 'minute_in' => ['0', '15', '30', '45'],
'max_date_difference' => ['48', '2026-08-24 00:00:00'],
- 'minimum_age' => ['18'],
- 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'],
+ 'minimum_age' => ['18'],
+ 'compare_to_column' => ['posts', 'owner_id', 'gte', 'id', '1'],
'file_exists_on_disk' => ['local'],
- 'hash_digest' => ['sha256'],
- 'max_words' => ['200'],
- 'min_words' => ['2'],
+ 'hash_digest' => ['sha256'],
+ 'max_words' => ['200'],
+ 'min_words' => ['2'],
];
$index = [];
diff --git a/src/Concerns/SkipsPrecognition.php b/src/Concerns/SkipsPrecognition.php
index b3dafea..23be9fb 100644
--- a/src/Concerns/SkipsPrecognition.php
+++ b/src/Concerns/SkipsPrecognition.php
@@ -1,9 +1,11 @@
- $rule
+ * @param object|string|array $rule
*/
public function rule(object|string|array $rule): static;
@@ -149,25 +151,27 @@ public function rule(object|string|array $rule): static;
* return is `$this|\Illuminate\Conditionable\HigherOrderWhenProxy`
* depending on whether a callback is supplied.
*
- * @param mixed $value
- * @param (callable(static, mixed): mixed)|null $callback
- * @param (callable(static, mixed): mixed)|null $default
+ * @param mixed $value
+ * @param (callable(static, mixed): mixed)|null $callback
+ * @param (callable(static, mixed): mixed)|null $default
+ *
* @return static|HigherOrderWhenProxy
*/
public function when($value = null, ?callable $callback = null, ?callable $default = null);
/**
- * @param mixed $value
- * @param (callable(static, mixed): mixed)|null $callback
- * @param (callable(static, mixed): mixed)|null $default
+ * @param mixed $value
+ * @param (callable(static, mixed): mixed)|null $callback
+ * @param (callable(static, mixed): mixed)|null $default
+ *
* @return static|HigherOrderWhenProxy
*/
public function unless($value = null, ?callable $callback = null, ?callable $default = null);
/**
- * @param Closure(Fluent): bool $condition
- * @param Closure(static): static|string|list $rules
- * @param Closure(static): static|string|list $defaultRules
+ * @param Closure(Fluent): bool $condition
+ * @param Closure(static): static|string|list $rules
+ * @param Closure(static): static|string|list $defaultRules
*/
public function whenInput(Closure $condition, Closure|string|array $rules, Closure|string|array $defaultRules = []): static;
diff --git a/src/Contracts/I18n/CountryDataset.php b/src/Contracts/I18n/CountryDataset.php
index 99987b4..e9a5063 100644
--- a/src/Contracts/I18n/CountryDataset.php
+++ b/src/Contracts/I18n/CountryDataset.php
@@ -1,4 +1,6 @@
- $rules Field → rule input, as the rule set holds it. Mutable.
- * @param array $messages Custom messages. Mutable.
- * @param array $attributes Human attribute names. Mutable.
- * @param array $data The data about to be validated. Mutable.
+ * @param array $rules Field → rule input, as the rule set holds it. Mutable.
+ * @param array $messages Custom messages. Mutable.
+ * @param array $attributes Human attribute names. Mutable.
+ * @param array $data The data about to be validated. Mutable.
*/
public function __construct(
public readonly RuleSet $ruleSet,
diff --git a/src/Events/ValidationCompleted.php b/src/Events/ValidationCompleted.php
index bdbaf6d..2f54934 100644
--- a/src/Events/ValidationCompleted.php
+++ b/src/Events/ValidationCompleted.php
@@ -1,4 +1,6 @@
- $data
- * @param array $validated
+ * @param array $data
+ * @param array $validated
*/
public function __construct(
public RuleSet $ruleSet,
diff --git a/src/Events/ValidationFailed.php b/src/Events/ValidationFailed.php
index 3068425..43b8557 100644
--- a/src/Events/ValidationFailed.php
+++ b/src/Events/ValidationFailed.php
@@ -1,10 +1,12 @@
- 'string',
- NumericRule::class => 'numeric',
- DateRule::class => 'date',
- ArrayRule::class => 'array',
- FileRule::class => 'file',
- ImageRule::class => 'image',
- BooleanRule::class => 'boolean',
+ StringRule::class => 'string',
+ NumericRule::class => 'numeric',
+ DateRule::class => 'date',
+ ArrayRule::class => 'array',
+ FileRule::class => 'file',
+ ImageRule::class => 'image',
+ BooleanRule::class => 'boolean',
AcceptedRule::class => 'accepted',
PasswordRule::class => 'password',
- EmailRule::class => 'email',
+ EmailRule::class => 'email',
];
/**
@@ -111,14 +113,14 @@ public static function for(string $method): ?string
private static function specialCase(string $method): ?string
{
return match ($method) {
- 'accepted' => 'Use `FluentRule::accepted()` for permissive accepted values (`\'yes\'`, `\'on\'`, `\'1\'`, `true`). Avoid `FluentRule::boolean()->accepted()` — the `boolean` base rule rejects `\'yes\'`/`\'on\'`.',
- 'declined' => "Use `FluentRule::boolean()->declined(...)` for strict boolean-only input. If HTML form values (`'no'`, `'off'`) need to pass, use `->rule('declined')` on the untyped builder instead.",
- 'size' => 'No `size()` method — use `->exactly(...)` on a typed builder. (This package renames Laravel\'s `size:` rule to `exactly()` for clarity.)',
+ 'accepted' => 'Use `FluentRule::accepted()` for permissive accepted values (`\'yes\'`, `\'on\'`, `\'1\'`, `true`). Avoid `FluentRule::boolean()->accepted()` — the `boolean` base rule rejects `\'yes\'`/`\'on\'`.',
+ 'declined' => "Use `FluentRule::boolean()->declined(...)` for strict boolean-only input. If HTML form values (`'no'`, `'off'`) need to pass, use `->rule('declined')` on the untyped builder instead.",
+ 'size' => 'No `size()` method — use `->exactly(...)` on a typed builder. (This package renames Laravel\'s `size:` rule to `exactly()` for clarity.)',
'gt', 'gte', 'lt', 'lte' => 'No `gt()`/`lt()` methods — use `FluentRule::numeric()->greaterThan(FIELD)`, `->greaterThanOrEqualTo(FIELD)`, `->lessThan(FIELD)`, `->lessThanOrEqualTo(FIELD)`.',
- 'alphaNum' => 'No `alphaNum()` method — use `FluentRule::string()->alphaNumeric(...)`.',
- 'contains' => 'Use `FluentRule::array()->contains(...)` (this package exposes `contains` on `array()`, not `string()`).',
- 'format' => 'Use `FluentRule::date()->format(...)` for date format; for string-format regex use `FluentRule::string()->regex(...)`.',
- default => null,
+ 'alphaNum' => 'No `alphaNum()` method — use `FluentRule::string()->alphaNumeric(...)`.',
+ 'contains' => 'Use `FluentRule::array()->contains(...)` (this package exposes `contains` on `array()`, not `string()`).',
+ 'format' => 'Use `FluentRule::date()->format(...)` for date format; for string-format regex use `FluentRule::string()->regex(...)`.',
+ default => null,
};
}
@@ -155,7 +157,8 @@ private static function methodMap(): array
}
/**
- * @param class-string $class
+ * @param class-string $class
+ *
* @return array
*/
private static function publicInstanceMethods(string $class): array
@@ -184,7 +187,7 @@ private static function publicInstanceMethods(string $class): array
}
/**
- * @param list $factories
+ * @param list $factories
*/
private static function formatFactoryList(array $factories): string
{
diff --git a/src/Exceptions/UnknownFluentRuleMethod.php b/src/Exceptions/UnknownFluentRuleMethod.php
index 276ed75..c2486d4 100644
--- a/src/Exceptions/UnknownFluentRuleMethod.php
+++ b/src/Exceptions/UnknownFluentRuleMethod.php
@@ -1,4 +1,6 @@
- $config
+ * @param array $config
+ *
* @return array|null
*/
private static function parsePartWithItemContext(string $part, array $config): ?array
@@ -149,18 +152,18 @@ private static function parsePartWithItemContext(string $part, array $config): ?
// For date rules, use the field-ref-aware parser. For everything else,
// delegate to the value-only parser in RuleConfigBuilder.
return match (true) {
- str_starts_with($part, 'date_equals:') => self::parseDateParamWithFieldRef($config, 'dateEquals', substr($part, 12)),
- str_starts_with($part, 'after_or_equal:') => self::parseDateParamWithFieldRef($config, 'afterOrEqual', substr($part, 15)),
+ str_starts_with($part, 'date_equals:') => self::parseDateParamWithFieldRef($config, 'dateEquals', substr($part, 12)),
+ str_starts_with($part, 'after_or_equal:') => self::parseDateParamWithFieldRef($config, 'afterOrEqual', substr($part, 15)),
str_starts_with($part, 'before_or_equal:') => self::parseDateParamWithFieldRef($config, 'beforeOrEqual', substr($part, 16)),
- str_starts_with($part, 'after:') => self::parseDateParamWithFieldRef($config, 'after', substr($part, 6)),
- str_starts_with($part, 'before:') => self::parseDateParamWithFieldRef($config, 'before', substr($part, 7)),
- str_starts_with($part, 'same:') => self::parseFieldOnlyRef($config, 'sameField', substr($part, 5)),
- str_starts_with($part, 'different:') => self::parseFieldOnlyRef($config, 'differentField', substr($part, 10)),
- str_starts_with($part, 'gte:') => self::parseFieldOnlyRef($config, 'gteField', substr($part, 4)),
- str_starts_with($part, 'lte:') => self::parseFieldOnlyRef($config, 'lteField', substr($part, 4)),
- str_starts_with($part, 'gt:') => self::parseFieldOnlyRef($config, 'gtField', substr($part, 3)),
- str_starts_with($part, 'lt:') => self::parseFieldOnlyRef($config, 'ltField', substr($part, 3)),
- default => RuleConfigBuilder::parseValuePart($part, $config),
+ str_starts_with($part, 'after:') => self::parseDateParamWithFieldRef($config, 'after', substr($part, 6)),
+ str_starts_with($part, 'before:') => self::parseDateParamWithFieldRef($config, 'before', substr($part, 7)),
+ str_starts_with($part, 'same:') => self::parseFieldOnlyRef($config, 'sameField', substr($part, 5)),
+ str_starts_with($part, 'different:') => self::parseFieldOnlyRef($config, 'differentField', substr($part, 10)),
+ str_starts_with($part, 'gte:') => self::parseFieldOnlyRef($config, 'gteField', substr($part, 4)),
+ str_starts_with($part, 'lte:') => self::parseFieldOnlyRef($config, 'lteField', substr($part, 4)),
+ str_starts_with($part, 'gt:') => self::parseFieldOnlyRef($config, 'gtField', substr($part, 3)),
+ str_starts_with($part, 'lt:') => self::parseFieldOnlyRef($config, 'ltField', substr($part, 3)),
+ default => RuleConfigBuilder::parseValuePart($part, $config),
};
}
@@ -169,7 +172,8 @@ private static function parsePartWithItemContext(string $part, array $config): ?
* is a date literal, behaves like a literal parse. Otherwise, if it's a
* plausible field name, stores it under `{$key}Field` for item-time resolution.
*
- * @param array $config
+ * @param array $config
+ *
* @return array|null
*/
private static function parseDateParamWithFieldRef(array $config, string $key, string $param): ?array
@@ -193,7 +197,8 @@ private static function parseDateParamWithFieldRef(array $config, string $key, s
* parameter isn't a single plausible identifier — so multi-param forms
* like `different:a,b` fall through to Laravel.
*
- * @param array $config
+ * @param array $config
+ *
* @return array|null
*/
private static function parseFieldOnlyRef(array $config, string $key, string $param): ?array
@@ -210,7 +215,8 @@ private static function parseFieldOnlyRef(array $config, string $key, string $pa
* against the item array. Delegates most checks to CoreValueCompiler's
* value-only closure.
*
- * @param array $c
+ * @param array $c
+ *
* @return Closure(mixed, array): bool
*/
private static function buildItemAwareClosure(array $c): Closure
@@ -261,13 +267,24 @@ private static function buildItemAwareClosure(array $c): Closure
return static function (mixed $value, array $item) use (
$valueClosure,
- $afterField, $beforeField,
- $afterOrEqualField, $beforeOrEqualField,
+ $afterField,
+ $beforeField,
+ $afterOrEqualField,
+ $beforeOrEqualField,
$dateEqualsField,
- $sameField, $differentField,
- $gtField, $gteField, $ltField, $lteField,
- $isNumeric, $isInteger, $isArray, $nullable, $hasImplicit,
- $hasDateFieldRef, $hasSizeComparison
+ $sameField,
+ $differentField,
+ $gtField,
+ $gteField,
+ $ltField,
+ $lteField,
+ $isNumeric,
+ $isInteger,
+ $isArray,
+ $nullable,
+ $hasImplicit,
+ $hasDateFieldRef,
+ $hasSizeComparison
): bool {
if (! $valueClosure($value)) {
return false;
@@ -421,7 +438,7 @@ private static function sizePair(mixed $value, mixed $ref, bool $isNumeric, bool
* missing, empty, non-string, unparseable) coerce to 0 — matches Laravel's
* loose-comparison behavior when a referenced field doesn't parse.
*
- * @param array $item
+ * @param array $item
*/
private static function resolveRefTimestamp(array $item, string $field): int
{
diff --git a/src/FastCheck/PresenceConditionalCompiler.php b/src/FastCheck/PresenceConditionalCompiler.php
index 7154730..48077f2 100644
--- a/src/FastCheck/PresenceConditionalCompiler.php
+++ b/src/FastCheck/PresenceConditionalCompiler.php
@@ -1,10 +1,12 @@
- $fields
- * @param array $item
+ * @param list $fields
+ * @param array $item
*/
private static function presenceConditionActive(string $type, array $fields, array $item): bool
{
@@ -104,11 +106,11 @@ private static function presenceConditionActive(string $type, array $fields, arr
}
return match ($type) {
- 'required_with' => in_array(true, $present, true),
- 'required_without' => in_array(false, $present, true),
- 'required_with_all' => ! in_array(false, $present, true),
+ 'required_with' => in_array(true, $present, true),
+ 'required_without' => in_array(false, $present, true),
+ 'required_with_all' => ! in_array(false, $present, true),
'required_without_all' => ! in_array(true, $present, true),
- default => false,
+ default => false,
};
}
}
diff --git a/src/FastCheck/ProhibitedCompiler.php b/src/FastCheck/ProhibitedCompiler.php
index 2a96580..ecdff96 100644
--- a/src/FastCheck/ProhibitedCompiler.php
+++ b/src/FastCheck/ProhibitedCompiler.php
@@ -1,4 +1,6 @@
- false, 'filled' => false,
- 'nullable' => false, 'sometimes' => false,
- 'string' => false, 'numeric' => false, 'integer' => false, 'integer.strict' => false,
- 'boolean' => false, 'array' => false, 'email' => false, 'date' => false,
- 'url' => false, 'ip' => false, 'uuid' => false, 'ulid' => false,
- 'accepted' => false, 'declined' => false,
- 'alpha' => false, 'alphaDash' => false, 'alphaNum' => false,
- 'min' => null, 'max' => null,
- 'digits' => null, 'digitsMin' => null, 'digitsMax' => null,
- 'in' => null, 'notIn' => null,
- 'regex' => null, 'notRegex' => null,
- 'dateFormat' => null,
- 'after' => null, 'before' => null,
+ 'required' => false, 'filled' => false,
+ 'nullable' => false, 'sometimes' => false,
+ 'string' => false, 'numeric' => false, 'integer' => false, 'integer.strict' => false,
+ 'boolean' => false, 'array' => false, 'email' => false, 'date' => false,
+ 'url' => false, 'ip' => false, 'uuid' => false, 'ulid' => false,
+ 'accepted' => false, 'declined' => false,
+ 'alpha' => false, 'alphaDash' => false, 'alphaNum' => false,
+ 'min' => null, 'max' => null,
+ 'digits' => null, 'digitsMin' => null, 'digitsMax' => null,
+ 'in' => null, 'notIn' => null,
+ 'regex' => null, 'notRegex' => null,
+ 'dateFormat' => null,
+ 'after' => null, 'before' => null,
'afterOrEqual' => null, 'beforeOrEqual' => null,
- 'dateEquals' => null,
+ 'dateEquals' => null,
// Item-context field-reference keys — populated by ItemContextCompiler.
- 'afterField' => null, 'beforeField' => null,
+ 'afterField' => null, 'beforeField' => null,
'afterOrEqualField' => null, 'beforeOrEqualField' => null,
- 'dateEqualsField' => null,
- 'sameField' => null, 'differentField' => null,
- 'gtField' => null, 'gteField' => null,
- 'ltField' => null, 'lteField' => null,
+ 'dateEqualsField' => null,
+ 'sameField' => null, 'differentField' => null,
+ 'gtField' => null, 'gteField' => null,
+ 'ltField' => null, 'lteField' => null,
];
}
@@ -63,7 +65,8 @@ public static function initialConfig(): array
* rules (`same:FIELD`, `gt:FIELD`, etc.) are NOT handled here — see
* `ItemContextCompiler::parsePartWithItemContext` for that path.
*
- * @param array $config
+ * @param array $config
+ *
* @return array|null
*/
public static function parseValuePart(string $part, array $config): ?array
@@ -83,34 +86,34 @@ public static function parseValuePart(string $part, array $config): ?array
}
return match (true) {
- $part === 'integer' => [...$config, 'integer' => true],
- $part === 'integer:strict' => [...$config, 'integer' => true, 'integer.strict' => true],
- $part === 'alpha', $part === 'alpha:ascii' => [...$config, 'alpha' => true],
+ $part === 'integer' => [...$config, 'integer' => true],
+ $part === 'integer:strict' => [...$config, 'integer' => true, 'integer.strict' => true],
+ $part === 'alpha', $part === 'alpha:ascii' => [...$config, 'alpha' => true],
$part === 'alpha_dash', $part === 'alpha_dash:ascii' => [...$config, 'alphaDash' => true],
- $part === 'alpha_num', $part === 'alpha_num:ascii' => [...$config, 'alphaNum' => true],
- $part === 'nullable' => [...$config, 'nullable' => true],
+ $part === 'alpha_num', $part === 'alpha_num:ascii' => [...$config, 'alphaNum' => true],
+ $part === 'nullable' => [...$config, 'nullable' => true],
// 'sometimes' not fast-checkable: distinguishing absent from
// present-null requires presence info the closure doesn't have.
$part === 'sometimes' => null,
- $part === 'bail' => $config,
+ $part === 'bail' => $config,
// Not (int): NumericRule::min() accepts int|float, and Laravel
// compares with BigNumber against the raw parameter. Casting
// `min:2.5` to 2 lets 2.2 through.
- str_starts_with($part, 'min:') => [...$config, 'min' => self::parseSize(substr($part, 4))],
- str_starts_with($part, 'max:') => [...$config, 'max' => self::parseSize(substr($part, 4))],
- str_starts_with($part, 'digits:') => [...$config, 'digits' => (int) substr($part, 7)],
- str_starts_with($part, 'digits_between:') => self::parseDigitsBetween($config, substr($part, 15)),
- str_starts_with($part, 'in:') => [...$config, 'in' => self::parseInValues(substr($part, 3))],
- str_starts_with($part, 'not_in:') => [...$config, 'notIn' => self::parseInValues(substr($part, 7))],
- str_starts_with($part, 'regex:') => [...$config, 'regex' => substr($part, 6)],
- str_starts_with($part, 'not_regex:') => [...$config, 'notRegex' => substr($part, 10)],
- str_starts_with($part, 'date_format:') => [...$config, 'dateFormat' => substr($part, 12)],
- str_starts_with($part, 'date_equals:') => self::parseDateLiteral($config, 'dateEquals', substr($part, 12)),
- str_starts_with($part, 'after_or_equal:') => self::parseDateLiteral($config, 'afterOrEqual', substr($part, 15)),
+ str_starts_with($part, 'min:') => [...$config, 'min' => self::parseSize(substr($part, 4))],
+ str_starts_with($part, 'max:') => [...$config, 'max' => self::parseSize(substr($part, 4))],
+ str_starts_with($part, 'digits:') => [...$config, 'digits' => (int) substr($part, 7)],
+ str_starts_with($part, 'digits_between:') => self::parseDigitsBetween($config, substr($part, 15)),
+ str_starts_with($part, 'in:') => [...$config, 'in' => self::parseInValues(substr($part, 3))],
+ str_starts_with($part, 'not_in:') => [...$config, 'notIn' => self::parseInValues(substr($part, 7))],
+ str_starts_with($part, 'regex:') => [...$config, 'regex' => substr($part, 6)],
+ str_starts_with($part, 'not_regex:') => [...$config, 'notRegex' => substr($part, 10)],
+ str_starts_with($part, 'date_format:') => [...$config, 'dateFormat' => substr($part, 12)],
+ str_starts_with($part, 'date_equals:') => self::parseDateLiteral($config, 'dateEquals', substr($part, 12)),
+ str_starts_with($part, 'after_or_equal:') => self::parseDateLiteral($config, 'afterOrEqual', substr($part, 15)),
str_starts_with($part, 'before_or_equal:') => self::parseDateLiteral($config, 'beforeOrEqual', substr($part, 16)),
- str_starts_with($part, 'after:') => self::parseDateLiteral($config, 'after', substr($part, 6)),
- str_starts_with($part, 'before:') => self::parseDateLiteral($config, 'before', substr($part, 7)),
- default => null,
+ str_starts_with($part, 'after:') => self::parseDateLiteral($config, 'after', substr($part, 6)),
+ str_starts_with($part, 'before:') => self::parseDateLiteral($config, 'before', substr($part, 7)),
+ default => null,
};
}
@@ -119,7 +122,7 @@ public static function parseValuePart(string $part, array $config): ?array
* measure: string length, array count, or numeric value. Without one,
* Laravel infers from runtime type — not fast-checkable.
*
- * @param array $config
+ * @param array $config
*/
public static function validateSizeRuleHasType(array $config): bool
{
@@ -144,7 +147,7 @@ public static function validateSizeRuleHasType(array $config): bool
* loose subset semantics (array_diff), which the scalar closure
* cannot reproduce.
*
- * @param array $config
+ * @param array $config
*/
public static function validateCompilableCombinations(array $config): bool
{
@@ -167,7 +170,8 @@ public static function validateCompilableCombinations(array $config): bool
* Build the value-only closure from a parsed config. ItemContextCompiler
* wraps the result in an item-aware closure for cross-field comparisons.
*
- * @param array $c
+ * @param array $c
+ *
* @return Closure(mixed): bool
*/
public static function buildValueClosure(array $c): Closure
@@ -200,10 +204,21 @@ public static function buildValueClosure(array $c): Closure
$hasInRegex = $in !== null || $notIn !== null || $regex !== null || $notRegex !== null;
return static function (mixed $value) use (
- $required, $nullable, $hasImplicit,
- $isString, $isNumeric, $isInteger, $isArray,
- $min, $max, $hasSize,
- $in, $notIn, $regex, $notRegex, $hasInRegex,
+ $required,
+ $nullable,
+ $hasImplicit,
+ $isString,
+ $isNumeric,
+ $isInteger,
+ $isArray,
+ $min,
+ $max,
+ $hasSize,
+ $in,
+ $notIn,
+ $regex,
+ $notRegex,
+ $hasInRegex,
$checks
): bool {
// Presence gate: Laravel's validateRequired() also rejects
@@ -300,7 +315,8 @@ public static function buildValueClosure(array $c): Closure
}
/**
- * @param array $config
+ * @param array $config
+ *
* @return array
*/
private static function parseDigitsBetween(array $config, string $value): array
@@ -365,7 +381,7 @@ private static function parseSize(string $value): int|float
* through a strict-comparison-friendly operator. Used to detect the
* loose/strict DISAGREEMENT zone the fast path must not decide.
*
- * @param list $haystack
+ * @param list $haystack
*/
private static function matchesLoosely(string $needle, array $haystack): bool
{
@@ -394,7 +410,8 @@ private static function parseInValues(string $values): array
* Parse a date comparison rule. Only compiles when the parameter is a
* date literal (resolvable by strtotime), not a field reference.
*
- * @param array $config
+ * @param array $config
+ *
* @return array|null
*/
private static function parseDateLiteral(array $config, string $key, string $param): ?array
@@ -415,7 +432,7 @@ private static function parseDateLiteral(array $config, string $key, string $par
* Mutates `$checks` in place; closures capture the type literals they
* compare against.
*
- * @param array $c
+ * @param array $c
* @param list $checks
*/
private static function addTypeChecks(array $c, array &$checks): void
@@ -454,7 +471,7 @@ private static function addTypeChecks(array $c, array &$checks): void
/**
* Append format-check closures (email, url, ip, uuid, ulid, alpha-family).
*
- * @param array $c
+ * @param array $c
* @param list $checks
*/
private static function addFormatChecks(array $c, array &$checks): void
@@ -500,7 +517,7 @@ private static function addFormatChecks(array $c, array &$checks): void
/**
* Append date-check closure when any date-literal rule is set.
*
- * @param array $c
+ * @param array $c
* @param list $checks
*/
private static function addDateChecks(array $c, array &$checks): void
@@ -577,7 +594,7 @@ private static function timestampWithinBounds(
/**
* Append digit-check closure(s) when `digits:` or `digits_between:` is set.
*
- * @param array $c
+ * @param array $c
* @param list $checks
*/
private static function addDigitChecks(array $c, array &$checks): void
diff --git a/src/FastCheck/Shared/ItemAwareBranchBuilder.php b/src/FastCheck/Shared/ItemAwareBranchBuilder.php
index 51d9763..b92e491 100644
--- a/src/FastCheck/Shared/ItemAwareBranchBuilder.php
+++ b/src/FastCheck/Shared/ItemAwareBranchBuilder.php
@@ -1,4 +1,6 @@
-label($label);
}
@@ -48,7 +50,7 @@ public static function string(?string $label = null, ?string $message = null): S
public static function numeric(?string $label = null, ?string $message = null): NumericRule
{
- $numericRule = new NumericRule();
+ $numericRule = new NumericRule;
if ($label !== null) {
$numericRule->label($label);
}
@@ -90,7 +92,7 @@ public static function dateTime(?string $label = null, ?string $message = null):
public static function boolean(?string $label = null, ?string $message = null): BooleanRule
{
- $booleanRule = new BooleanRule();
+ $booleanRule = new BooleanRule;
if ($label !== null) {
$booleanRule->label($label);
}
@@ -104,7 +106,7 @@ public static function boolean(?string $label = null, ?string $message = null):
public static function accepted(?string $label = null, ?string $message = null): AcceptedRule
{
- $acceptedRule = new AcceptedRule();
+ $acceptedRule = new AcceptedRule;
if ($label !== null) {
$acceptedRule->label($label);
}
@@ -118,7 +120,7 @@ public static function accepted(?string $label = null, ?string $message = null):
public static function declined(?string $label = null, ?string $message = null): DeclinedRule
{
- $declinedRule = new DeclinedRule();
+ $declinedRule = new DeclinedRule;
if ($label !== null) {
$declinedRule->label($label);
}
@@ -147,7 +149,7 @@ public static function array(Arrayable|array|null $keys = null, ?string $label =
public static function file(?string $label = null, ?string $message = null): FileRule
{
- $fileRule = new FileRule();
+ $fileRule = new FileRule;
if ($label !== null) {
$fileRule->label($label);
}
@@ -182,7 +184,7 @@ public static function email(?string $label = null, bool $defaults = true, ?stri
*/
public static function phone(?string $label = null, ?string $message = null): PhoneRule
{
- $phoneRule = new PhoneRule();
+ $phoneRule = new PhoneRule;
if ($label !== null) {
$phoneRule->label($label);
}
@@ -196,7 +198,7 @@ public static function phone(?string $label = null, ?string $message = null): Ph
public static function image(?string $label = null, ?string $message = null): ImageRule
{
- $imageRule = new ImageRule();
+ $imageRule = new ImageRule;
if ($label !== null) {
$imageRule->label($label);
}
@@ -236,7 +238,7 @@ public static function password(?int $min = null, ?string $label = null, bool $d
*/
public static function url(?string $label = null, ?string $message = null): UrlRule
{
- $urlRule = new UrlRule();
+ $urlRule = new UrlRule;
if ($label !== null) {
$urlRule->label($label);
}
@@ -269,7 +271,7 @@ public static function ulid(?string $label = null, ?string $message = null): Str
*/
public static function ip(?string $label = null, ?string $message = null): IpAddressRule
{
- $ipRule = new IpAddressRule();
+ $ipRule = new IpAddressRule;
if ($label !== null) {
$ipRule->label($label);
}
@@ -299,7 +301,7 @@ public static function ipv6(?string $label = null, ?string $message = null): IpA
*/
public static function macAddress(?string $label = null, ?string $message = null): MacAddressRule
{
- $macRule = new MacAddressRule();
+ $macRule = new MacAddressRule;
if ($label !== null) {
$macRule->label($label);
}
@@ -340,7 +342,7 @@ public static function username(int $min = 3, int $max = 32, ?string $label = nu
*/
public static function subdomain(?string $label = null, ?string $message = null): StringRule
{
- return self::string($label)->rule(new Subdomain(), $message);
+ return self::string($label)->rule(new Subdomain, $message);
}
/** A fully-qualified domain name, internationalised names included. */
@@ -387,13 +389,13 @@ public static function enum(string $type, ?Closure $callback = null, ?string $la
public static function field(?string $label = null): FieldRule
{
- $fieldRule = new FieldRule();
+ $fieldRule = new FieldRule;
return $label !== null ? $fieldRule->label($label) : $fieldRule;
}
/**
- * @param array $rules
+ * @param array $rules
*/
public static function anyOf(array $rules): AnyOf
{
diff --git a/src/FluentRules.php b/src/FluentRules.php
index a6f3d41..c5cd6ac 100644
--- a/src/FluentRules.php
+++ b/src/FluentRules.php
@@ -1,4 +1,6 @@
- $arguments
+ */
+ public function __call(string $name, array $arguments): mixed
+ {
+ // Only reached for names not declared above — i.e. macros, which
+ // Macroable resolves through FluentRule's __callStatic.
+ return FluentRule::__callStatic($name, $arguments);
+ }
+
public function string(?string $label = null, ?string $message = null): StringRule
{
return FluentRule::string($label, $message);
@@ -208,23 +223,10 @@ public function field(?string $label = null): FieldRule
}
/**
- * @param array $rules
+ * @param array $rules
*/
public function anyOf(array $rules): AnyOf
{
return FluentRule::anyOf($rules);
}
-
- /**
- * Forward anything not declared above to {@see FluentRule}, so macros
- * registered on the static factory are also reachable on the instance.
- *
- * @param array $arguments
- */
- public function __call(string $name, array $arguments): mixed
- {
- // Only reached for names not declared above — i.e. macros, which
- // Macroable resolves through FluentRule's __callStatic.
- return FluentRule::__callStatic($name, $arguments);
- }
}
diff --git a/src/FluentValidator.php b/src/FluentValidator.php
index 3bc6958..b114e2a 100644
--- a/src/FluentValidator.php
+++ b/src/FluentValidator.php
@@ -1,10 +1,12 @@
- $data
- * @param array $rules
- * @param array $messages
- * @param array $attributes
+ * @param array $data
+ * @param array $rules
+ * @param array $messages
+ * @param array $attributes
*/
public function __construct(
array $data,
diff --git a/src/HasFluentRules.php b/src/HasFluentRules.php
index 2f08ce8..110bc14 100644
--- a/src/HasFluentRules.php
+++ b/src/HasFluentRules.php
@@ -1,15 +1,17 @@
-schemaExpectsFluentSchema();
+ $hasRules = $this->definesOwnRules();
+
+ /** @var array|RuleSet $rules */
+ $rules = match (true) {
+ $hasSchema && $hasRules => $this->mergeSchemaAndRules(
+ $this->container->call([$this, 'schema']),
+ $this->container->call([$this, 'rules']),
+ ),
+ $hasSchema => $this->container->call([$this, 'schema']),
+ $hasRules => $this->container->call([$this, 'rules']),
+ default => [],
+ };
+
+ /** @var array $data */
+ $data = $this->validationData();
+
+ // Auto-unwrap: rules() may return either a plain array or a RuleSet
+ // (the latter pattern lets callers chain ->only/->except/->merge
+ // before returning, eliminating a terminal ->toArray() call).
+ $ruleSet = $rules instanceof RuleSet ? $rules : RuleSet::from($rules);
+ $prepared = $ruleSet->prepare($data);
+
+ // Pre-exclude rules whose exclude_unless/exclude_if conditions
+ // don't match the actual data. This happens BEFORE the validator
+ // constructor, so excluded rules are never parsed.
+ $preparedRules = $this->preExcludeRules($prepared->rules, $data);
+
+ [$fastChecks, $attributePatternMap] = $this->buildFastCheckMaps($prepared, $preparedRules);
+
+ $messages = $this->messages() + $prepared->messages;
+ $attributes = $this->attributes() + $prepared->attributes;
+
+ // Only use OptimizedValidator when there are fast-checkable wildcard
+ // rules or conditional rules were pre-excluded.
+ if ($fastChecks !== [] || count($preparedRules) < count($prepared->rules)) {
+ $validator = $this->makeOptimizedValidator($factory, $data, $preparedRules, $messages, $attributes);
+ $validator->withFastChecks($fastChecks, $attributePatternMap);
+ } else {
+ /** @var Validator $validator */
+ $validator = $factory->make($data, $preparedRules, $messages, $attributes);
+ }
+
+ if ($prepared->implicitAttributes !== []) {
+ new ReflectionProperty(Validator::class, 'implicitAttributes')
+ ->setValue($validator, $prepared->implicitAttributes);
+ }
+
+ $validator->stopOnFirstFailure($this->stopOnFirstFailure);
+
+ $this->applyBatchPresenceVerifier($validator, $prepared, $preparedRules, $data);
+
+ if ($this->isPrecognitive()) {
+ $validator->setRules(
+ $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders()),
+ );
+ }
+
+ return $validator;
+ }
+
/**
* Whether the request's `schema()` method is the FluentSchema builder hook
* (its first parameter is typed FluentSchema) rather than a coincidental,
@@ -171,89 +247,15 @@ private function definesOwnRules(): bool
return new ReflectionMethod($this, 'rules')->getFileName() !== __FILE__;
}
- protected function createDefaultValidator(ValidationFactory $factory): Validator
- {
- // schema(FluentSchema $rules) — the builder shape — and rules() are both
- // rule sources. schema() detection keys off the FluentSchema-typed first
- // parameter (not just the method name), so an unrelated schema() method
- // (e.g. one returning a JSON/DB schema) is never hijacked. rules() counts
- // only when the consumer declared their own (definesOwnRules()); the
- // trait's fallback rules() just re-exposes schema() and must not trigger
- // a merge. When both are present they are merged rather than one shadowing
- // the other — the more specific declaration wins each shared field (see
- // mergeSchemaAndRules()), so an abstract base or trait can supply one
- // method and a concrete request the other. Each source is resolved by the
- // container.
- $hasSchema = method_exists($this, 'schema') && $this->schemaExpectsFluentSchema();
- $hasRules = $this->definesOwnRules();
-
- /** @var array|RuleSet $rules */
- $rules = match (true) {
- $hasSchema && $hasRules => $this->mergeSchemaAndRules(
- $this->container->call([$this, 'schema']),
- $this->container->call([$this, 'rules']),
- ),
- $hasSchema => $this->container->call([$this, 'schema']),
- $hasRules => $this->container->call([$this, 'rules']),
- default => [],
- };
-
- /** @var array $data */
- $data = $this->validationData();
-
- // Auto-unwrap: rules() may return either a plain array or a RuleSet
- // (the latter pattern lets callers chain ->only/->except/->merge
- // before returning, eliminating a terminal ->toArray() call).
- $ruleSet = $rules instanceof RuleSet ? $rules : RuleSet::from($rules);
- $prepared = $ruleSet->prepare($data);
-
- // Pre-exclude rules whose exclude_unless/exclude_if conditions
- // don't match the actual data. This happens BEFORE the validator
- // constructor, so excluded rules are never parsed.
- $preparedRules = $this->preExcludeRules($prepared->rules, $data);
-
- [$fastChecks, $attributePatternMap] = $this->buildFastCheckMaps($prepared, $preparedRules);
-
- $messages = $this->messages() + $prepared->messages;
- $attributes = $this->attributes() + $prepared->attributes;
-
- // Only use OptimizedValidator when there are fast-checkable wildcard
- // rules or conditional rules were pre-excluded.
- if ($fastChecks !== [] || count($preparedRules) < count($prepared->rules)) {
- $validator = $this->makeOptimizedValidator($factory, $data, $preparedRules, $messages, $attributes);
- $validator->withFastChecks($fastChecks, $attributePatternMap);
- } else {
- /** @var Validator $validator */
- $validator = $factory->make($data, $preparedRules, $messages, $attributes);
- }
-
- if ($prepared->implicitAttributes !== []) {
- new ReflectionProperty(Validator::class, 'implicitAttributes')
- ->setValue($validator, $prepared->implicitAttributes);
- }
-
- $validator->stopOnFirstFailure($this->stopOnFirstFailure);
-
- $this->applyBatchPresenceVerifier($validator, $prepared, $preparedRules, $data);
-
- if ($this->isPrecognitive()) {
- $validator->setRules(
- $this->filterPrecognitiveRules($validator->getRulesWithoutPlaceholders())
- );
- }
-
- return $validator;
- }
-
/**
* Create an OptimizedValidator with the same setup the factory provides
* (extensions, container, presence verifier, excludeUnvalidatedArrayKeys)
* without mutating the shared factory's resolver. Octane-safe.
*
- * @param array $data
- * @param array $rules
- * @param array $messages
- * @param array $attributes
+ * @param array $data
+ * @param array $rules
+ * @param array $messages
+ * @param array $attributes
*/
private function makeOptimizedValidator(
ValidationFactory $factory,
diff --git a/src/HasFluentValidation.php b/src/HasFluentValidation.php
index 361a47e..9bf9cf5 100644
--- a/src/HasFluentValidation.php
+++ b/src/HasFluentValidation.php
@@ -1,4 +1,6 @@
-|null $rules
+ * @param array $messages
+ * @param array $attributes
+ *
+ * @return array{0: array|null, 1: array, 2: array}
+ */
+ protected function compileFluentRules(?array $rules, array $messages, array $attributes): array
+ {
+ // If no rules passed (null), resolve from rules() method or $rules
+ // property. An explicitly empty array `[]` means "caller wants no
+ // validation" — it must NOT fall back to the component's default
+ // rules, matching Livewire's $this->validate([]) behavior.
+ $ruleSet = $rules !== null ? RuleSet::from($rules) : $this->resolveFluentRuleSource();
+
+ if ($ruleSet->isEmpty()) {
+ return [$rules, $messages, $attributes];
+ }
+
+ $hasFluentRules = $ruleSet->hasObjectRules();
+
+ if (! $hasFluentRules) {
+ return [$rules, $messages, $attributes];
+ }
+
+ $flatRules = $ruleSet->flattenRules();
+
+ // Use Livewire's data resolution when available — it correctly
+ // handles model-bound properties and nested data for wildcard expansion.
+ // For array source, preserve the exact shape callers passed previously.
+ $rawData = method_exists($this, 'getDataForValidation') // @phpstan-ignore function.alreadyNarrowedType
+ ? $this->getDataForValidation($flatRules)
+ : (method_exists($this, 'all') ? $this->all() : []); // @phpstan-ignore function.alreadyNarrowedType
+
+ // Unwrap Eloquent models to arrays so WildcardExpander can traverse them.
+ if (method_exists($this, 'unwrapDataForValidation')) { // @phpstan-ignore function.alreadyNarrowedType
+ $rawData = $this->unwrapDataForValidation($rawData);
+ }
+
+ $data = $this->toNullableArray($rawData) ?? [];
+
+ $prepared = $ruleSet->prepare($data, $flatRules);
+
+ return [
+ $prepared->rules,
+ array_merge($prepared->messages, $messages),
+ array_merge($prepared->attributes, $attributes),
+ ];
+ }
+
/**
* Merge with Livewire's rulesFromOutside (same as parent::getRules()).
*
- * @param array $rules
+ * @param array $rules
+ *
* @return array
*/
private function mergeRulesFromOutside(array $rules): array
@@ -246,56 +302,4 @@ private function toStringMap(mixed $value): array
return $result;
}
-
- /**
- * Compile FluentRule objects to native format, expand wildcards
- * against actual data, and extract labels/messages.
- *
- * @param array|null $rules
- * @param array $messages
- * @param array $attributes
- * @return array{0: array|null, 1: array, 2: array}
- */
- protected function compileFluentRules(?array $rules, array $messages, array $attributes): array
- {
- // If no rules passed (null), resolve from rules() method or $rules
- // property. An explicitly empty array `[]` means "caller wants no
- // validation" — it must NOT fall back to the component's default
- // rules, matching Livewire's $this->validate([]) behavior.
- $ruleSet = $rules !== null ? RuleSet::from($rules) : $this->resolveFluentRuleSource();
-
- if ($ruleSet->isEmpty()) {
- return [$rules, $messages, $attributes];
- }
-
- $hasFluentRules = $ruleSet->hasObjectRules();
-
- if (! $hasFluentRules) {
- return [$rules, $messages, $attributes];
- }
-
- $flatRules = $ruleSet->flattenRules();
-
- // Use Livewire's data resolution when available — it correctly
- // handles model-bound properties and nested data for wildcard expansion.
- // For array source, preserve the exact shape callers passed previously.
- $rawData = method_exists($this, 'getDataForValidation') // @phpstan-ignore function.alreadyNarrowedType
- ? $this->getDataForValidation($flatRules)
- : (method_exists($this, 'all') ? $this->all() : []); // @phpstan-ignore function.alreadyNarrowedType
-
- // Unwrap Eloquent models to arrays so WildcardExpander can traverse them.
- if (method_exists($this, 'unwrapDataForValidation')) { // @phpstan-ignore function.alreadyNarrowedType
- $rawData = $this->unwrapDataForValidation($rawData);
- }
-
- $data = $this->toNullableArray($rawData) ?? [];
-
- $prepared = $ruleSet->prepare($data, $flatRules);
-
- return [
- $prepared->rules,
- array_merge($prepared->messages, $messages),
- array_merge($prepared->attributes, $attributes),
- ];
- }
}
diff --git a/src/HasFluentValidationForFilament.php b/src/HasFluentValidationForFilament.php
index 44a3eca..e6770d0 100644
--- a/src/HasFluentValidationForFilament.php
+++ b/src/HasFluentValidationForFilament.php
@@ -1,4 +1,6 @@
- $itemRules
+ * @param array $itemRules
*/
public static function hasAny(array $itemRules): bool
{
@@ -64,7 +57,7 @@ public static function hasAny(array $itemRules): bool
* Subclasses' `apply()` methods bind their per-call context (field,
* itemData, etc.) into a closure and hand it here.
*
- * @param Closure(string): ?string $rewriteOne
+ * @param Closure(string): ?string $rewriteOne
*/
protected static function applyTemplate(mixed $rule, Closure $rewriteOne): mixed
{
@@ -112,7 +105,7 @@ protected static function applyTemplate(mixed $rule, Closure $rewriteOne): mixed
* rewrite path doesn't bypass a `{field}.{rule}`-style override at
* message-formatting time. Identical across all conditional reducers.
*
- * @param array $itemMessages
+ * @param array $itemMessages
*/
protected static function hasCustomMessage(string $field, string $ruleName, array $itemMessages): bool
{
@@ -169,4 +162,13 @@ protected static function stringContainsRule(string $rule): bool
return static::parse($rule) !== null;
}
+
+ /**
+ * Parse a single rule string into `[ruleName, rawParam]` when it matches
+ * one of the family's recognised conditionals. Subclasses define the
+ * recognised set; longest-prefix order is each subclass's responsibility.
+ *
+ * @return array{0: string, 1: string}|null
+ */
+ abstract protected static function parse(string $rule): ?array;
}
diff --git a/src/Internal/BatchLimitRemap.php b/src/Internal/BatchLimitRemap.php
index ee0a207..653633a 100644
--- a/src/Internal/BatchLimitRemap.php
+++ b/src/Internal/BatchLimitRemap.php
@@ -1,11 +1,13 @@
- $values
- * @param array $wheres
+ * @param array $values
+ * @param array $wheres
+ *
* @return array
*/
public static function run(
@@ -34,7 +37,8 @@ public static function run(
$chunks = [];
foreach (array_chunk($values, self::CHUNK_SIZE) as $chunk) {
- $query = ($connection !== null
+ $query = (
+ $connection !== null
? DB::connection($connection)->table($table)
: DB::table($table)
)->useWritePdo();
@@ -59,7 +63,7 @@ public static function run(
/**
* Apply a single Laravel-style where condition to the query.
*
- * @param array{column: string, value: string} $where
+ * @param array{column: string, value: string} $where
*/
private static function applyWhere(Builder $query, array $where): void
{
diff --git a/src/Internal/BatchRuleShape.php b/src/Internal/BatchRuleShape.php
index 85adf93..feb86aa 100644
--- a/src/Internal/BatchRuleShape.php
+++ b/src/Internal/BatchRuleShape.php
@@ -1,4 +1,6 @@
-, ignore: mixed, idColumn: string}|null $meta
+ * @param array{connection: string|null, table: string, column: string, wheres: array, ignore: mixed, idColumn: string}|null $meta
*/
public static function of(?array $meta): string
{
diff --git a/src/Internal/ConditionalEvaluationPhase.php b/src/Internal/ConditionalEvaluationPhase.php
index a0002e5..088eccc 100644
--- a/src/Internal/ConditionalEvaluationPhase.php
+++ b/src/Internal/ConditionalEvaluationPhase.php
@@ -1,4 +1,6 @@
- */
private array $valueCache = [];
+ /**
+ * Replace wildcards in a condition field reference with the concrete key
+ * at the SAME dot-position in the attribute path — mirroring how Laravel
+ * resolves a dependent wildcard reference against the attribute under
+ * validation. E.g. "interactions.*.type" against "interactions.5.style.top"
+ * → "interactions.5.type", and "items.*.type" against "items.foo.rows.0.x"
+ * → "items.foo.type" (associative keys handled, not just numeric indices).
+ *
+ * A `*` whose position has no corresponding attribute segment is left in
+ * place, so callers can detect the unresolved wildcard and defer.
+ *
+ * Pure — exposed `static` so tests can pin it without instantiating.
+ */
+ public static function resolveWildcard(string $attribute, string $conditionField): string
+ {
+ if (! str_contains($conditionField, '*')) {
+ return $conditionField;
+ }
+
+ $attributeSegments = explode('.', $attribute);
+ $fieldSegments = explode('.', $conditionField);
+
+ foreach ($fieldSegments as $i => $segment) {
+ if ($segment === '*') {
+ $fieldSegments[$i] = $attributeSegments[$i] ?? '*';
+ }
+ }
+
+ return implode('.', $fieldSegments);
+ }
+
/**
* Build a flat map of attributes that carry exclude_unless / exclude_if
* tuples, paired with the parsed tuple data. Pure — depends only on the
* rule shape. Accepts the Validator's untyped `$rules` array directly;
* non-string keys are filtered inside.
*
- * @param array $rules
+ * @param array $rules
+ *
* @return array}>>
*/
public function indexConditionalAttrs(array $rules): array
@@ -64,9 +98,9 @@ public function indexConditionalAttrs(array $rules): array
* because `Validator::getValue()` is protected — the closure must be
* constructed inside the validator subclass to capture scope.
*
- * @param list}> $tuples
- * @param Closure(string): mixed $getValue
- * @param array $rules The validator's parsed rules, read
+ * @param list}> $tuples
+ * @param Closure(string): mixed $getValue
+ * @param array $rules The validator's parsed rules, read
* only to learn whether a dependent
* is declared `boolean`. Defaults to
* none, which simply skips the
@@ -133,35 +167,4 @@ public function evaluate(string $attribute, array $tuples, Closure $getValue, ar
return $deferred ? ConditionalVerdict::Defer : ConditionalVerdict::NotExcluded;
}
-
- /**
- * Replace wildcards in a condition field reference with the concrete key
- * at the SAME dot-position in the attribute path — mirroring how Laravel
- * resolves a dependent wildcard reference against the attribute under
- * validation. E.g. "interactions.*.type" against "interactions.5.style.top"
- * → "interactions.5.type", and "items.*.type" against "items.foo.rows.0.x"
- * → "items.foo.type" (associative keys handled, not just numeric indices).
- *
- * A `*` whose position has no corresponding attribute segment is left in
- * place, so callers can detect the unresolved wildcard and defer.
- *
- * Pure — exposed `static` so tests can pin it without instantiating.
- */
- public static function resolveWildcard(string $attribute, string $conditionField): string
- {
- if (! str_contains($conditionField, '*')) {
- return $conditionField;
- }
-
- $attributeSegments = explode('.', $attribute);
- $fieldSegments = explode('.', $conditionField);
-
- foreach ($fieldSegments as $i => $segment) {
- if ($segment === '*') {
- $fieldSegments[$i] = $attributeSegments[$i] ?? '*';
- }
- }
-
- return implode('.', $fieldSegments);
- }
}
diff --git a/src/Internal/ConditionalValueMatcher.php b/src/Internal/ConditionalValueMatcher.php
index abbd83f..45e179b 100644
--- a/src/Internal/ConditionalValueMatcher.php
+++ b/src/Internal/ConditionalValueMatcher.php
@@ -1,4 +1,6 @@
- $rawValues The rule's value parameters.
- * @param array $itemData
- * @param array $itemRules Item-scoped rules, for the `boolean` declaration check.
+ * @param list $rawValues The rule's value parameters.
+ * @param array $itemData
+ * @param array $itemRules Item-scoped rules, for the `boolean` declaration check.
*/
public static function matches(string $depPath, array $rawValues, array $itemData, array $itemRules): bool
{
@@ -42,7 +44,7 @@ public static function matches(string $depPath, array $rawValues, array $itemDat
* through a validator's protected `getValue()`), so they don't re-resolve
* via `data_get`.
*
- * @param list $rawValues
+ * @param list $rawValues
*/
public static function matchesValue(mixed $other, array $rawValues, bool $dependentHasBooleanRule = false): bool
{
@@ -60,6 +62,35 @@ public static function matchesValue(mixed $other, array $rawValues, bool $depend
: self::scalarLooseIn($other, $values);
}
+ /**
+ * Does the dependent field's rule set contain a `boolean` marker?
+ *
+ * Public because both the item-scoped path ({@see matches()}) and the
+ * top-level path ({@see ConditionalEvaluationPhase::evaluate()}) need the
+ * same answer, and Laravel applies the conversion in both.
+ * Best-effort check against the item-scoped rule set — mirrors Laravel's
+ * `shouldConvertToBoolean` which reads `$this->rules[$parameter]`.
+ *
+ * `array-key`, not `string`: the top-level caller hands over the
+ * validator's own `$rules`, which Laravel types as a bare array.
+ *
+ * @param array $itemRules
+ */
+ public static function dependentHasBooleanRule(string $depPath, array $itemRules): bool
+ {
+ $rules = $itemRules[$depPath] ?? null;
+
+ if (is_string($rules)) {
+ return in_array('boolean', explode('|', $rules), true);
+ }
+
+ if (! is_array($rules)) {
+ return false;
+ }
+
+ return in_array('boolean', $rules, true);
+ }
+
/**
* Stand-in for PHP's loose `in_array($other, $values, false)` with `$other`
* a non-bool/non-null scalar — phpstan-strict-rules forbids loose `in_array`,
@@ -72,7 +103,7 @@ public static function matchesValue(mixed $other, array $rawValues, bool $depend
* - any other scalar compares by string coercion, covering numeric-string ↔
* numeric (`'1'` ↔ `1`).
*
- * @param list $values
+ * @param list $values
*/
private static function scalarLooseIn(int|float|string $other, array $values): bool
{
@@ -97,14 +128,15 @@ private static function scalarLooseIn(int|float|string $other, array $values): b
* value is already a bool; null conversion when the resolved value is
* null. Order matters — bool first, then null.
*
- * @param list $values
+ * @param list $values
+ *
* @return list
*/
private static function convertValues(array $values, mixed $other, bool $dependentHasBooleanRule): array
{
if (is_bool($other) || $dependentHasBooleanRule) {
$values = array_map(static fn (mixed $v): mixed => match ($v) {
- 'true' => true,
+ 'true' => true,
'false' => false,
default => $v,
}, $values);
@@ -119,33 +151,4 @@ private static function convertValues(array $values, mixed $other, bool $depende
return $values;
}
-
- /**
- * Does the dependent field's rule set contain a `boolean` marker?
- *
- * Public because both the item-scoped path ({@see matches()}) and the
- * top-level path ({@see ConditionalEvaluationPhase::evaluate()}) need the
- * same answer, and Laravel applies the conversion in both.
- * Best-effort check against the item-scoped rule set — mirrors Laravel's
- * `shouldConvertToBoolean` which reads `$this->rules[$parameter]`.
- *
- * `array-key`, not `string`: the top-level caller hands over the
- * validator's own `$rules`, which Laravel types as a bare array.
- *
- * @param array $itemRules
- */
- public static function dependentHasBooleanRule(string $depPath, array $itemRules): bool
- {
- $rules = $itemRules[$depPath] ?? null;
-
- if (is_string($rules)) {
- return in_array('boolean', explode('|', $rules), true);
- }
-
- if (! is_array($rules)) {
- return false;
- }
-
- return in_array('boolean', $rules, true);
- }
}
diff --git a/src/Internal/ConditionalVerdict.php b/src/Internal/ConditionalVerdict.php
index 5903dfb..ec96e8d 100644
--- a/src/Internal/ConditionalVerdict.php
+++ b/src/Internal/ConditionalVerdict.php
@@ -1,4 +1,6 @@
- $rules Field (or expanded attribute) → rules
+ * @param array $rules Field (or expanded attribute) → rules
+ *
* @return array
*/
public static function findPoisonedTableColumns(array $rules): array
diff --git a/src/Internal/ExcludeConditionExtractor.php b/src/Internal/ExcludeConditionExtractor.php
index fd7b019..6f1b3d9 100644
--- a/src/Internal/ExcludeConditionExtractor.php
+++ b/src/Internal/ExcludeConditionExtractor.php
@@ -1,4 +1,6 @@
- $segment
+ * @param array