[Array](#rule-array)
+[Array Keys](#rule-array-keys)
[Between](#rule-between)
[Contains](#rule-contains)
[Doesnt Contain](#rule-doesnt-contain)
@@ -1659,6 +1660,21 @@ Validator::make($input, [
In general, you should always specify the array keys that are allowed to be present within your array.
+
+#### array_keys:_foo_,_bar_,...
+
+The field under validation must be a PHP `array` whose keys are all included in the given list. At least one key must be provided:
+
+```php
+'user' => ['array_keys:name,username'],
+```
+
+For convenience, you may use the `Rule::arrayKeys` method:
+
+```php
+'user' => [Rule::arrayKeys('name', 'username')],
+```
+
#### ascii
diff --git a/src/translation/lang/en/validation.php b/src/translation/lang/en/validation.php
index 1c3c27386..32f05c552 100644
--- a/src/translation/lang/en/validation.php
+++ b/src/translation/lang/en/validation.php
@@ -24,6 +24,7 @@
'alpha_num' => 'The :attribute field must only contain letters and numbers.',
'any_of' => 'The :attribute field is invalid.',
'array' => 'The :attribute field must be an array.',
+ 'array_keys' => 'The :attribute field must only contain the following keys: :values.',
'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
'base64' => 'The :attribute field must be a valid Base64 string.',
'before' => 'The :attribute field must be a date before :date.',
diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php
index b1926bfaf..6e7130819 100644
--- a/src/validation/src/Concerns/FormatsMessages.php
+++ b/src/validation/src/Concerns/FormatsMessages.php
@@ -20,10 +20,6 @@ trait FormatsMessages
*/
protected function getMessage(string $attribute, string $rule): string
{
- $attributeWithPlaceholders = $attribute;
-
- $attribute = $this->replacePlaceholderInString($attribute);
-
$inlineMessage = $this->getInlineMessage($attribute, $rule);
// First we will retrieve the custom message for the validation rule if one
@@ -35,7 +31,7 @@ protected function getMessage(string $attribute, string $rule): string
$lowerRule = Str::snake($rule);
- $customKey = "validation.custom.{$attribute}.{$lowerRule}";
+ $customKey = 'validation.custom.' . $this->replacePlaceholderInString($attribute) . ".{$lowerRule}";
$customMessage = $this->getCustomMessageFromTranslator(
in_array($rule, $this->sizeRules, true)
@@ -54,7 +50,7 @@ protected function getMessage(string $attribute, string $rule): string
// specific error message for the type of attribute being validated such
// as a number, file or string which all have different message types.
if (in_array($rule, $this->sizeRules, true)) {
- return $this->getSizeMessage($attributeWithPlaceholders, $rule);
+ return $this->getSizeMessage($attribute, $rule);
}
// Finally, if no developer specified messages have been set, and no other
@@ -98,10 +94,12 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
{
$source = $source ?: $this->customMessages;
- $keys = ["{$attribute}.{$lowerRule}", $lowerRule, $attribute];
+ $displayAttribute = $this->replacePlaceholderInString($attribute);
+
+ $keys = ["{$displayAttribute}.{$lowerRule}", $lowerRule, $displayAttribute];
if ($this->getAttributeType($attribute) !== 'file') {
- $shortRule = "{$attribute}." . Str::snake(class_basename($lowerRule));
+ $shortRule = "{$displayAttribute}." . Str::snake(class_basename($lowerRule));
if (! in_array($shortRule, $keys)) {
$keys[] = $shortRule;
@@ -132,7 +130,7 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
if (Str::is($sourceKey, $key)) {
$message = $source[$sourceKey];
- if ($sourceKey === $attribute && is_array($message)) {
+ if ($sourceKey === $displayAttribute && is_array($message)) {
return $message[$lowerRule] ?? null;
}
@@ -223,6 +221,9 @@ protected function getAttributeType(string $attribute): string
/**
* Replace all error message place-holders with actual values.
+ *
+ * Attribute paths and dependent field parameters retain their encoded literal
+ * dots and asterisks until display or delivery to a registered custom replacer.
*/
public function makeReplacements(string $message, string $attribute, string $rule, array $parameters): string
{
@@ -237,7 +238,13 @@ public function makeReplacements(string $message, string $attribute, string $rul
$message = $this->replaceOrdinalPositionPlaceholder($message, $attribute);
if (isset($this->replacers[Str::snake($rule)])) {
- return $this->callReplacer($message, $attribute, Str::snake($rule), $parameters, $this);
+ return $this->callReplacer(
+ $message,
+ $this->replacePlaceholderInString($attribute),
+ Str::snake($rule),
+ $this->dependsOnOtherFields($rule) ? $this->replaceDotPlaceholderInParameters($parameters) : $parameters,
+ $this
+ );
}
if (method_exists($this, $replacer = "replace{$rule}")) {
return $this->{$replacer}($message, $attribute, $rule, $parameters);
@@ -253,9 +260,12 @@ public function getDisplayableAttribute(string $attribute): string
{
$primaryAttribute = $this->getPrimaryAttribute($attribute);
+ // Resolve wildcard metadata before decoding a literal dot into a path separator.
$expectedAttributes = $attribute !== $primaryAttribute
- ? [$attribute, $primaryAttribute]
- : [$attribute];
+ ? [$this->replacePlaceholderInString($attribute), $this->replacePlaceholderInString($primaryAttribute)]
+ : [$this->replacePlaceholderInString($attribute)];
+
+ $attribute = $expectedAttributes[0];
foreach ($expectedAttributes as $name) {
// The developer may dynamically specify the array of custom attributes on this
@@ -453,6 +463,8 @@ protected function replaceInputPlaceholder(string $message, string $attribute):
*/
public function getDisplayableValue(string $attribute, mixed $value): string
{
+ $attribute = $this->replacePlaceholderInString($attribute);
+
if (isset($this->customValues[$attribute][$value])) {
return $this->customValues[$attribute][$value];
}
diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php
index 01bbf3b98..c49f2d743 100644
--- a/src/validation/src/Concerns/ReplacesAttributes.php
+++ b/src/validation/src/Concerns/ReplacesAttributes.php
@@ -274,6 +274,29 @@ protected function replaceInArrayKeys(string $message, string $attribute, string
return $this->replaceIn($message, $attribute, $rule, $parameters);
}
+ /**
+ * Replace all place-holders for the array_keys rule.
+ *
+ * @param array
$parameters
+ */
+ protected function replaceArrayKeys(string $message, string $attribute, string $rule, array $parameters): string
+ {
+ $message = $this->replaceIn($message, $attribute, $rule, $parameters);
+
+ $value = $this->getValue($attribute);
+
+ $unexpected = is_array($value)
+ ? array_keys(array_diff_key($value, $this->acceptedArrayKeys($parameters)))
+ : [];
+
+ $unexpected = array_map(
+ fn (int|string $key): string => $this->getDisplayableValue($attribute, $this->replacePlaceholderInString((string) $key)),
+ $unexpected,
+ );
+
+ return $this->replaceWhileKeepingCase($message, ['unexpected' => implode(', ', $unexpected)]);
+ }
+
/**
* Replace all place-holders for the required_array_keys rule.
*
diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php
index e2b968e00..6b9836672 100644
--- a/src/validation/src/Concerns/ValidatesAttributes.php
+++ b/src/validation/src/Concerns/ValidatesAttributes.php
@@ -415,7 +415,37 @@ public function validateArray(string $attribute, mixed $value, array $parameters
return true;
}
- return empty(array_diff_key($value, array_fill_keys($parameters, '')));
+ return empty(array_diff_key($value, $this->acceptedArrayKeys($parameters)));
+ }
+
+ /**
+ * Get the accepted literal and encoded array keys.
+ *
+ * @param array $parameters
+ * @return array
+ */
+ protected function acceptedArrayKeys(array $parameters): array
+ {
+ // Validator data has encoded keys, while direct validation calls may supply literal keys.
+ $keys = array_fill_keys($parameters, '');
+
+ return $keys + ValidationData::encodeKeys($keys);
+ }
+
+ /**
+ * Validate that an array does not contain any keys other than the given keys.
+ *
+ * @param array $parameters
+ */
+ public function validateArrayKeys(string $attribute, mixed $value, array $parameters): bool
+ {
+ $this->requireParameterCount(1, $parameters, 'array_keys');
+
+ if (! is_array($value)) {
+ return false;
+ }
+
+ return empty(array_diff_key($value, $this->acceptedArrayKeys($parameters)));
}
/**
@@ -437,8 +467,8 @@ public function validateRequiredArrayKeys(string $attribute, mixed $value, array
return false;
}
- foreach ($parameters as $param) {
- if (! Arr::exists($value, $param)) {
+ foreach ($parameters as $parameter) {
+ if (! Arr::exists($value, $parameter) && ! Arr::exists($value, ValidationData::encodeKey((string) $parameter))) {
return false;
}
}
@@ -1495,8 +1525,8 @@ public function validateInArrayKeys(string $attribute, mixed $value, array $para
return false;
}
- foreach ($parameters as $param) {
- if (Arr::exists($value, $param)) {
+ foreach ($parameters as $parameter) {
+ if (Arr::exists($value, $parameter) || Arr::exists($value, ValidationData::encodeKey((string) $parameter))) {
return true;
}
}
diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php
index 480bc9e45..299303b5f 100644
--- a/src/validation/src/Rule.php
+++ b/src/validation/src/Rule.php
@@ -13,6 +13,7 @@
use Hypervel\Support\Arr;
use Hypervel\Support\Traits\Macroable;
use Hypervel\Validation\Rules\AnyOf;
+use Hypervel\Validation\Rules\ArrayKeys;
use Hypervel\Validation\Rules\ArrayRule;
use Hypervel\Validation\Rules\Can;
use Hypervel\Validation\Rules\Contains;
@@ -88,6 +89,14 @@ public static function array(mixed $keys = null): ArrayRule
return new ArrayRule(...func_get_args());
}
+ /**
+ * Get an array keys rule builder instance.
+ */
+ public static function arrayKeys(array|Arrayable|UnitEnum|int|string $keys): ArrayKeys
+ {
+ return new ArrayKeys(...func_get_args());
+ }
+
/**
* Create a new nested rule set.
*/
diff --git a/src/validation/src/Rules/ArrayKeys.php b/src/validation/src/Rules/ArrayKeys.php
new file mode 100644
index 000000000..8cd100adb
--- /dev/null
+++ b/src/validation/src/Rules/ArrayKeys.php
@@ -0,0 +1,44 @@
+toArray();
+ }
+
+ $this->keys = is_array($keys) ? $keys : func_get_args();
+ }
+
+ /**
+ * Convert the rule to a validation string.
+ */
+ public function __toString(): string
+ {
+ $keys = array_map(
+ static fn ($key) => enum_value($key),
+ $this->keys,
+ );
+
+ return 'array_keys:' . implode(',', $keys);
+ }
+}
diff --git a/src/validation/src/ValidationData.php b/src/validation/src/ValidationData.php
index 8a1945ba9..62129a0f5 100644
--- a/src/validation/src/ValidationData.php
+++ b/src/validation/src/ValidationData.php
@@ -42,6 +42,20 @@ public static function decodeAttribute(string $attribute): string
);
}
+ /**
+ * Encode literal dots and asterisks in a data key.
+ */
+ public static function encodeKey(int|string $key): string
+ {
+ $placeholderHash = static::placeholderHash();
+
+ return str_replace(
+ ['.', '*'],
+ ['__dot__' . $placeholderHash, '__asterisk__' . $placeholderHash],
+ (string) $key,
+ );
+ }
+
/**
* Encode literal dots and asterisks in data keys.
*/
@@ -55,6 +69,7 @@ public static function encodeKeys(array $data): array
$value = static::encodeKeys($value);
}
+ // Keep encoding inline to avoid a method and hash lookup for every input key.
$key = str_replace(
['.', '*'],
['__dot__' . $placeholderHash, '__asterisk__' . $placeholderHash],
diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php
index 85d168e38..ab90b75d8 100644
--- a/src/validation/src/Validator.php
+++ b/src/validation/src/Validator.php
@@ -381,8 +381,6 @@ protected function replacePlaceholderInString(string $value): string
*/
protected function replaceDotPlaceholderInParameters(array $parameters): array
{
- // Inline date-comparison failures bypass validateAttribute(), so their raw
- // scalar parameters need the same string normalization as delegated rules.
return array_map(
static fn (mixed $field): string => ValidationData::replacePlaceholderInString((string) $field),
$parameters,
@@ -1307,6 +1305,8 @@ protected function hasNotFailedPreviousRuleIfPresenceRule(object|string $rule, s
*/
protected function validateUsingCustomRule(string $attribute, mixed $value, Rule $rule): void
{
+ $attributeWithPlaceholders = $attribute;
+
$originalAttribute = $this->replacePlaceholderInString($attribute);
$attribute = match (true) {
@@ -1339,7 +1339,7 @@ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule
$this->failedRules[$originalAttribute][$ruleClass] = [];
- $messages = $this->getFromLocalArray($originalAttribute, $ruleClass) ?? $rule->message();
+ $messages = $this->getFromLocalArray($attributeWithPlaceholders, $ruleClass) ?? $rule->message();
$messages = $messages ? (array) $messages : [$ruleClass];
@@ -1348,7 +1348,7 @@ protected function validateUsingCustomRule(string $attribute, mixed $value, Rule
$this->messages->add($key, $this->makeReplacements(
$message,
- $key,
+ $key === $originalAttribute ? $attributeWithPlaceholders : $key,
$ruleClass,
[]
));
@@ -1401,17 +1401,21 @@ public function addFailure(string $attribute, string $rule, array $parameters =
}
if ($this->dependsOnOtherFields($rule)) {
- $parameters = $this->replaceDotPlaceholderInParameters($parameters);
+ // Inline checks may supply scalar parameters; retain their encoded field paths.
+ $parameters = array_map(strval(...), $parameters);
}
+ // Message lookups must distinguish literal dots and asterisks from path syntax.
$this->messages->add($attribute, $this->makeReplacements(
$this->getMessage($attributeWithPlaceholders, $rule),
- $attribute,
+ $attributeWithPlaceholders,
$rule,
$parameters
));
- $this->failedRules[$attribute][$rule] = $parameters;
+ $this->failedRules[$attribute][$rule] = $this->dependsOnOtherFields($rule)
+ ? $this->replaceDotPlaceholderInParameters($parameters)
+ : $parameters;
}
/**
diff --git a/tests/Validation/ValidationArrayKeysRuleTest.php b/tests/Validation/ValidationArrayKeysRuleTest.php
new file mode 100644
index 000000000..68cc9682f
--- /dev/null
+++ b/tests/Validation/ValidationArrayKeysRuleTest.php
@@ -0,0 +1,184 @@
+assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+
+ $rule = Rule::arrayKeys(['key_1', 'key_2', 'key_3']);
+
+ $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+
+ $rule = Rule::arrayKeys(collect(['key_1', 'key_2', 'key_3']));
+
+ $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+
+ $rule = Rule::arrayKeys([ArrayKeys::key_1, ArrayKeys::key_2, ArrayKeys::key_3]);
+
+ $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+
+ $rule = Rule::arrayKeys([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2, ArrayKeysBacked::Key3]);
+
+ $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+
+ $rule = Rule::arrayKeys([1, 2, 3]);
+
+ $this->assertSame('array_keys:1,2,3', (string) $rule);
+ }
+
+ public function testArrayKeysValidation(): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_3' => 'baz']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]);
+ $this->assertTrue($v->fails());
+
+ $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::arrayKeys(['key_1'])]);
+ $this->assertTrue($v->fails());
+
+ $v = new Validator($trans, ['foo' => 'not an array'], ['foo' => Rule::arrayKeys(['key_1'])]);
+ $this->assertTrue($v->fails());
+
+ $v = new Validator($trans, ['foo' => (object) ['key_1' => 'bar']], ['foo' => Rule::arrayKeys(['key_1'])]);
+ $this->assertTrue($v->fails());
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_2' => '']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => ['key_1' => []]], ['foo' => Rule::arrayKeys(['key_1'])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => []], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => ['bar', 'baz']], ['foo' => Rule::arrayKeys([0, 1])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => (string) Rule::arrayKeys(['key_1'])]);
+ $this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['foo' => null], ['foo' => ['nullable', Rule::arrayKeys(['key_1'])]]);
+ $this->assertTrue($v->passes());
+ }
+
+ public function testArrayKeysValidationRequiresAtLeastOneKey(): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar']], ['foo' => 'array_keys']);
+
+ $this->expectExceptionObject(new InvalidArgumentException('Validation rule array_keys requires at least 1 parameters.'));
+
+ $v->passes();
+ }
+
+ public function testArrayKeysValidationErrorMessage(): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $trans->addLines([
+ 'validation.array_keys' => 'The :attribute field must only contain the following keys: :values.',
+ ], 'en');
+
+ $v = new Validator($trans, ['foo' => ['key_1' => 'bar', 'key_3' => 'baz']], ['foo' => Rule::arrayKeys(['key_1', 'key_2'])]);
+
+ $this->assertTrue($v->fails());
+ $this->assertSame(
+ 'The foo field must only contain the following keys: key_1, key_2.',
+ $v->messages()->first('foo')
+ );
+ $this->assertSame(['ArrayKeys' => ['key_1', 'key_2']], $v->failed()['foo']);
+ }
+
+ public function testArrayKeysValidationErrorMessageCanReferenceTheUnexpectedKeys(): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator(
+ $trans,
+ ['foo' => ['key_3' => 'bar', 'key_1' => 'baz', 'key_4' => 'qux']],
+ ['foo' => Rule::arrayKeys(['key_1', 'key_2'])],
+ ['foo.array_keys' => 'The :attribute field does not accept :unexpected. Accepted keys: :values.']
+ );
+
+ $this->assertTrue($v->fails());
+ $this->assertSame(
+ 'The foo field does not accept key_3, key_4. Accepted keys: key_1, key_2.',
+ $v->messages()->first('foo')
+ );
+ }
+
+ public function testUnexpectedKeysArePlaceholderSafeAndEmptyForNonArrays(): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator(
+ $trans,
+ ['foo' => ['key_1' => 'a', ':values' => 'b', ':attribute' => 'c']],
+ ['foo' => Rule::arrayKeys(['key_1'])],
+ ['foo.array_keys' => 'Unexpected keys: :unexpected. Accepted: :values.']
+ );
+
+ $this->assertTrue($v->fails());
+ $this->assertSame('Unexpected keys: :values, :attribute. Accepted: key_1.', $v->messages()->first('foo'));
+
+ $v = new Validator(
+ $trans,
+ ['foo' => 'not an array'],
+ ['foo' => Rule::arrayKeys(['key_1'])],
+ ['foo.array_keys' => 'Unexpected keys: :unexpected.']
+ );
+
+ $this->assertTrue($v->fails());
+ $this->assertSame('Unexpected keys: .', $v->messages()->first('foo'));
+ }
+
+ #[TestWith(['a.b'])]
+ #[TestWith(['a*b'])]
+ public function testArrayKeysAcceptsLiteralKeys(string $key): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['options' => [$key => 'value']],
+ ['options' => Rule::arrayKeys($key)],
+ );
+
+ $this->assertTrue($validator->passes());
+ }
+
+ #[TestWith(['options', 'options'])]
+ #[TestWith(['options.group', 'options\.group'])]
+ #[TestWith(['options*group', 'options\*group'])]
+ public function testUnexpectedKeysUseTheirLiteralNames(string $attribute, string $ruleAttribute): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ [$attribute => ['allowed.key' => 1, 'extra.key' => 2, 'extra*key' => 3]],
+ [$ruleAttribute => Rule::arrayKeys('allowed.key')],
+ ['array_keys' => 'Unexpected: :unexpected. Accepted: :values.'],
+ );
+
+ $this->assertTrue($validator->fails());
+ $this->assertSame('Unexpected: extra.key, extra*key. Accepted: allowed.key.', $validator->errors()->first($attribute));
+ }
+}
diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php
index aa4b7cf96..c470aa023 100755
--- a/tests/Validation/ValidationValidatorTest.php
+++ b/tests/Validation/ValidationValidatorTest.php
@@ -1472,6 +1472,30 @@ public function testValidateArrayKeys()
$this->assertFalse($v->passes());
}
+ #[TestWith(['array', 'a.b'])]
+ #[TestWith(['array', 'a*b'])]
+ #[TestWith(['required_array_keys', 'a.b'])]
+ #[TestWith(['required_array_keys', 'a*b'])]
+ #[TestWith(['in_array_keys', 'a.b'])]
+ #[TestWith(['in_array_keys', 'a*b'])]
+ public function testArrayRulesAcceptLiteralKeys(string $rule, string $key): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['options' => [$key => 'value']],
+ ['options' => $rule . ':' . $key],
+ );
+
+ $this->assertTrue($validator->passes());
+ }
+
+ public function testArrayValidationAcceptsLiteralKeysWhenCalledDirectly(): void
+ {
+ $validator = new Validator($this->getArrayTranslator(), [], []);
+
+ $this->assertTrue($validator->validateArray('options', ['a.b' => 1, 'a*b' => 2], ['a.b', 'a*b']));
+ }
+
public function testValidateCurrentPassword(): void
{
// Fails when user is not logged in.
@@ -7905,6 +7929,167 @@ public function testAsteriskPlaceholdersInParametersAreReplaced(): void
$this->assertSame('The name field is required when user.role* is not present.', $validator->messages()->first());
}
+ #[TestWith(['settings.version', 'settings\.version'])]
+ #[TestWith(['settings*version', 'settings\*version'])]
+ public function testLiteralFieldMessagesUseTheCorrectInput(string $attribute, string $ruleAttribute): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ [$attribute => 'invalid', 'settings' => ['version' => 'nested']],
+ [$ruleAttribute => 'integer'],
+ ['integer' => ':attribute: :input'],
+ [$attribute => 'Version'],
+ );
+ $validator->addCustomValues([$attribute => ['invalid' => 'Invalid version']]);
+
+ $this->assertSame('Version: Invalid version', $validator->errors()->first($attribute));
+ }
+
+ public function testLiteralWildcardSegmentsPreserveLabelsAndPositions(): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['versions' => ['1.2' => [3 => 'invalid']]],
+ ['versions.*.*' => 'integer'],
+ ['integer' => ':attribute: :index / :position / :second-index'],
+ ['versions.*.*' => 'Version'],
+ );
+
+ $this->assertSame('Version: 3 / 4 / :second-index', $validator->errors()->first());
+
+ $validator->setAttributeNames([]);
+ $validator->setImplicitAttributesFormatter(static fn (string $attribute): string => "Field {$attribute}");
+ $validator->passes();
+
+ $this->assertSame('Field versions.1.2.3: 3 / 4 / :second-index', $validator->errors()->first());
+ }
+
+ #[TestWith(['settings.version', 'settings\.version'])]
+ #[TestWith(['settings*version', 'settings\*version'])]
+ public function testDependentRuleMessagesReadLiteralFieldValues(string $attribute, string $ruleAttribute): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ [$attribute => 'yes', 'settings' => ['version' => 'no']],
+ ['name' => 'required_if:' . $ruleAttribute . ',yes'],
+ ['required_if' => ':other: :value'],
+ [$attribute => 'Version'],
+ );
+ $validator->addCustomValues([$attribute => ['yes' => 'Enabled']]);
+
+ $this->assertSame('Version: Enabled', $validator->errors()->first('name'));
+ $this->assertSame(['RequiredIf' => [$attribute, 'yes']], $validator->failed()['name']);
+ }
+
+ public function testComparisonMessagesPreserveBothLiteralFieldPaths(): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['current.value' => 1, 'other.value' => 50, 'other' => ['value' => 100]],
+ ['current\.value' => 'numeric|gt:other\.value'],
+ ['gt' => ':attribute must exceed :value.'],
+ );
+
+ $this->assertSame('current.value must exceed 50.', $validator->errors()->first());
+ }
+
+ #[TestWith(['inline'])]
+ #[TestWith(['translation'])]
+ public function testLiteralFieldMessagesRetainTheirNumericType(string $source): void
+ {
+ $translator = $this->getArrayTranslator();
+ $messages = ['value.amount.min' => ['numeric' => 'Numeric minimum.', 'string' => 'String minimum.']];
+
+ if ($source === 'translation') {
+ $translator->addLines(['validation.custom.value.amount.min.numeric' => 'Numeric minimum.'], 'en');
+ }
+
+ $validator = new Validator(
+ $translator,
+ ['value.amount' => 1],
+ ['value\.amount' => 'numeric|min:5'],
+ $source === 'inline' ? $messages : [],
+ );
+
+ $this->assertSame('Numeric minimum.', $validator->errors()->first());
+ }
+
+ public function testLiteralFieldMessagesUseFallbackMessageKeys(): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['value.amount' => 'invalid'],
+ ['value\.amount' => 'integer'],
+ );
+ $validator->setFallbackMessages(['value.amount.integer' => 'Integer required.']);
+
+ $this->assertSame('Integer required.', $validator->errors()->first());
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCustomReplacersReceiveDecodedFieldPaths(bool $classBased): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['settings.version' => 'invalid', 'other.value' => 'yes'],
+ ['settings\.version' => 'accepted_if:other\.value,yes'],
+ ['accepted_if' => ':input'],
+ );
+
+ $callback = function (string $message, string $attribute, string $rule, array $parameters, Validator $instance) use ($validator): string {
+ $this->assertSame('invalid', $message);
+ $this->assertSame('settings.version', $attribute);
+ $this->assertSame('accepted_if', $rule);
+ $this->assertSame(['other.value', 'yes'], $parameters);
+ $this->assertSame($validator, $instance);
+
+ return 'Custom message.';
+ };
+
+ if ($classBased) {
+ $validator->setContainer($container = m::mock(ContainerContract::class));
+ $container->shouldReceive('make')->once()->with('LiteralFieldReplacer')->andReturn($replacer = m::mock(stdClass::class));
+ $replacer->shouldReceive('replace')->once()->andReturnUsing($callback);
+ $validator->addReplacer('accepted_if', 'LiteralFieldReplacer');
+ } else {
+ $validator->addReplacer('accepted_if', $callback);
+ }
+
+ $this->assertSame('Custom message.', $validator->errors()->first('settings.version'));
+ }
+
+ public function testCustomRuleMessagesPreserveLiteralFieldIdentity(): void
+ {
+ $rule = new class implements Rule {
+ /**
+ * Determine if the validation rule passes.
+ */
+ public function passes(string $attribute, mixed $value): bool
+ {
+ return $attribute !== 'settings.version' || $value !== 'invalid';
+ }
+
+ /**
+ * Get the validation error messages.
+ */
+ public function message(): array
+ {
+ return [':attribute: :input', 'other' => ':attribute: :input'];
+ }
+ };
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['settings.version' => 'invalid', 'settings' => ['version' => 'nested'], 'other' => 'other input'],
+ ['settings\.version' => $rule],
+ );
+
+ $this->assertSame([
+ 'settings.version' => ['settings.version: invalid'],
+ 'other' => ['other: other input'],
+ ], $validator->errors()->getMessages());
+ }
+
public function testCoveringEmptyKeys()
{
$trans = $this->getArrayTranslator();
From 782af27fd9f7618a332bf067d16cea7b2cc81701 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 20:24:25 +0000
Subject: [PATCH 11/29] test(console): complete upstream command resolution
assertions
Port the remaining current Laravel assertions for eager command identity,
absent aliases, missing-input prompting and command-object execution.
Hypervel executes cloned command instances, so report the prompt flag and
exact argument values through the existing fixture output instead of
inspecting the original command. Preserve exit-code assertions and all
Hypervel-specific clone coverage without adding runtime hooks or shared
recorders.
Document DumpCommand::prohibit at schema squashing's public usage surface.
Its source implementation already prevents both dumping and pruning before
connection resolution. Current Laravel docs contain no corresponding guide.
Upstream:
https://github.com/laravel/framework/pull/57761
https://github.com/laravel/framework/pull/57735
Compared with current Laravel 13.x at
cdbd17f7e3257e8ae4207d3c3bef6058452d5f72.
Validation: ConsoleApplicationResolveTest (34 tests, 92 assertions), Console
ParaTest (521 tests, 1596 assertions), composer lint:fix and diff check pass.
Self-reviewed and signed off by claude-laravel-parity.
---
src/docs/migrations.md | 10 +++
.../Console/ConsoleApplicationResolveTest.php | 64 ++++++++++---------
.../FakeCommandWithArrayInputPrompting.php | 9 ++-
.../FakeCommandWithInputPrompting.php | 9 ++-
4 files changed, 61 insertions(+), 31 deletions(-)
diff --git a/src/docs/migrations.md b/src/docs/migrations.md
index 069da386b..9bcd9dbba 100644
--- a/src/docs/migrations.md
+++ b/src/docs/migrations.md
@@ -76,6 +76,16 @@ php artisan schema:dump --database=testing --prune
You should commit your database schema file to source control so that other new developers on your team may quickly create your application's initial database structure.
+To prevent schema dumps in production, call `DumpCommand::prohibit` from the `boot` method of your application's `AppServiceProvider`:
+
+```php
+use Hypervel\Database\Console\DumpCommand;
+
+DumpCommand::prohibit($this->app->isProduction());
+```
+
+When prohibited, `schema:dump` exits without dumping the schema or pruning migrations.
+
> [!WARNING]
> Migration squashing is only available for the MariaDB, MySQL, PostgreSQL, and SQLite databases and utilizes the database's command-line client.
diff --git a/tests/Console/ConsoleApplicationResolveTest.php b/tests/Console/ConsoleApplicationResolveTest.php
index e36667d32..291f0dc56 100644
--- a/tests/Console/ConsoleApplicationResolveTest.php
+++ b/tests/Console/ConsoleApplicationResolveTest.php
@@ -98,7 +98,7 @@ public function testResolveRegistersAllPipeAliases()
$this->assertArrayHasKey('test:alias', $map);
}
- public function testResolveEagerlyResolvesCommandWithoutStaticName()
+ public function testResolveEagerlyResolvesCommandWithoutStaticName(): void
{
$command = new SymfonyCommand('test:dynamic');
$container = $this->createMock(Application::class);
@@ -107,11 +107,11 @@ public function testResolveEagerlyResolvesCommandWithoutStaticName()
->with(StubDynamicCommand::class)
->willReturn($command);
- $app = $this->createApp($container);
- $result = $app->resolve(StubDynamicCommand::class);
+ $artisan = $this->createApp($container);
+ $result = $artisan->resolve(StubDynamicCommand::class);
- $this->assertInstanceOf(SymfonyCommand::class, $result);
- $this->assertArrayNotHasKey('test:dynamic', $this->getCommandMap($app));
+ $this->assertSame($command, $result);
+ $this->assertArrayNotHasKey('test:dynamic', $this->getCommandMap($artisan));
}
public function testAsCommandAttributeTakesPriorityOverSignature()
@@ -335,36 +335,42 @@ public function testAliasesAttributeOverridesSignatureAliasesInCommandMap(): voi
$this->assertArrayNotHasKey('test:aliases-attribute-ignored', $map);
}
- public function testResolvingCommandsWithNoAliasViaAttribute()
+ public function testResolvingCommandsWithNoAliasViaAttribute(): void
{
- $app = $this->createApp($this->app);
- $app->resolve(StubAttributedCommand::class);
- $app->setContainerCommandLoader();
+ $artisan = $this->createApp($this->app);
+ $artisan->resolve(StubAttributedCommand::class);
+ $artisan->setContainerCommandLoader();
- $this->assertInstanceOf(StubAttributedCommand::class, $app->get('test:attributed'));
+ $this->assertInstanceOf(StubAttributedCommand::class, $artisan->get('test:attributed'));
try {
- $app->get('some-nonexistent-alias');
+ $artisan->get('some-nonexistent-alias');
$this->fail();
} catch (Throwable $e) {
$this->assertInstanceOf(CommandNotFoundException::class, $e);
}
+
+ $this->assertArrayHasKey('test:attributed', $artisan->all());
+ $this->assertArrayNotHasKey('some-nonexistent-alias', $artisan->all());
}
- public function testResolvingCommandsWithNoAliasViaProperty()
+ public function testResolvingCommandsWithNoAliasViaProperty(): void
{
- $app = $this->createApp($this->app);
- $app->resolve(StubCommandWithoutPropertyAlias::class);
- $app->setContainerCommandLoader();
+ $artisan = $this->createApp($this->app);
+ $artisan->resolve(StubCommandWithoutPropertyAlias::class);
+ $artisan->setContainerCommandLoader();
- $this->assertInstanceOf(StubCommandWithoutPropertyAlias::class, $app->get('alias-test:no-alias'));
+ $this->assertInstanceOf(StubCommandWithoutPropertyAlias::class, $artisan->get('alias-test:no-alias'));
try {
- $app->get('some-nonexistent-alias');
+ $artisan->get('some-nonexistent-alias');
$this->fail();
} catch (Throwable $e) {
$this->assertInstanceOf(CommandNotFoundException::class, $e);
}
+
+ $this->assertArrayHasKey('alias-test:no-alias', $artisan->all());
+ $this->assertArrayNotHasKey('some-nonexistent-alias', $artisan->all());
}
// ---------------------------------------------------------------
@@ -403,7 +409,7 @@ public function testCallStringAndArrayInputProduceSameResult(): void
// PromptsForMissingInput
// ---------------------------------------------------------------
- public function testCommandInputPromptsWhenRequiredArgumentIsMissing()
+ public function testCommandInputPromptsWhenRequiredArgumentIsMissing(): void
{
$artisan = $this->createApp($this->app);
$output = new BufferedOutput;
@@ -414,10 +420,10 @@ public function testCommandInputPromptsWhenRequiredArgumentIsMissing()
$exitCode = $artisan->call('fake-command-for-testing', [], $output);
$this->assertSame(0, $exitCode);
- $this->assertSame("foo\n", $output->fetch());
+ $this->assertSame(['prompted' => true, 'name' => 'foo'], json_decode($output->fetch(), true));
}
- public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed()
+ public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed(): void
{
$artisan = $this->createApp($this->app);
$output = new BufferedOutput;
@@ -425,14 +431,14 @@ public function testCommandInputDoesntPromptWhenRequiredArgumentIsPassed()
$artisan->addCommands([new FakeCommandWithInputPrompting]);
$exitCode = $artisan->call('fake-command-for-testing', [
- 'name' => 'bar',
+ 'name' => 'foo',
], $output);
$this->assertSame(0, $exitCode);
- $this->assertSame("bar\n", $output->fetch());
+ $this->assertSame(['prompted' => false, 'name' => 'foo'], json_decode($output->fetch(), true));
}
- public function testCommandInputPromptsWhenRequiredArgumentsAreMissing()
+ public function testCommandInputPromptsWhenRequiredArgumentsAreMissing(): void
{
$artisan = $this->createApp($this->app);
$output = new BufferedOutput;
@@ -443,10 +449,10 @@ public function testCommandInputPromptsWhenRequiredArgumentsAreMissing()
$exitCode = $artisan->call('fake-command-for-testing-array', [], $output);
$this->assertSame(0, $exitCode);
- $this->assertSame("foo\n", $output->fetch());
+ $this->assertSame(['prompted' => true, 'names' => ['foo']], json_decode($output->fetch(), true));
}
- public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed()
+ public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed(): void
{
$artisan = $this->createApp($this->app);
$output = new BufferedOutput;
@@ -454,14 +460,14 @@ public function testCommandInputDoesntPromptWhenRequiredArgumentsArePassed()
$artisan->addCommands([new FakeCommandWithArrayInputPrompting]);
$exitCode = $artisan->call('fake-command-for-testing-array', [
- 'names' => ['bar', 'baz'],
+ 'names' => ['foo', 'bar', 'baz'],
], $output);
$this->assertSame(0, $exitCode);
- $this->assertSame("bar,baz\n", $output->fetch());
+ $this->assertSame(['prompted' => false, 'names' => ['foo', 'bar', 'baz']], json_decode($output->fetch(), true));
}
- public function testCallMethodCanCallArtisanCommandUsingCommandClassObject()
+ public function testCallMethodCanCallArtisanCommandUsingCommandClassObject(): void
{
$artisan = $this->createApp($this->app);
$output = new BufferedOutput;
@@ -472,7 +478,7 @@ public function testCallMethodCanCallArtisanCommandUsingCommandClassObject()
$exitCode = $artisan->call($command, [], $output);
$this->assertSame(0, $exitCode);
- $this->assertSame("foo\n", $output->fetch());
+ $this->assertSame(['prompted' => true, 'name' => 'foo'], json_decode($output->fetch(), true));
}
public function testSequentialCallsUseFreshCommandInstances(): void
diff --git a/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php b/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php
index c1d4b5824..b0369cc0a 100644
--- a/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php
+++ b/tests/Console/Fixtures/FakeCommandWithArrayInputPrompting.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Console\PromptsForMissingInput;
use Hypervel\Prompts\Prompt;
use Hypervel\Prompts\TextPrompt;
+use Hypervel\Support\Json;
use Symfony\Component\Console\Input\InputInterface;
class FakeCommandWithArrayInputPrompting extends Command implements PromptsForMissingInput
@@ -16,6 +17,9 @@ class FakeCommandWithArrayInputPrompting extends Command implements PromptsForMi
public bool $prompted = false;
+ /**
+ * Configure the prompt fallback for missing input.
+ */
protected function configurePrompts(InputInterface $input): void
{
Prompt::interactive(true);
@@ -28,9 +32,12 @@ protected function configurePrompts(InputInterface $input): void
});
}
+ /**
+ * Report the prompt result from the executed command instance.
+ */
public function handle(): int
{
- $this->line(implode(',', $this->argument('names')));
+ $this->line(Json::encode(['prompted' => $this->prompted, 'names' => $this->argument('names')]));
return self::SUCCESS;
}
diff --git a/tests/Console/Fixtures/FakeCommandWithInputPrompting.php b/tests/Console/Fixtures/FakeCommandWithInputPrompting.php
index b159cd28a..d8aff1577 100644
--- a/tests/Console/Fixtures/FakeCommandWithInputPrompting.php
+++ b/tests/Console/Fixtures/FakeCommandWithInputPrompting.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Console\PromptsForMissingInput;
use Hypervel\Prompts\Prompt;
use Hypervel\Prompts\TextPrompt;
+use Hypervel\Support\Json;
use Symfony\Component\Console\Input\InputInterface;
class FakeCommandWithInputPrompting extends Command implements PromptsForMissingInput
@@ -16,6 +17,9 @@ class FakeCommandWithInputPrompting extends Command implements PromptsForMissing
public bool $prompted = false;
+ /**
+ * Configure the prompt fallback for missing input.
+ */
protected function configurePrompts(InputInterface $input): void
{
Prompt::interactive(true);
@@ -28,9 +32,12 @@ protected function configurePrompts(InputInterface $input): void
});
}
+ /**
+ * Report the prompt result from the executed command instance.
+ */
public function handle(): int
{
- $this->line((string) $this->argument('name'));
+ $this->line(Json::encode(['prompted' => $this->prompted, 'name' => $this->argument('name')]));
return self::SUCCESS;
}
From e4863b7db2a6dd0cddaba676d5b700a238f1c799 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 20:31:57 +0000
Subject: [PATCH 12/29] docs(http): configure exception truncation during
provider registration
Port Laravel's current registered callback example so global truncation
settings take effect during bootstrap before lazy exception-handler
resolution. Preserve the per-request truncation example.
The source and complete current tests for immediate exception summaries,
once-only report-time recomputation and continued exception logging are
already present. Only the corresponding documentation correction was
missing.
Upstream:
https://github.com/laravel/framework/pull/57767
https://github.com/laravel/framework/pull/57847
https://github.com/laravel/docs/pull/10912
Docs source: 2914ba0b06c6be40c2f1f992555853f6266707d6.
Verified the callback/setter APIs and bootstrap/reporting lifecycle. The
complete section matches current Laravel docs after namespace adaptation;
git diff --check passes. Self-reviewed and signed off by
claude-laravel-parity. No source or tests changed.
---
src/docs/http-client.md | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/docs/http-client.md b/src/docs/http-client.md
index 5107e0700..642a68940 100644
--- a/src/docs/http-client.md
+++ b/src/docs/http-client.md
@@ -485,17 +485,17 @@ return Http::post(/* ... */)->throw(function (Response $response, RequestExcepti
})->json();
```
-By default, `RequestException` messages are truncated to 120 characters when logged or reported. To customize or disable this behavior, you may utilize the `truncateRequestExceptionsAt` and `dontTruncateRequestExceptions` methods when configuring your application's exception handling behavior in your `bootstrap/app.php` file:
+By default, `RequestException` messages are truncated to 120 characters when logged or reported. To customize or disable this behavior, you may utilize the `truncateAt` and `dontTruncate` methods when configuring your application's registered behavior in your `bootstrap/app.php` file:
```php
-use Hypervel\Foundation\Configuration\Exceptions;
+use Hypervel\Http\Client\RequestException;
-->withExceptions(function (Exceptions $exceptions): void {
+->registered(function (): void {
// Truncate request exception messages to 240 characters...
- $exceptions->truncateRequestExceptionsAt(240);
+ RequestException::truncateAt(240);
// Disable request exception message truncation...
- $exceptions->dontTruncateRequestExceptions();
+ RequestException::dontTruncate();
})
```
From 9cd68556d6f747855dded75cc5760b38a4c1e0dc Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 20:59:13 +0000
Subject: [PATCH 13/29] fix(routing): preserve middleware state when listing
routes
Route listing at normal or verbose output flushed the shared Router's
middleware groups and could cache group names as executable middleware.
Later listings and HTTP requests then reused corrupted worker state.
Resolve collapsed display middleware with an explicit empty group map,
sharing the existing alias, exclusion and priority resolution without
mutating the router or the route's executable caches.
Keep resolveMiddleware's existing signature and normal dispatch hooks.
Add the stateless no-group resolver and its facade annotation. Apply the
required native void type to the touched command handler without changing
its exit result. Document the existing --middleware substring filter and
how verbosity controls matching middleware within groups.
Complete the current-source assessment of Laravel PRs:
https://github.com/laravel/framework/pull/57797
https://github.com/laravel/framework/pull/48703
Source reference: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Their current upstream tests already exist and remain intact; add a real
application regression for both cold and previously dispatched routes.
Validation: regression 2 tests/21 assertions; existing route-list tests
13/115; Foundation console 129/476; Routing 533/1738; route warmup 15/46.
Full source/type PHPStan, formatting and diff checks pass. Peer review
signed off the complete five-file change.
---
src/docs/routing.md | 8 ++
.../src/Console/RouteListCommand.php | 22 ++++--
src/routing/src/Router.php | 24 +++++-
src/support/src/Facades/Route.php | 1 +
.../RouteListCommandMiddlewareTest.php | 73 +++++++++++++++++++
5 files changed, 118 insertions(+), 10 deletions(-)
create mode 100644 tests/Foundation/Console/RouteListCommandMiddlewareTest.php
diff --git a/src/docs/routing.md b/src/docs/routing.md
index 7e617b0a2..623a6d56e 100644
--- a/src/docs/routing.md
+++ b/src/docs/routing.md
@@ -205,6 +205,14 @@ php artisan route:list -v
php artisan route:list -vv
```
+You may use the `--middleware` option to only show routes whose listed middleware contains a given string:
+
+```shell
+php artisan route:list -v --middleware=auth
+```
+
+Use `-vv` to match middleware within middleware groups.
+
You may also instruct Hypervel to only show routes that begin with a given URI:
```shell
diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php
index 3597fe26f..951f2af94 100644
--- a/src/foundation/src/Console/RouteListCommand.php
+++ b/src/foundation/src/Console/RouteListCommand.php
@@ -71,18 +71,18 @@ public function __construct(
/**
* Execute the console command.
*/
- public function handle()
+ public function handle(): void
{
- if (! $this->output->isVeryVerbose()) {
- $this->router->flushMiddlewareGroups();
- }
-
if (! $this->router->getRoutes()->count()) {
- return $this->components->error("Your application doesn't have any routes."); // @phpstan-ignore method.void
+ $this->components->error("Your application doesn't have any routes.");
+
+ return;
}
if (empty($routes = $this->getRoutes())) {
- return $this->components->error("Your application doesn't have any routes matching the given criteria."); // @phpstan-ignore method.void
+ $this->components->error("Your application doesn't have any routes matching the given criteria.");
+
+ return;
}
$this->displayRoutes($routes);
@@ -187,7 +187,13 @@ protected function resolveUri(Route $route): string
*/
protected function getMiddleware(Route $route): string
{
- return (new Collection($this->router->gatherRouteMiddleware($route)))
+ // Collapsed output must preserve the worker's middleware groups and must
+ // not replace the route's cached executable middleware with group names.
+ $middleware = $this->output->isVeryVerbose()
+ ? $this->router->gatherRouteMiddleware($route)
+ : $this->router->resolveMiddlewareWithoutGroups($route->gatherMiddleware(), $route->excludedMiddleware());
+
+ return (new Collection($middleware))
->map(fn ($middleware) => $middleware instanceof Closure ? 'Closure' : $middleware)
->implode("\n");
}
diff --git a/src/routing/src/Router.php b/src/routing/src/Router.php
index c65649ad4..ba91b3cfc 100644
--- a/src/routing/src/Router.php
+++ b/src/routing/src/Router.php
@@ -785,17 +785,37 @@ public function gatherRouteMiddleware(Route $route): array
* @return array
*/
public function resolveMiddleware(array $middleware, array $excluded = []): array
+ {
+ return $this->resolveMiddlewareUsingGroups($middleware, $excluded, $this->middlewareGroups);
+ }
+
+ /**
+ * Resolve middleware aliases without expanding middleware groups.
+ *
+ * @return array
+ */
+ public function resolveMiddlewareWithoutGroups(array $middleware, array $excluded = []): array
+ {
+ return $this->resolveMiddlewareUsingGroups($middleware, $excluded, []);
+ }
+
+ /**
+ * Resolve middleware using the given middleware groups.
+ *
+ * @return array
+ */
+ protected function resolveMiddlewareUsingGroups(array $middleware, array $excluded, array $middlewareGroups): array
{
$excluded = $excluded === []
? $excluded
: (new Collection($excluded))
- ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups))
+ ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $middlewareGroups))
->flatten()
->values()
->all();
$middleware = (new Collection($middleware))
- ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $this->middlewareGroups))
+ ->map(fn (string|Closure $name): string|Closure|array => MiddlewareNameResolver::resolve($name, $this->middleware, $middlewareGroups))
->flatten()
->when(
! empty($excluded),
diff --git a/src/support/src/Facades/Route.php b/src/support/src/Facades/Route.php
index c297213fc..9b4165be4 100644
--- a/src/support/src/Facades/Route.php
+++ b/src/support/src/Facades/Route.php
@@ -67,6 +67,7 @@
* @method static \Hypervel\Routing\Route redirect(string $uri, string $destination, int $status = 302)
* @method static \Hypervel\Routing\Router removeMiddlewareFromGroup(string $group, array|string $middleware)
* @method static array resolveMiddleware(array $middleware, array $excluded = [])
+ * @method static array resolveMiddlewareWithoutGroups(array $middleware, array $excluded = [])
* @method static \Hypervel\Routing\PendingResourceRegistration resource(string $name, string $controller, array $options = [])
* @method static void resourceParameters(array $parameters = [])
* @method static void resources(array $resources, array $options = [])
diff --git a/tests/Foundation/Console/RouteListCommandMiddlewareTest.php b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php
new file mode 100644
index 000000000..17049fd46
--- /dev/null
+++ b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php
@@ -0,0 +1,73 @@
+app->make(Router::class);
+ $router->middlewareGroup('inspection', [RouteListCommandInspectionMiddleware::class]);
+ $route = $router->get('/middleware-inspection', static fn (): string => 'OK')
+ ->middleware('inspection');
+
+ if ($warm) {
+ $this->get('/middleware-inspection')->assertOk()->assertHeader('X-Route-Middleware', 'applied');
+ }
+
+ $groups = $router->getMiddlewareGroups();
+ $resolvedMiddleware = $route->resolvedMiddleware;
+ $pipeline = $route->middlewarePipeline;
+
+ Artisan::call('route:list', ['--json' => true, '-v' => true, '--path' => 'middleware-inspection']);
+ $routes = json_decode(Artisan::output(), true);
+
+ $this->assertSame(['inspection'], $routes[0]['middleware']);
+ $this->assertSame($groups, $router->getMiddlewareGroups());
+ $this->assertSame($resolvedMiddleware, $route->resolvedMiddleware);
+ $this->assertSame($pipeline, $route->middlewarePipeline);
+
+ Artisan::call('route:list', ['--json' => true, '-vv' => true, '--path' => 'middleware-inspection']);
+ $routes = json_decode(Artisan::output(), true);
+
+ $this->assertSame([RouteListCommandInspectionMiddleware::class], $routes[0]['middleware']);
+ $this->get('/middleware-inspection')->assertOk()->assertHeader('X-Route-Middleware', 'applied');
+ $this->assertSame([RouteListCommandInspectionMiddleware::class], $route->resolvedMiddleware);
+ }
+
+ /**
+ * Provide cold and previously dispatched routes.
+ */
+ public static function middlewareCacheStates(): array
+ {
+ return [
+ 'cold' => [false],
+ 'warm' => [true],
+ ];
+ }
+}
+
+class RouteListCommandInspectionMiddleware
+{
+ /**
+ * Mark responses that pass through the route middleware.
+ */
+ public function handle(Request $request, Closure $next): Response
+ {
+ $response = $next($request);
+ $response->headers->set('X-Route-Middleware', 'applied');
+
+ return $response;
+ }
+}
From a967532741f3de95c06870c372d049d8de380010 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 21:15:23 +0000
Subject: [PATCH 14/29] fix(console): initialize middleware before inspecting
routes
Console bootstrap does not resolve the HTTP kernel, which installs the
application's middleware groups, aliases and priority on the router.
route:list could therefore display unexpanded groups, miss middleware
filters and cache unresolved group names that break a later HTTP request.
Wayfinder likewise missed URL defaults supplied by grouped or aliased
middleware and generated required parameters instead of optional ones.
Resolve the application's HTTP kernel contract once when each command
runs, before inspecting middleware. Preserve custom kernel bindings,
existing signatures, route caches and the application-wide boot order.
This uses the existing configuration path without per-route resolution,
request overhead, cache invalidation or a new initialization mechanism.
Add a real-application route-list regression for kernel-configured aliases
inside groups, filtering and subsequent HTTP dispatch. Extend Wayfinder's
existing defaults coverage with kernel initialization and grouped routes,
preserving every original assertion. Bind the kernel contract in the
hand-built route-list fixtures and complete the touched fixture docblocks.
Found while reviewing the adaptation of Laravel route-list PRs:
https://github.com/laravel/framework/pull/57797
https://github.com/laravel/framework/pull/48703
The peer independently reproduced the console configuration gap in current
Laravel 13.x; Hypervel's persistent route cache also exposes a request
failure after listing. This is an adjacent correction, not another port.
Validation: route-list regression 3 tests/28 assertions, existing tests
13/115, Wayfinder defaults 10/28; Foundation console ParaTest 130/483 and
Wayfinder ParaTest 36/127. Full PHPStan source/type checks, formatting and
diff checks pass. Peer signed off with the fixture-title cleanup applied.
---
.../src/Console/RouteListCommand.php | 5 +++
src/wayfinder/src/GenerateCommand.php | 5 +++
.../RouteListCommandMiddlewareTest.php | 33 +++++++++++++++++
.../Console/RouteListCommandTest.php | 5 +--
tests/Wayfinder/GenerateCommandTest.php | 35 +++++++++++++++++--
5 files changed, 79 insertions(+), 4 deletions(-)
diff --git a/src/foundation/src/Console/RouteListCommand.php b/src/foundation/src/Console/RouteListCommand.php
index 951f2af94..d194d5e5e 100644
--- a/src/foundation/src/Console/RouteListCommand.php
+++ b/src/foundation/src/Console/RouteListCommand.php
@@ -6,6 +6,7 @@
use Closure;
use Hypervel\Console\Command;
+use Hypervel\Contracts\Http\Kernel as HttpKernel;
use Hypervel\Contracts\Routing\UrlGenerator;
use Hypervel\Routing\Route;
use Hypervel\Routing\Router;
@@ -73,6 +74,10 @@ public function __construct(
*/
public function handle(): void
{
+ // Console bootstrap leaves the HTTP kernel unresolved. Resolving it installs
+ // the application's middleware groups, aliases, and priority on the router.
+ $this->hypervel->make(HttpKernel::class);
+
if (! $this->router->getRoutes()->count()) {
$this->components->error("Your application doesn't have any routes.");
diff --git a/src/wayfinder/src/GenerateCommand.php b/src/wayfinder/src/GenerateCommand.php
index b47568e1f..dfcf0b916 100644
--- a/src/wayfinder/src/GenerateCommand.php
+++ b/src/wayfinder/src/GenerateCommand.php
@@ -7,6 +7,7 @@
use BackedEnum;
use Closure;
use Hypervel\Console\Command;
+use Hypervel\Contracts\Http\Kernel as HttpKernel;
use Hypervel\Contracts\Routing\UrlRoutable;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Routing\Route as BaseRoute;
@@ -83,6 +84,10 @@ public function handle(): int
throw new InvalidArgumentException('The --path option may not be empty.');
}
+ // Console bootstrap leaves the HTTP kernel unresolved. Resolving it installs
+ // the application's middleware groups, aliases, and priority on the router.
+ $this->hypervel->make(HttpKernel::class);
+
$this->view->replaceNamespace('wayfinder', __DIR__ . '/../resources');
$this->view->addExtension('blade.ts', 'blade');
diff --git a/tests/Foundation/Console/RouteListCommandMiddlewareTest.php b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php
index 17049fd46..49b909875 100644
--- a/tests/Foundation/Console/RouteListCommandMiddlewareTest.php
+++ b/tests/Foundation/Console/RouteListCommandMiddlewareTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Foundation\Console;
use Closure;
+use Hypervel\Contracts\Http\Kernel;
use Hypervel\Http\Request;
use Hypervel\Routing\Router;
use Hypervel\Support\Facades\Artisan;
@@ -17,6 +18,7 @@ class RouteListCommandMiddlewareTest extends TestCase
#[DataProvider('middlewareCacheStates')]
public function testListingPreservesMiddlewareForSubsequentRequests(bool $warm): void
{
+ $this->app->make(Kernel::class);
$router = $this->app->make(Router::class);
$router->middlewareGroup('inspection', [RouteListCommandInspectionMiddleware::class]);
$route = $router->get('/middleware-inspection', static fn (): string => 'OK')
@@ -56,6 +58,37 @@ public static function middlewareCacheStates(): array
'warm' => [true],
];
}
+
+ public function testListingInitializesConfiguredMiddlewareBeforeTheFirstRequest(): void
+ {
+ $this->app->afterResolving(Kernel::class, static function (Kernel $kernel): void {
+ $kernel->setMiddlewareAliases([
+ ...$kernel->getMiddlewareAliases(),
+ 'inspection.alias' => RouteListCommandInspectionMiddleware::class,
+ ]);
+ $kernel->setMiddlewareGroups([
+ ...$kernel->getMiddlewareGroups(),
+ 'inspection' => ['inspection.alias'],
+ ]);
+ });
+
+ $this->app->make(Router::class)->get('/configured-middleware', static fn (): string => 'OK')
+ ->middleware('inspection');
+
+ $this->assertFalse($this->app->resolved(Kernel::class));
+
+ Artisan::call('route:list', [
+ '--json' => true,
+ '-vv' => true,
+ '--middleware' => RouteListCommandInspectionMiddleware::class,
+ ]);
+ $routes = json_decode(Artisan::output(), true);
+
+ $this->assertCount(1, $routes);
+ $this->assertSame('configured-middleware', $routes[0]['uri']);
+ $this->assertSame([RouteListCommandInspectionMiddleware::class], $routes[0]['middleware']);
+ $this->get('/configured-middleware')->assertOk()->assertHeader('X-Route-Middleware', 'applied');
+ }
}
class RouteListCommandInspectionMiddleware
diff --git a/tests/Foundation/Console/RouteListCommandTest.php b/tests/Foundation/Console/RouteListCommandTest.php
index 9e77e43a5..5cc66e1cf 100644
--- a/tests/Foundation/Console/RouteListCommandTest.php
+++ b/tests/Foundation/Console/RouteListCommandTest.php
@@ -7,6 +7,7 @@
use Hypervel\Console\Application;
use Hypervel\Console\Events\ArtisanStarting;
use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Contracts\Http\Kernel as KernelContract;
use Hypervel\Foundation\Console\RouteListCommand;
use Hypervel\Foundation\Http\Kernel;
use Hypervel\Routing\Router;
@@ -49,7 +50,7 @@ protected function setUp(): void
$kernel->prependToMiddlewarePriority('Middleware 5');
- $hypervel->instance(Kernel::class, $kernel);
+ $hypervel->instance(KernelContract::class, $kernel);
$router->get('/example', function () {
return 'Hello World';
@@ -264,7 +265,7 @@ public function testControllerRoutePathIsNull(): void
protected array $middlewareGroups = [];
};
- $hypervel->instance(Kernel::class, $kernel);
+ $hypervel->instance(KernelContract::class, $kernel);
$router->get('/controller-route', [RouteListCommandTestController::class, 'index']);
diff --git a/tests/Wayfinder/GenerateCommandTest.php b/tests/Wayfinder/GenerateCommandTest.php
index 24bd2cb1c..6fd177444 100644
--- a/tests/Wayfinder/GenerateCommandTest.php
+++ b/tests/Wayfinder/GenerateCommandTest.php
@@ -6,6 +6,7 @@
use Closure;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
+use Hypervel\Contracts\Http\Kernel as HttpKernel;
use Hypervel\Filesystem\Filesystem;
use Hypervel\Routing\RouteCollection;
use Hypervel\Routing\Router;
@@ -202,8 +203,16 @@ public function testSkipRoutesIgnoresDuplicateNamesWhenGeneratingActions(): void
public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults(): void
{
- $router = $this->app->make(Router::class);
- $router->aliasMiddleware('wayfinder.defaults', ParameterizedWayfinderDefaultsMiddleware::class);
+ $this->app->afterResolving(HttpKernel::class, static function (HttpKernel $kernel): void {
+ $kernel->setMiddlewareAliases([
+ ...$kernel->getMiddlewareAliases(),
+ 'wayfinder.defaults' => ParameterizedWayfinderDefaultsMiddleware::class,
+ ]);
+ $kernel->setMiddlewareGroups([
+ ...$kernel->getMiddlewareGroups(),
+ 'wayfinder' => ['wayfinder.defaults:tenant'],
+ ]);
+ });
Route::get('/direct/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'direct'])
->middleware(ParameterizedWayfinderDefaultsMiddleware::class . ':tenant');
@@ -211,6 +220,8 @@ public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults():
->middleware('wayfinder.defaults:tenant');
Route::get('/plain/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'plain'])
->middleware(ParameterizedWayfinderDefaultsMiddleware::class);
+ Route::get('/group/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'group'])
+ ->middleware('wayfinder');
// This class is intentionally undefined to exercise the absent-middleware guard.
Route::get('/missing/{tenant}', [ParameterizedWayfinderDefaultsController::class, 'missing'])
->middleware(MissingWayfinderDefaultsMiddleware::class . ':tenant');
@@ -232,6 +243,7 @@ public function testParameterizedMiddlewareUsesItsResolvedClassForUrlDefaults():
$this->assertStringContainsString("url: '/direct/{tenant?}'", $content);
$this->assertStringContainsString("url: '/alias/{tenant?}'", $content);
$this->assertStringContainsString("url: '/plain/{tenant?}'", $content);
+ $this->assertStringContainsString("url: '/group/{tenant?}'", $content);
$this->assertStringContainsString("url: '/missing/{tenant}'", $content);
}
@@ -306,18 +318,37 @@ public function handle(mixed $request, Closure $next): mixed
class ParameterizedWayfinderDefaultsController
{
+ /**
+ * Handle the route with parameterized middleware defaults.
+ */
public function direct(): void
{
}
+ /**
+ * Handle the route with aliased middleware defaults.
+ */
public function alias(): void
{
}
+ /**
+ * Handle the route with unparameterized middleware defaults.
+ */
public function plain(): void
{
}
+ /**
+ * Handle the route with grouped middleware defaults.
+ */
+ public function group(): void
+ {
+ }
+
+ /**
+ * Handle the route with an undefined middleware class.
+ */
public function missing(): void
{
}
From 74828e4ae9b890725547e7d32bce742ebe6acaf1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 21:55:03 +0000
Subject: [PATCH 15/29] Complete string helper type parity and correct matching
contracts
Port current Laravel 13.x string annotations and the complete Str type
fixture from 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Preserve native
Hypervel signatures, scalar and Stringable inputs, cache-free casing,
Symfony UUID types and existing worker-state behavior.
Return false when containsAll receives no needles, retaining one-pass
iteration and early failure. Treat a non-iterable endsWith needle as a
single value so Stringable objects use their string value, not properties.
Carry the complete numbers annotation and equivalent regex update.
Correct upstream position types to reflect mb_strpos's integer result
for empty needles. Keep prefix narrowing accurate for numeric inputs and
align fluent wrapper annotations. Describe excerpt radius as an integer:
fractional coercion is deprecated upstream and rejected by strict PHP;
do not introduce truncation behavior or a compatibility cast.
Retain all applicable upstream type assertions, correct the wrong position
expectation and cover scalar/Stringable inference, object suffix matching,
empty needles and the array/string numbers return types.
Laravel PRs:
https://github.com/laravel/framework/pull/57230
https://github.com/laravel/framework/pull/57820
https://github.com/laravel/framework/pull/58356
https://github.com/laravel/framework/pull/58364
https://github.com/laravel/framework/pull/58372
https://github.com/laravel/framework/pull/58365
https://github.com/laravel/framework/pull/60746
https://github.com/laravel/framework/pull/61053
Validation: SupportStrTest 228 tests / 1292 assertions; Support ParaTest
2647 tests / 8638 assertions; full source and 27 type-fixture analysis;
formatting and diff checks pass. Independently reviewed and signed off by
claude-laravel-parity, including the final numbers correction.
---
src/support/src/Str.php | 83 ++++++++++++---
src/support/src/Stringable.php | 14 ++-
tests/Support/SupportStrTest.php | 25 +++++
types/Support/Str.php | 172 +++++++++++++++++++++++++++++++
4 files changed, 274 insertions(+), 20 deletions(-)
create mode 100644 types/Support/Str.php
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index 47906ae55..9d7b02376 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -183,6 +183,8 @@ public static function betweenFirst(string $subject, string|int|float|bool|BaseS
/**
* Convert a value to camel case.
+ *
+ * @return ($value is '' ? '' : string)
*/
public static function camel(string $value): string
{
@@ -205,6 +207,8 @@ public static function charAt(string $subject, mixed $index): string|false
/**
* Remove the given string(s) if it exists at the start of the haystack.
+ *
+ * @param string|string[] $needle
*/
public static function chopStart(string $subject, string|array $needle): string
{
@@ -219,6 +223,8 @@ public static function chopStart(string $subject, string|array $needle): string
/**
* Remove the given string(s) if it exists at the end of the haystack.
+ *
+ * @param string|string[] $needle
*/
public static function chopEnd(string $subject, string|array $needle): string
{
@@ -235,6 +241,7 @@ public static function chopEnd(string $subject, string|array $needle): string
* Determine if a given string contains a given substring.
*
* @param iterable|string $needles
+ * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false))
*/
public static function contains(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool
{
@@ -263,22 +270,28 @@ public static function contains(string $haystack, string|iterable $needles, bool
* Determine if a given string contains all array values.
*
* @param iterable $needles
+ * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false))
*/
public static function containsAll(string $haystack, iterable $needles, bool $ignoreCase = false): bool
{
+ $any = false;
+
foreach ($needles as $needle) {
+ $any = true;
+
if (! static::contains($haystack, $needle, $ignoreCase)) {
return false;
}
}
- return true;
+ return $any;
}
/**
* Determine if a given string doesn't contain a given substring.
*
* @param iterable|string $needles
+ * @return ($needles is array{} ? true : ($haystack is non-empty-string ? bool : true))
*/
public static function doesntContain(string $haystack, string|iterable $needles, bool $ignoreCase = false): bool
{
@@ -287,6 +300,9 @@ public static function doesntContain(string $haystack, string|iterable $needles,
/**
* Convert the case of a string.
+ *
+ * @param MB_CASE_FOLD|MB_CASE_FOLD_SIMPLE|MB_CASE_LOWER|MB_CASE_LOWER_SIMPLE|MB_CASE_TITLE|MB_CASE_TITLE_SIMPLE|MB_CASE_UPPER|MB_CASE_UPPER_SIMPLE $mode
+ * @return ($string is '' ? '' : string)
*/
public static function convertCase(string $string, int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): string
{
@@ -305,6 +321,7 @@ public static function counted(string $value, int|array|Countable $count): strin
* Replace consecutive instances of a given character with a single character in the given string.
*
* @param array|string $characters
+ * @return ($string is '' ? '' : string)
*/
public static function deduplicate(string $string, array|string $characters = ' '): string
{
@@ -322,7 +339,8 @@ public static function deduplicate(string $string, array|string $characters = '
/**
* Determine if a given string ends with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
+ * @return ($needles is array{} ? false : ($haystack is null|''|false ? false : bool))
*/
public static function endsWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -333,7 +351,7 @@ public static function endsWith(string|int|float|bool|BaseStringable|null $hayst
$haystack = (string) $haystack;
if (! is_iterable($needles)) {
- $needles = (array) $needles;
+ $needles = [$needles];
}
foreach ($needles as $needle) {
@@ -350,7 +368,8 @@ public static function endsWith(string|int|float|bool|BaseStringable|null $hayst
/**
* Determine if a given string doesn't end with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
+ * @return ($needles is array{} ? true : ($haystack is null|''|false ? true : bool))
*/
public static function doesntEndWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -358,9 +377,9 @@ public static function doesntEndWith(string|int|float|bool|BaseStringable|null $
}
/**
- * Extracts an excerpt from text that matches the first instance of a phrase.
+ * Extract an excerpt from text that matches the first instance of a phrase.
*
- * @param array{radius?: float|int, omission?: string} $options
+ * @param array{radius?: int, omission?: string} $options
*/
public static function excerpt(string|int|float|bool|BaseStringable|null $text, string|int|float|bool|BaseStringable|null $phrase = '', array $options = []): ?string
{
@@ -395,6 +414,8 @@ public static function excerpt(string|int|float|bool|BaseStringable|null $text,
/**
* Cap a string with a single instance of a given value.
+ *
+ * @return ($value is '' ? ($cap is '' ? '' : non-empty-string) : non-empty-string)
*/
public static function finish(string $value, string $cap): string
{
@@ -405,6 +426,8 @@ public static function finish(string $value, string $cap): string
/**
* Wrap the string with the given strings.
+ *
+ * @return ($value is '' ? ($before is '' ? ($after is '' ? '' : ($after is null ? '' : non-empty-string)) : non-empty-string) : non-empty-string)
*/
public static function wrap(string $value, string $before, ?string $after = null): string
{
@@ -479,6 +502,8 @@ public static function isAscii(string|int|float|bool|BaseStringable|null $value)
/**
* Determine if a given value is valid JSON.
+ *
+ * @phpstan-assert-if-true =non-empty-string $value
*/
public static function isJson(mixed $value): bool
{
@@ -493,6 +518,8 @@ public static function isJson(mixed $value): bool
* Determine if a given value is a valid URL.
*
* @param string[] $protocols
+ *
+ * @phpstan-assert-if-true =non-empty-string $value
*/
public static function isUrl(mixed $value, array $protocols = []): bool
{
@@ -545,6 +572,8 @@ public static function isUrl(mixed $value, array $protocols = []): bool
* Determine if a given value is a valid UUID.
*
* @param null|'max'|'nil'|int<0, 8> $version
+ *
+ * @phpstan-assert-if-true =non-empty-string $value
*/
public static function isUuid(mixed $value, int|string|null $version = null): bool
{
@@ -573,6 +602,8 @@ public static function isUuid(mixed $value, int|string|null $version = null): bo
/**
* Determine if a given value is a valid ULID.
+ *
+ * @phpstan-assert-if-true =non-empty-string $value
*/
public static function isUlid(mixed $value): bool
{
@@ -585,6 +616,8 @@ public static function isUlid(mixed $value): bool
/**
* Convert a string to kebab case.
+ *
+ * @return ($value is '' ? '' : string)
*/
public static function kebab(string $value): string
{
@@ -593,6 +626,8 @@ public static function kebab(string $value): string
/**
* Return the length of the given string.
+ *
+ * @return non-negative-int
*/
public static function length(string $value, ?string $encoding = null): int
{
@@ -625,6 +660,8 @@ public static function limit(string $value, int $limit = 100, string $end = '...
/**
* Convert the given string to lower-case.
+ *
+ * @return ($value is '' ? '' : lowercase-string&non-empty-string)
*/
public static function lower(string $value): string
{
@@ -646,9 +683,10 @@ public static function words(string $value, int $words = 100, string $end = '...
}
/**
- * Converts GitHub flavored Markdown into HTML.
+ * Convert GitHub flavored Markdown into HTML.
*
* @param \League\CommonMark\Extension\ExtensionInterface[] $extensions
+ * @return ($string is '' ? '' : string)
*/
public static function markdown(string $string, array $options = [], array $extensions = []): string
{
@@ -664,9 +702,10 @@ public static function markdown(string $string, array $options = [], array $exte
}
/**
- * Converts inline Markdown into HTML.
+ * Convert inline Markdown into HTML.
*
* @param \League\CommonMark\Extension\ExtensionInterface[] $extensions
+ * @return ($string is '' ? '' : string)
*/
public static function inlineMarkdown(string $string, array $options = [], array $extensions = []): string
{
@@ -733,6 +772,7 @@ public static function match(string $pattern, string $subject): string
* Determine if a given string matches a given pattern.
*
* @param iterable|string $pattern
+ * @return ($pattern is array{} ? false : bool)
*/
public static function isMatch(string|iterable $pattern, string $value): bool
{
@@ -767,10 +807,13 @@ public static function matchAll(string $pattern, string $subject): Collection
/**
* Remove all non-numeric characters from a string.
+ *
+ * @param string|string[] $value
+ * @return ($value is string ? string : string[])
*/
public static function numbers(string|array $value): string|array
{
- return preg_replace('/[^0-9]/', '', $value);
+ return preg_replace('/\D/', '', $value);
}
/**
@@ -852,6 +895,8 @@ public static function pluralPascal(string $value, int|array|Countable $count =
/**
* Generate a random, secure password.
+ *
+ * @return ($letters is false ? ($numbers is true ? ($symbols is false ? ($spaces is false ? numeric-string : string) : string) : string) : string)
*/
public static function password(int $length = 32, bool $letters = true, bool $numbers = true, bool $symbols = true, bool $spaces = false): string
{
@@ -888,6 +933,8 @@ public static function password(int $length = 32, bool $letters = true, bool $nu
/**
* Find the multi-byte safe position of the first occurrence of a given substring in a string.
+ *
+ * @return ($needle is '' ? int : ($haystack is '' ? false : false|int))
*/
public static function position(string $haystack, string $needle, int $offset = 0, ?string $encoding = null): int|false
{
@@ -1029,6 +1076,7 @@ private static function toStringOr(mixed $value, string $fallback): string
* @param iterable|string $search
* @param iterable|string $replace
* @param iterable|string $subject
+ * @return ($subject is string ? string : string[])
*/
public static function replace(string|iterable $search, string|iterable $replace, string|iterable $subject, bool $caseSensitive = true): string|array
{
@@ -1129,6 +1177,7 @@ public static function replaceEnd(string|int|float|bool|BaseStringable|null $sea
* @param string|string[] $pattern
* @param (Closure(array): string)|string|string[] $replace
* @param string|string[] $subject
+ * @return ($subject is array ? null|string[] : null|string)
*/
public static function replaceMatches(string|array $pattern, Closure|array|string $replace, string|array $subject, int $limit = -1): string|array|null
{
@@ -1165,6 +1214,8 @@ public static function reverse(string $value): string
/**
* Begin a string with a single instance of a given value.
+ *
+ * @return ($value is '' ? ($prefix is '' ? '' : non-empty-string) : non-empty-string)
*/
public static function start(string $value, string $prefix): string
{
@@ -1175,6 +1226,8 @@ public static function start(string $value, string $prefix): string
/**
* Convert the given string to upper-case.
+ *
+ * @return ($value is '' ? '' : non-empty-string&uppercase-string)
*/
public static function upper(string $value): string
{
@@ -1394,10 +1447,10 @@ public static function squish(string $value): string
/**
* Determine if a given string starts with a given substring.
*
- * @param iterable|string $needles
- * @return ($needles is array{} ? false : ($haystack is non-empty-string ? bool : false))
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
+ * @return ($needles is array{} ? false : ($haystack is null|''|false ? false : bool))
*
- * @phpstan-assert-if-true =non-empty-string $haystack
+ * @phpstan-assert-if-true =non-empty-string|int|float|true|BaseStringable $haystack
*/
public static function startsWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -1425,10 +1478,10 @@ public static function startsWith(string|int|float|bool|BaseStringable|null $hay
/**
* Determine if a given string doesn't start with a given substring.
*
- * @param iterable|string $needles
- * @return ($needles is array{} ? true : ($haystack is non-empty-string ? bool : true))
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
+ * @return ($needles is array{} ? true : ($haystack is null|''|false ? true : bool))
*
- * @phpstan-assert-if-false =non-empty-string $haystack
+ * @phpstan-assert-if-false =non-empty-string|int|float|true|BaseStringable $haystack
*/
public static function doesntStartWith(string|int|float|bool|BaseStringable|null $haystack, string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
diff --git a/src/support/src/Stringable.php b/src/support/src/Stringable.php
index cc2cefd7a..74e4e0ef4 100644
--- a/src/support/src/Stringable.php
+++ b/src/support/src/Stringable.php
@@ -189,6 +189,8 @@ public function doesntContain(string|iterable $needles, bool $ignoreCase = false
/**
* Convert the case of a string.
+ *
+ * @param MB_CASE_FOLD|MB_CASE_FOLD_SIMPLE|MB_CASE_LOWER|MB_CASE_LOWER_SIMPLE|MB_CASE_TITLE|MB_CASE_TITLE_SIMPLE|MB_CASE_UPPER|MB_CASE_UPPER_SIMPLE $mode
*/
public function convertCase(int $mode = MB_CASE_FOLD, ?string $encoding = 'UTF-8'): static
{
@@ -222,7 +224,7 @@ public function dirname(int $levels = 1): static
/**
* Determine if a given string ends with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
*/
public function endsWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -232,7 +234,7 @@ public function endsWith(string|int|float|bool|BaseStringable|iterable|null $nee
/**
* Determine if a given string doesn't end with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
*/
public function doesntEndWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -252,7 +254,9 @@ public function exactly(mixed $value): bool
}
/**
- * Extracts an excerpt from text that matches the first instance of a phrase.
+ * Extract an excerpt from text that matches the first instance of a phrase.
+ *
+ * @param array{radius?: int, omission?: string} $options
*/
public function excerpt(string $phrase = '', array $options = []): ?string
{
@@ -745,7 +749,7 @@ public function snake(string $delimiter = '_'): static
/**
* Determine if a given string starts with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
*/
public function startsWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
@@ -755,7 +759,7 @@ public function startsWith(string|int|float|bool|BaseStringable|iterable|null $n
/**
* Determine if a given string doesn't start with a given substring.
*
- * @param iterable|string $needles
+ * @param null|BaseStringable|bool|float|int|iterable|string $needles
*/
public function doesntStartWith(string|int|float|bool|BaseStringable|iterable|null $needles): bool
{
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index a238edbb6..840ebe09f 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -319,6 +319,15 @@ public function testEndsWith(): void
$this->assertTrue(Str::endsWith(0.27, '0.27'));
$this->assertFalse(Str::endsWith(0.27, '8'));
$this->assertFalse(Str::endsWith(null, 'Marc'));
+ $this->assertTrue(Str::endsWith('foobar', new class {
+ /**
+ * Return the suffix.
+ */
+ public function __toString(): string
+ {
+ return 'bar';
+ }
+ }));
// Test for multibyte string support
$this->assertTrue(Str::endsWith('Jönköping', 'öping'));
$this->assertTrue(Str::endsWith('Malmö', 'mö'));
@@ -352,6 +361,15 @@ public function testDoesntEndWith(): void
$this->assertFalse(Str::doesntEndWith(0.27, '0.27'));
$this->assertTrue(Str::doesntEndWith(0.27, '8'));
$this->assertTrue(Str::doesntEndWith(null, 'Marc'));
+ $this->assertFalse(Str::doesntEndWith('foobar', new class {
+ /**
+ * Return the suffix.
+ */
+ public function __toString(): string
+ {
+ return 'bar';
+ }
+ }));
// Test for multibyte string support
$this->assertFalse(Str::doesntEndWith('Jönköping', 'öping'));
$this->assertFalse(Str::doesntEndWith('Malmö', 'mö'));
@@ -547,6 +565,9 @@ public function testStrContainsAll(string $haystack, iterable $needles, bool $ex
$this->assertEquals($expected, Str::containsAll($haystack, $needles, $ignoreCase));
}
+ /**
+ * Provide strings and needles for complete substring matching.
+ */
public static function strContainsAllProvider(): array
{
return [
@@ -556,6 +577,7 @@ public static function strContainsAllProvider(): array
['Taylor Otwell', ['taylor'], true, true],
['Taylor Otwell', ['taylor', 'xxx'], false, false],
['Taylor Otwell', ['taylor', 'xxx'], false, true],
+ ['Taylor Otwell', [], false, false],
];
}
@@ -1552,6 +1574,9 @@ public function testPosition(): void
$this->assertFalse(Str::position('Hello, World!', 'X', 0, 'UTF-8'));
$this->assertFalse(Str::position('', 'test'));
$this->assertFalse(Str::position('Hello, World!', 'X'));
+ $this->assertSame(0, Str::position('Taylor', ''));
+ $this->assertSame(3, Str::position('Taylor', '', 3));
+ $this->assertSame(0, Str::position('', ''));
}
public function testSubstrReplace(): void
diff --git a/types/Support/Str.php b/types/Support/Str.php
new file mode 100644
index 000000000..d7cb03fd7
--- /dev/null
+++ b/types/Support/Str.php
@@ -0,0 +1,172 @@
+', Str::replace($search, $replace, [$subject]));
+
+assertType('\'\'', Str::camel(''));
+assertType('string', Str::camel('Taylor Otwell'));
+
+assertType('false', Str::contains('Taylor Otwell', []));
+assertType('false', Str::contains('', 'Taylor'));
+assertType('bool', Str::contains('Taylor Otwell', 'Taylor'));
+
+assertType('false', Str::containsAll('Taylor Otwell', []));
+assertType('bool', Str::containsAll('Taylor Otwell', ['Taylor']));
+
+assertType('true', Str::doesntContain('Taylor Otwell', []));
+assertType('true', Str::doesntContain('', 'Taylor'));
+assertType('bool', Str::doesntContain('Taylor Otwell', 'Taylor'));
+
+assertType('\'\'', Str::convertCase(''));
+assertType('string', Str::convertCase('Taylor Otwell'));
+
+assertType('\'\'', Str::deduplicate(''));
+assertType('string', Str::deduplicate('Taylor Otwell'));
+
+assertType('false', Str::endsWith('Taylor Otwell', []));
+assertType('false', Str::endsWith('', 'Taylor'));
+assertType('bool', Str::endsWith('Taylor Otwell', 'Taylor'));
+assertType('bool', Str::endsWith(123, '3'));
+assertType('bool', Str::endsWith('123', 3));
+assertType('bool', Str::of('123')->endsWith(3));
+
+assertType('true', Str::doesntEndWith('Taylor Otwell', []));
+assertType('true', Str::doesntEndWith('', 'Taylor'));
+assertType('bool', Str::doesntEndWith('Taylor Otwell', 'Taylor'));
+assertType('bool', Str::doesntEndWith(123, '3'));
+assertType('bool', Str::of('123')->doesntEndWith(3));
+
+assertType('\'\'', Str::kebab(''));
+assertType('string', Str::kebab('Taylor Otwell'));
+
+assertType('\'\'', Str::lower(''));
+assertType('lowercase-string&non-empty-string', Str::lower('Taylor'));
+assertType('\'\'', Str::upper(''));
+assertType('non-empty-string&uppercase-string', Str::upper('Taylor'));
+
+assertType('\'\'', Str::markdown(''));
+assertType('string', Str::markdown('Taylor Otwell'));
+
+assertType('\'\'', Str::inlineMarkdown(''));
+assertType('string', Str::inlineMarkdown('Taylor Otwell'));
+
+assertType('false', Str::isMatch([], 'Taylor Otwell'));
+assertType('bool', Str::isMatch(['Taylor'], 'Taylor Otwell'));
+
+assertType('string', Str::numbers('(555) 123-4567'));
+assertType('array', Str::numbers(['(555) 123-4567']));
+
+assertType('numeric-string', Str::password(letters: false, symbols: false, spaces: false));
+assertType('string', Str::password());
+
+assertType('int', Str::position('Taylor Otwell', ''));
+assertType('int', Str::position('', ''));
+assertType('false', Str::position('', 'Taylor'));
+assertType('int|false', Str::position('Taylor Otwell', 'Taylor'));
+
+assertType('string|null', Str::replaceMatches('', '', 'Taylor Otwell'));
+assertType('array|null', Str::replaceMatches('', '', ['Taylor', 'Otwell']));
+
+assertType('false', Str::startsWith('Taylor Otwell', []));
+assertType('false', Str::startsWith('', 'Taylor'));
+assertType('bool', Str::startsWith('Taylor Otwell', 'Taylor'));
+assertType('bool', Str::startsWith(123, '1'));
+assertType('bool', Str::startsWith('123', 1));
+assertType('bool', Str::of('123')->startsWith(1));
+
+/** @var Stringable $stringable */
+assertType('bool', Str::startsWith($stringable, '1'));
+assertType('bool', Str::endsWith('123', $stringable));
+
+assertType('true', Str::doesntStartWith('Taylor Otwell', []));
+assertType('true', Str::doesntStartWith('', 'Taylor'));
+assertType('bool', Str::doesntStartWith('Taylor Otwell', 'Taylor'));
+assertType('bool', Str::doesntStartWith(123, '1'));
+assertType('bool', Str::of('123')->doesntStartWith(1));
+
+assertType('\'\'', Str::studly(''));
+assertType('string', Str::studly('Taylor Otwell'));
+
+assertType('\'\'', Str::pascal(''));
+assertType('string', Str::pascal('Taylor Otwell'));
+
+assertType('\'\'', Str::toBase64(''));
+assertType('string', Str::toBase64('Taylor Otwell'));
+
+assertType('\'\'', Str::fromBase64(''));
+assertType('string', Str::fromBase64('Taylor Otwell'));
+
+assertType('\'\'', Str::lcfirst(''));
+assertType('non-empty-string', Str::lcfirst('Taylor Otwell'));
+
+assertType('\'\'', Str::ucfirst(''));
+assertType('non-empty-string', Str::ucfirst('Taylor Otwell'));
+
+assertType('\'\'', Str::ucwords(''));
+assertType('non-empty-string', Str::ucwords('Taylor Otwell'));
+
+assertType('array{}', Str::ucsplit(''));
+assertType('array', Str::ucsplit('Taylor Otwell'));
From 57bfafd2ffbd313b2138512d2e5a6f13366dc698 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 21:55:03 +0000
Subject: [PATCH 16/29] Clarify when the Blade hasStack directive renders
Explain that hasStack renders its body when the stack has content. The
previous sentence described the opposite condition. Source and every
current upstream test are already covered by the coroutine-backed stack
implementation; retain the existing example and runtime behavior.
Correct the upstream wording while reconciling:
https://github.com/laravel/framework/pull/57788
https://github.com/laravel/docs/pull/10913
Compared the current Laravel docs and compiler/stack tests, traced the
compiled negation and coroutine-local stack reads, and obtained final
peer signoff with the reviewed string-helper batch. Diff checks pass.
---
src/docs/blade.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/docs/blade.md b/src/docs/blade.md
index 271f99cc1..6bbc3b6e1 100644
--- a/src/docs/blade.md
+++ b/src/docs/blade.md
@@ -1895,7 +1895,7 @@ If you would like to prepend content onto the beginning of a stack, you should u
@endprepend
```
-The `@hasStack` directive may be used to determine if a stack is empty:
+The `@hasStack` directive may be used to render markup when a stack has content:
```blade
@hasStack('list')
From 7a1ee22f9848f53e5ea1c3c3460ecd685450090e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 22:24:01 +0000
Subject: [PATCH 17/29] Fix Unicode case-insensitive string replacement and
removal
Port Laravel framework PR #60882 from 13.x source
01d008c9b5f32cb7c5e50a9a22273113d810b2a2:
https://github.com/laravel/framework/pull/60882
Route case-insensitive replace() and remove() through the shared upstream
helper so Unicode case pairs match. Preserve positional replacements,
subject keys, literal replacement text, sequential matching, native ASCII
folding and whole-call byte behavior for malformed UTF-8 inputs.
Keep Hypervel's Traversable normalization and native typing. Check the
ASCII fast path before allocating normalized search arrays, and validate
input groups without copying every subject into a flattened array. Keep
the native PCRE failure behavior without fallback machinery.
Port all upstream assertions and the scalar-search/array-replacement error
test. Add regression coverage for malformed searches and replacements,
quoted patterns, literal replacement bytes, keyed inputs and missing
replacement values.
Validation: SupportStrTest (229 tests), Support ParaTest (2648 tests),
full source/type-fixture PHPStan, formatting and diff checks pass.
The final helper was benchmarked against current upstream and independently
reviewed and signed off by claude-laravel-parity.
---
src/support/src/Str.php | 55 ++++++++++++++++++++++++++++++--
tests/Support/SupportStrTest.php | 22 +++++++++++++
2 files changed, 75 insertions(+), 2 deletions(-)
diff --git a/src/support/src/Str.php b/src/support/src/Str.php
index 9d7b02376..f907508db 100644
--- a/src/support/src/Str.php
+++ b/src/support/src/Str.php
@@ -1094,7 +1094,58 @@ public static function replace(string|iterable $search, string|iterable $replace
return $caseSensitive
? str_replace($search, $replace, $subject)
- : str_ireplace($search, $replace, $subject);
+ : static::replaceWhileIgnoringCase($search, $replace, $subject);
+ }
+
+ /**
+ * Replace the given value in the given string regardless of case.
+ *
+ * @param string|string[] $search
+ * @param string|string[] $replace
+ * @param string|string[] $subject
+ * @return ($subject is string ? string : string[])
+ */
+ protected static function replaceWhileIgnoringCase(string|array $search, string|array $replace, string|array $subject): string|array
+ {
+ if (! is_array($search) && is_array($replace)) {
+ return str_ireplace($search, $replace, $subject);
+ }
+
+ if (is_string($search) ? static::isAscii($search) : array_all($search, static::isAscii(...))) {
+ return str_ireplace($search, $replace, $subject);
+ }
+
+ $searches = is_array($search) ? array_values($search) : [$search];
+
+ $replacements = is_array($replace)
+ ? array_values($replace)
+ : array_fill(0, count($searches), $replace);
+
+ // Validate every input first: replacement bytes can invalidate later UTF-8 matching.
+ foreach ([$searches, $replacements, (array) $subject] as $values) {
+ foreach ($values as $value) {
+ if (! preg_match('//u', (string) $value)) {
+ return str_ireplace($search, $replace, $subject);
+ }
+ }
+ }
+
+ foreach ($searches as $index => $term) {
+ $term = (string) $term;
+
+ if ($term === '') {
+ continue;
+ }
+
+ $replacement = (string) ($replacements[$index] ?? '');
+
+ // ASCII terms retain native case folding even alongside Unicode terms.
+ $subject = static::isAscii($term)
+ ? str_ireplace($term, $replacement, $subject)
+ : preg_replace_callback('/' . preg_quote($term, '/') . '/iu', fn (): string => $replacement, $subject);
+ }
+
+ return $subject;
}
/**
@@ -1201,7 +1252,7 @@ public static function remove(string|iterable $search, string $subject, bool $ca
return $caseSensitive
? str_replace($search, '', $subject)
- : str_ireplace($search, '', $subject);
+ : static::replaceWhileIgnoringCase($search, '', $subject);
}
/**
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index 840ebe09f..43ab4bc27 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -1142,6 +1142,25 @@ public function testReplace(): void
$this->assertSame('foo/bar/baz', Str::replace(' ', '/', 'foo bar baz'));
$this->assertSame('foo bar baz', Str::replace(['?1', '?2', '?3'], ['foo', 'bar', 'baz'], '?1 ?2 ?3'));
$this->assertSame(['foo', 'bar', 'baz'], Str::replace(collect(['?1', '?2', '?3']), collect(['foo', 'bar', 'baz']), collect(['?1', '?2', '?3'])));
+
+ $this->assertSame('Xltý kôň', Str::replace('ž', 'X', 'Žltý kôň', false));
+ $this->assertSame('žltý pes', Str::replace('KÔŇ', 'pes', 'žltý kôň', false));
+ $this->assertSame('Xltý pes', Str::replace(['ž', 'KÔŇ'], ['X', 'pes'], 'Žltý kôň', false));
+ $this->assertSame(['Xltý', 'kôň'], Str::replace('ž', 'X', ['Žltý', 'kôň'], false));
+ $this->assertSame('ſ Yito X', Str::replace(['s', 'ž'], ['X', 'Y'], 'ſ žito s', false));
+ $this->assertSame("caf\xC3 X", Str::replace('ž', 'X', "caf\xC3 ž", false));
+ $this->assertSame('É', Str::replace(["\xFF", 'é'], ['X', 'Y'], 'É', false));
+ $this->assertSame("\xFFÉ", Str::replace(['ž', 'é'], ["\xFF", 'x'], 'žÉ', false));
+ $this->assertSame('$1\X', Str::replace('ž.+?', '$1\X', 'Ž.+?', false));
+ $this->assertSame(['label' => 'Xltý pes'], Str::replace(['first' => 'ž', 'second' => 'KÔŇ'], [10 => 'X', 20 => 'pes'], ['label' => 'Žltý kôň'], false));
+ $this->assertSame('Xltý kň', Str::replace(['ž', 'ô'], ['X'], 'Žltý kôň', false));
+ }
+
+ public function testReplaceThrowsForScalarSearchAndArrayReplacement(): void
+ {
+ $this->expectException(TypeError::class);
+
+ Str::replace('ž', ['X'], 'Ž', false);
}
public function testReplaceArray(): void
@@ -1246,6 +1265,9 @@ public function testRemove(): void
$this->assertSame('Fooar', Str::remove(['f', 'b'], 'Foobar'));
$this->assertSame('ooar', Str::remove(['f', 'b'], 'Foobar', false));
$this->assertSame('Foobar', Str::remove(['f', '|'], 'Foo|bar'));
+
+ $this->assertSame('ltý', Str::remove('ž', 'Žltý', false));
+ $this->assertSame('žltý ', Str::remove('KÔŇ', 'žltý kôň', false));
}
public function testReverse(): void
From 94e16b7de1307cc3bc156d09adb82d15fbdfaa87 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 23:24:01 +0000
Subject: [PATCH 18/29] Preserve fractional durations and add date overflow
control
Complete the current Laravel time-helper behavior in Hypervel's shared
mutable/immutable DateHelpers trait. Add the nullable overflow argument,
apply years before months, and preserve returned immutable instances.
Skip zero year/month operations to avoid unnecessary immutable copies.
Correct fractional seconds, minutes, hours and days that Carbon's default
fluent setters silently truncate. Construct whole units and rounded lower
fields with native calendar constants, carrying rounding into the requested
unit. Preserve calendar-day arithmetic, negative values, formatting and
microsecond precision without changing process-global Carbon settings.
Keep the existing integer construction paths.
Build Sleep selector intervals numerically so computed tiny durations do
not fail when Carbon parses PHP's scientific notation. Preserve chained
durations, negative clamping, millisecond rounding and microsecond
truncation. Native interval copying adds about 2-4 microseconds per Sleep
construction; avoid custom parsing or duplicated interval accumulation.
Port every current upstream plus/minus assertion and add focused immutable,
fractional-field, rounding, formatting and DST regressions. Extend existing
Sleep cases instead of duplicating them. Correct factual interval docblocks
and document the public overflow option and fractional duration helpers.
Upstream PRs:
https://github.com/laravel/framework/pull/57856
https://github.com/laravel/framework/pull/59509
https://github.com/laravel/framework/pull/57997
https://github.com/laravel/framework/pull/58006
Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Additional corrections: AI-044 (fractional intervals), AI-045 (Sleep).
Validation: Support ParaTest 2683 tests / 8717 assertions; Cache ParaTest
1462 tests / 5434 assertions with two unavailable-msgpack skips. All changed
test classes pass individually. Full PHPStan source and type fixtures,
formatting and diff checks pass. Self-reviewed and peer signed off.
---
src/docs/helpers.md | 10 ++-
src/support/src/Sleep.php | 11 +--
src/support/src/Traits/DateHelpers.php | 39 +++++++--
src/support/src/functions.php | 75 ++++++++++++++---
tests/Support/SleepTest.php | 35 +++++---
tests/Support/SupportCarbonImmutableTest.php | 35 ++++++++
tests/Support/SupportCarbonTest.php | 18 ++++
.../Support/SupportIntervalFunctionsTest.php | 84 +++++++++++++++++++
8 files changed, 267 insertions(+), 40 deletions(-)
create mode 100644 tests/Support/SupportIntervalFunctionsTest.php
diff --git a/src/docs/helpers.md b/src/docs/helpers.md
index eff07769b..5b3e0fc2c 100644
--- a/src/docs/helpers.md
+++ b/src/docs/helpers.md
@@ -3461,6 +3461,12 @@ return now()->minus(hours: 8);
return now()->minus(weeks: 4);
```
+When adding or subtracting months or years, you may pass `overflow: false` to keep the resulting date within the target month:
+
+```php
+CarbonImmutable::parse('2026-01-31')->plus(months: 1, overflow: false); // 2026-02-28
+```
+
Since the default date is immutable, assign the result of a modifier when you want to retain the changed value:
```php
@@ -3482,7 +3488,7 @@ For a thorough discussion of Carbon and its features, please consult the [offici
#### Interval Functions
-Hypervel also offers `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, and `years` functions that return `CarbonInterval` instances, which extend PHP's [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) class. These functions may be used anywhere that Hypervel accepts a `DateInterval` instance:
+Hypervel also offers `microseconds`, `milliseconds`, `seconds`, `minutes`, `hours`, `days`, `weeks`, `months`, and `years` functions that return `CarbonInterval` instances, which extend PHP's [DateInterval](https://www.php.net/manual/en/class.dateinterval.php) class. These functions may be used anywhere that Hypervel accepts a `DateInterval` instance:
```php
use Hypervel\Support\Facades\Cache;
@@ -3492,6 +3498,8 @@ use function Hypervel\Support\{minutes};
Cache::put('metrics', $metrics, minutes(10));
```
+The functions from `microseconds` through `days` also accept fractional amounts, such as `seconds(1.5)`.
+
### Deferred Functions
diff --git a/src/support/src/Sleep.php b/src/support/src/Sleep.php
index 227ea1819..fb2766507 100644
--- a/src/support/src/Sleep.php
+++ b/src/support/src/Sleep.php
@@ -138,7 +138,8 @@ protected function duration(DateInterval|float|int $duration): static
*/
public function minutes(): static
{
- $this->duration->add('minutes', $this->pullPending());
+ // Build numeric intervals so tiny durations are never parsed from scientific notation.
+ $this->duration->add(minutes($this->pullPending()));
return $this;
}
@@ -156,7 +157,7 @@ public function minute(): static
*/
public function seconds(): static
{
- $this->duration->add('seconds', $this->pullPending());
+ $this->duration->add(seconds($this->pullPending()));
return $this;
}
@@ -174,7 +175,7 @@ public function second(): static
*/
public function milliseconds(): static
{
- $this->duration->add('milliseconds', $this->pullPending());
+ $this->duration->add(microseconds(round($this->pullPending() * Carbon::MICROSECONDS_PER_MILLISECOND)));
return $this;
}
@@ -192,13 +193,13 @@ public function millisecond(): static
*/
public function microseconds(): static
{
- $this->duration->add('microseconds', $this->pullPending());
+ $this->duration->add(microseconds($this->pullPending()));
return $this;
}
/**
- * Sleep for on microsecond.
+ * Sleep for one microsecond.
*/
public function microsecond(): static
{
diff --git a/src/support/src/Traits/DateHelpers.php b/src/support/src/Traits/DateHelpers.php
index 01fdf8b98..b59e73254 100644
--- a/src/support/src/Traits/DateHelpers.php
+++ b/src/support/src/Traits/DateHelpers.php
@@ -33,7 +33,7 @@ public static function createFromId(Uuid|Ulid|string $id): static
}
/**
- * Get the current date / time plus a given amount of time.
+ * Get the date / time plus a given amount of time.
*/
public function plus(
int $years = 0,
@@ -43,16 +43,28 @@ public function plus(
int $hours = 0,
int $minutes = 0,
int $seconds = 0,
- int $microseconds = 0
+ int $microseconds = 0,
+ ?bool $overflow = null
): static {
- return $this->add("
- {$years} years {$months} months {$weeks} weeks {$days} days
+ $date = $this;
+
+ // Zero-unit operations also clone immutable dates.
+ if ($years !== 0) {
+ $date = $date->add('years', $years, $overflow);
+ }
+
+ if ($months !== 0) {
+ $date = $date->add('months', $months, $overflow);
+ }
+
+ return $date->add("
+ {$weeks} weeks {$days} days
{$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds
");
}
/**
- * Get the current date / time minus a given amount of time.
+ * Get the date / time minus a given amount of time.
*/
public function minus(
int $years = 0,
@@ -62,10 +74,21 @@ public function minus(
int $hours = 0,
int $minutes = 0,
int $seconds = 0,
- int $microseconds = 0
+ int $microseconds = 0,
+ ?bool $overflow = null
): static {
- return $this->sub("
- {$years} years {$months} months {$weeks} weeks {$days} days
+ $date = $this;
+
+ if ($years !== 0) {
+ $date = $date->sub('years', $years, $overflow);
+ }
+
+ if ($months !== 0) {
+ $date = $date->sub('months', $months, $overflow);
+ }
+
+ return $date->sub("
+ {$weeks} weeks {$days} days
{$hours} hours {$minutes} minutes {$seconds} seconds {$microseconds} microseconds
");
}
diff --git a/src/support/src/functions.php b/src/support/src/functions.php
index 3c64cf149..81c8dceb2 100644
--- a/src/support/src/functions.php
+++ b/src/support/src/functions.php
@@ -68,7 +68,7 @@ function now(DateTimeZone|UnitEnum|string|null $tz = null): CarbonInterface
}
/**
- * Get the current date / time plus the given number of microseconds.
+ * Create an interval of the given number of microseconds.
*/
function microseconds(int|float $microseconds): CarbonInterval
{
@@ -76,7 +76,7 @@ function microseconds(int|float $microseconds): CarbonInterval
}
/**
- * Get the current date / time plus the given number of milliseconds.
+ * Create an interval of the given number of milliseconds.
*/
function milliseconds(int|float $milliseconds): CarbonInterval
{
@@ -84,39 +84,88 @@ function milliseconds(int|float $milliseconds): CarbonInterval
}
/**
- * Get the current date / time plus the given number of seconds.
+ * Create an interval of the given number of seconds.
*/
function seconds(int|float $seconds): CarbonInterval
{
- return CarbonInterval::seconds($seconds);
+ if (is_int($seconds)) {
+ return CarbonInterval::seconds($seconds);
+ }
+
+ $whole = $seconds < 0 ? ceil($seconds) : floor($seconds);
+ $microseconds = (int) round(($seconds - $whole) * CarbonInterface::MICROSECONDS_PER_SECOND);
+
+ // A rounded fraction can reach a full second, which must not stay in the microsecond field.
+ return CarbonInterval::seconds($whole + intdiv($microseconds, CarbonInterface::MICROSECONDS_PER_SECOND))
+ ->microseconds($microseconds % CarbonInterface::MICROSECONDS_PER_SECOND);
}
/**
- * Get the current date / time plus the given number of minutes.
+ * Create an interval of the given number of minutes.
*/
function minutes(int|float $minutes): CarbonInterval
{
- return CarbonInterval::minutes($minutes);
+ if (is_int($minutes)) {
+ return CarbonInterval::minutes($minutes);
+ }
+
+ $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE;
+ $whole = $minutes < 0 ? ceil($minutes) : floor($minutes);
+ $microseconds = (int) round(($minutes - $whole) * $microsecondsPerMinute);
+ $remainder = $microseconds % $microsecondsPerMinute;
+
+ return CarbonInterval::minutes($whole + intdiv($microseconds, $microsecondsPerMinute))
+ ->seconds(intdiv($remainder, CarbonInterface::MICROSECONDS_PER_SECOND))
+ ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND);
}
/**
- * Get the current date / time plus the given number of hours.
+ * Create an interval of the given number of hours.
*/
function hours(int|float $hours): CarbonInterval
{
- return CarbonInterval::hours($hours);
+ if (is_int($hours)) {
+ return CarbonInterval::hours($hours);
+ }
+
+ $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE;
+ $microsecondsPerHour = $microsecondsPerMinute * CarbonInterface::MINUTES_PER_HOUR;
+ $whole = $hours < 0 ? ceil($hours) : floor($hours);
+ $microseconds = (int) round(($hours - $whole) * $microsecondsPerHour);
+ $remainder = $microseconds % $microsecondsPerHour;
+
+ return CarbonInterval::hours($whole + intdiv($microseconds, $microsecondsPerHour))
+ ->minutes(intdiv($remainder, $microsecondsPerMinute))
+ ->seconds(intdiv($remainder % $microsecondsPerMinute, CarbonInterface::MICROSECONDS_PER_SECOND))
+ ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND);
}
/**
- * Get the current date / time plus the given number of days.
+ * Create an interval of the given number of days.
*/
function days(int|float $days): CarbonInterval
{
- return CarbonInterval::days($days);
+ if (is_int($days)) {
+ return CarbonInterval::days($days);
+ }
+
+ $microsecondsPerMinute = CarbonInterface::MICROSECONDS_PER_SECOND * CarbonInterface::SECONDS_PER_MINUTE;
+ $microsecondsPerHour = $microsecondsPerMinute * CarbonInterface::MINUTES_PER_HOUR;
+ $microsecondsPerDay = $microsecondsPerHour * CarbonInterface::HOURS_PER_DAY;
+ $whole = $days < 0 ? ceil($days) : floor($days);
+ $microseconds = (int) round(($days - $whole) * $microsecondsPerDay);
+ $remainder = $microseconds % $microsecondsPerDay;
+
+ // Keep whole calendar days intact; cascading can turn them into months.
+ return CarbonInterval::days($whole + intdiv($microseconds, $microsecondsPerDay))
+ ->hours(intdiv($remainder, $microsecondsPerHour))
+ ->minutes(intdiv($remainder % $microsecondsPerHour, $microsecondsPerMinute))
+ ->seconds(intdiv($remainder % $microsecondsPerMinute, CarbonInterface::MICROSECONDS_PER_SECOND))
+ ->microseconds($remainder % CarbonInterface::MICROSECONDS_PER_SECOND);
}
/**
- * Get the current date / time plus the given number of weeks.
+ * Create an interval of the given number of weeks.
*/
function weeks(int $weeks): CarbonInterval
{
@@ -124,7 +173,7 @@ function weeks(int $weeks): CarbonInterval
}
/**
- * Get the current date / time plus the given number of months.
+ * Create an interval of the given number of months.
*/
function months(int $months): CarbonInterval
{
@@ -132,7 +181,7 @@ function months(int $months): CarbonInterval
}
/**
- * Get the current date / time plus the given number of years.
+ * Create an interval of the given number of years.
*/
function years(int $years): CarbonInterval
{
diff --git a/tests/Support/SleepTest.php b/tests/Support/SleepTest.php
index ad194e4ef..758c8a40a 100644
--- a/tests/Support/SleepTest.php
+++ b/tests/Support/SleepTest.php
@@ -68,13 +68,15 @@ public function testItCanFakeSleeping()
$this->assertEqualsWithDelta(0, $end - $start, 0.03);
}
- public function testItCanSpecifyMinutes()
+ #[TestWith([1.5, 90_000_000.0])]
+ #[TestWith([0.000001, 60.0])]
+ public function testItCanSpecifyMinutes(float $duration, float $microseconds): void
{
Sleep::fake();
- $sleep = Sleep::for(1.5)->minutes();
+ $sleep = Sleep::for($duration)->minutes();
- $this->assertSame((float) $sleep->duration->totalMicroseconds, 90_000_000.0);
+ $this->assertSame($microseconds, $sleep->duration->totalMicroseconds);
}
public function testItCanSpecifyMinute()
@@ -86,13 +88,15 @@ public function testItCanSpecifyMinute()
$this->assertSame((float) $sleep->duration->totalMicroseconds, 60_000_000.0);
}
- public function testItCanSpecifySeconds()
+ #[TestWith([1.5, 1_500_000.0])]
+ #[TestWith([0.000001, 1.0])]
+ public function testItCanSpecifySeconds(float $duration, float $microseconds): void
{
Sleep::fake();
- $sleep = Sleep::for(1.5)->seconds();
+ $sleep = Sleep::for($duration)->seconds();
- $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500_000.0);
+ $this->assertSame($microseconds, $sleep->duration->totalMicroseconds);
}
public function testItCanSpecifySecond()
@@ -104,13 +108,16 @@ public function testItCanSpecifySecond()
$this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000_000.0);
}
- public function testItCanSpecifyMilliseconds()
+ #[TestWith([1.5, 1_500.0])]
+ #[TestWith([0.0015, 2.0])]
+ #[TestWith([0.000001, 0.0])]
+ public function testItCanSpecifyMilliseconds(float $duration, float $microseconds): void
{
Sleep::fake();
- $sleep = Sleep::for(1.5)->milliseconds();
+ $sleep = Sleep::for($duration)->milliseconds();
- $this->assertSame((float) $sleep->duration->totalMicroseconds, 1_500.0);
+ $this->assertSame($microseconds, $sleep->duration->totalMicroseconds);
}
public function testItCanSpecifyMillisecond()
@@ -122,14 +129,16 @@ public function testItCanSpecifyMillisecond()
$this->assertSame((float) $sleep->duration->totalMicroseconds, 1_000.0);
}
- public function testItCanSpecifyMicroseconds()
+ #[TestWith([1.5, 1.0])]
+ #[TestWith([0.000001, 0.0])]
+ public function testItCanSpecifyMicroseconds(float $duration, float $microseconds): void
{
Sleep::fake();
- $sleep = Sleep::for(1.5)->microseconds();
+ $sleep = Sleep::for($duration)->microseconds();
- // rounded as microseconds is the smallest unit supported...
- $this->assertSame((float) $sleep->duration->totalMicroseconds, 1.0);
+ // Truncated as microseconds is the smallest unit supported...
+ $this->assertSame($microseconds, $sleep->duration->totalMicroseconds);
}
public function testItCanSpecifyMicrosecond()
diff --git a/tests/Support/SupportCarbonImmutableTest.php b/tests/Support/SupportCarbonImmutableTest.php
index 5ec52ae22..c751ce68c 100644
--- a/tests/Support/SupportCarbonImmutableTest.php
+++ b/tests/Support/SupportCarbonImmutableTest.php
@@ -112,6 +112,41 @@ public static function dateUnitProvider(): array
];
}
+ #[DataProvider('overflowProvider')]
+ public function testPlusAndMinusRespectOverflowSettings(
+ string $method,
+ string $unit,
+ string $original,
+ string $clamped,
+ string $overflowed,
+ ): void {
+ $date = CarbonImmutable::parse($original)->settings(['monthOverflow' => false, 'yearOverflow' => false]);
+
+ $this->assertSame($clamped, $date->{$method}(...[$unit => 1])->toDateString());
+ $this->assertSame($overflowed, $date->{$method}(...[$unit => 1], overflow: true)->toDateString());
+ $this->assertSame($original, $date->toDateString());
+ }
+
+ /**
+ * Provide month and year overflow boundaries for both operations.
+ */
+ public static function overflowProvider(): array
+ {
+ return [
+ 'add month' => ['plus', 'months', '2026-01-31', '2026-02-28', '2026-03-03'],
+ 'subtract month' => ['minus', 'months', '2026-05-31', '2026-04-30', '2026-05-01'],
+ 'add year' => ['plus', 'years', '2024-02-29', '2025-02-28', '2025-03-01'],
+ 'subtract year' => ['minus', 'years', '2024-02-29', '2023-02-28', '2023-03-01'],
+ ];
+ }
+
+ public function testPlusAppliesYearsBeforeMonths(): void
+ {
+ $date = CarbonImmutable::parse('2024-02-29');
+
+ $this->assertSame('2025-03-28', $date->plus(years: 1, months: 1, overflow: false)->toDateString());
+ }
+
public function testConversionsPreserveHypervelClassesAndDateState(): void
{
$immutable = CarbonImmutable::parse('2026-07-22 12:34:56.123456', 'Pacific/Auckland')
diff --git a/tests/Support/SupportCarbonTest.php b/tests/Support/SupportCarbonTest.php
index 2dcc78b59..71d26f08e 100644
--- a/tests/Support/SupportCarbonTest.php
+++ b/tests/Support/SupportCarbonTest.php
@@ -132,6 +132,24 @@ public function testCreateFromId(): void
$this->assertEquals('2023-05-12 03:21:18.117185', $uuidv7->toDateTimeString('microsecond'));
}
+ public function testPlus(): void
+ {
+ $carbon = Carbon::parse('2026-01-31');
+ $this->assertSame('2026-03-03', $carbon->plus(months: 1, overflow: true)->toDateString());
+
+ $carbon = Carbon::parse('2026-01-31');
+ $this->assertSame('2026-02-28', $carbon->plus(months: 1, overflow: false)->toDateString());
+ }
+
+ public function testMinus(): void
+ {
+ $carbon = Carbon::parse('2026-05-31');
+ $this->assertSame('2026-05-01', $carbon->minus(months: 1, overflow: true)->toDateString());
+
+ $carbon = Carbon::parse('2026-05-31');
+ $this->assertSame('2026-04-30', $carbon->minus(months: 1, overflow: false)->toDateString());
+ }
+
public function testCreateFromIdRejectsNonTimeBasedUuid(): void
{
$this->expectException(InvalidArgumentException::class);
diff --git a/tests/Support/SupportIntervalFunctionsTest.php b/tests/Support/SupportIntervalFunctionsTest.php
new file mode 100644
index 000000000..058a6584c
--- /dev/null
+++ b/tests/Support/SupportIntervalFunctionsTest.php
@@ -0,0 +1,84 @@
+assertSame($microseconds, $interval->totalMicroseconds);
+ $this->assertSame($fields, [$interval->d, $interval->h, $interval->i, $interval->s, $interval->microseconds]);
+ }
+
+ /**
+ * Provide whole, fractional, and rounded interval values.
+ */
+ public static function intervalProvider(): array
+ {
+ return [
+ 'fractional seconds' => [seconds(...), 1.4, 1400000, [0, 0, 0, 1, 400000]],
+ 'fractional minutes' => [minutes(...), 1.4, 84000000, [0, 0, 1, 24, 0]],
+ 'fractional hours' => [hours(...), 1.4, 5040000000, [0, 1, 24, 0, 0]],
+ 'fractional days' => [days(...), 1.4, 120960000000, [1, 9, 36, 0, 0]],
+ 'negative seconds' => [seconds(...), -1.4, -1400000, [0, 0, 0, -1, -400000]],
+ 'negative minutes' => [minutes(...), -1.4, -84000000, [0, 0, -1, -24, 0]],
+ 'negative hours' => [hours(...), -1.4, -5040000000, [0, -1, -24, 0, 0]],
+ 'negative days' => [days(...), -1.4, -120960000000, [-1, -9, -36, 0, 0]],
+ 'tiny seconds' => [seconds(...), 0.000001, 1, [0, 0, 0, 0, 1]],
+ 'tiny minutes' => [minutes(...), 0.000001, 60, [0, 0, 0, 0, 60]],
+ 'tiny hours' => [hours(...), 0.000001, 3600, [0, 0, 0, 0, 3600]],
+ 'tiny days' => [days(...), 0.000001, 86400, [0, 0, 0, 0, 86400]],
+ 'integer seconds' => [seconds(...), 2, 2000000, [0, 0, 0, 2, 0]],
+ 'integer minutes' => [minutes(...), 2, 120000000, [0, 0, 2, 0, 0]],
+ 'integer hours' => [hours(...), 2, 7200000000, [0, 2, 0, 0, 0]],
+ 'integer days' => [days(...), 2, 172800000000, [2, 0, 0, 0, 0]],
+ 'rounded second' => [seconds(...), 0.9999999, 1000000, [0, 0, 0, 1, 0]],
+ 'rounded day' => [days(...), 1.9999999999999, 172800000000, [2, 0, 0, 0, 0]],
+ 'days remain days' => [days(...), 31.5, 2721600000000, [31, 12, 0, 0, 0]],
+ ];
+ }
+
+ public function testFractionalUnitsAreIncludedInIntervalFormatting(): void
+ {
+ $this->assertSame('1 minute 24 seconds', minutes(1.4)->forHumans());
+ $this->assertSame('P31DT12H', days(31.5)->spec());
+ }
+
+ #[DataProvider('calendarDayProvider')]
+ public function testDayIntervalsPreserveCalendarArithmetic(float $amount, string $expected): void
+ {
+ $date = CarbonImmutable::parse('2026-03-06 20:00:00', 'America/New_York');
+
+ $this->assertSame($expected, $date->add(days($amount))->format('Y-m-d H:i:sP'));
+ }
+
+ /**
+ * Provide calendar-day intervals crossing daylight saving time.
+ */
+ public static function calendarDayProvider(): array
+ {
+ return [
+ 'fractional day' => [1.5, '2026-03-08 09:00:00-04:00'],
+ 'rounding carries into days' => [1.9999999999999, '2026-03-08 20:00:00-04:00'],
+ 'days do not cascade into months' => [31.5, '2026-04-07 08:00:00-04:00'],
+ ];
+ }
+}
From 98b8eb5a24da0302bf8a2f715e26516dee0b61e5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 6 Sep 2026 23:24:19 +0000
Subject: [PATCH 19/29] Freeze the array-store increment test clock
Port the current Laravel regression setup for incrementing a missing cache
key. Freeze time before construction and retain the captured immutable date
when advancing ten years, preserving every upstream assertion.
Hypervel's permanent-entry sentinel already bypasses expiration checks;
this completes upstream test parity without changing cache source behavior.
https://github.com/laravel/framework/pull/57905
Porting source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Validation: CacheArrayStoreTest 55 tests / 110 assertions; Cache ParaTest
1462 tests / 5434 assertions with two unavailable-msgpack skips. Formatting
and diff checks pass. Self-reviewed and peer signed off with the time batch.
---
tests/Cache/CacheArrayStoreTest.php | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php
index 3b67367ba..a62c014f9 100644
--- a/tests/Cache/CacheArrayStoreTest.php
+++ b/tests/Cache/CacheArrayStoreTest.php
@@ -143,13 +143,15 @@ public function testIncrementNonNumericValues(): void
public function testNonExistingKeysCanBeIncremented(): void
{
+ CarbonImmutable::setTestNow($now = CarbonImmutable::now());
+
$store = new ArrayStore;
$result = $store->increment('foo');
$this->assertEquals(1, $result);
$this->assertEquals(1, $store->get('foo'));
// Will be there forever
- CarbonImmutable::setTestNow(CarbonImmutable::now()->addYears(10));
+ CarbonImmutable::setTestNow($now->addYears(10));
$this->assertEquals(1, $store->get('foo'));
}
From 648132964dce27181c23441f55f062a6558eb6a0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:32:50 +0000
Subject: [PATCH 20/29] Port PHP 8.5-compatible path, mail and word-count tests
Port all three test corrections from Laravel framework PR #59251 using
13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2:
https://github.com/laravel/framework/pull/59251
Use the percent-encoded multibyte path fixture, discard the quoted-printable
soft-break marker when extracting an embedded image Content-ID, and compare
locale-dependent Cyrillic word counts with the native PHP result. Preserve
all other cases and assertions, including explicit character-list counts.
Add native return types to the touched path test methods and callbacks.
The mail correction completes current test fidelity for the embedded-image
fix in https://github.com/laravel/framework/pull/57726. Its later CID-based
rendering and in-memory attachment corrections are already implemented and
covered: https://github.com/laravel/framework/pull/58173 and
https://github.com/laravel/framework/pull/60361.
Validation: each changed test file passed immediately; the combined request,
path, string, mail and notification run passed 441 tests / 2126 assertions.
Full formatter changed zero files; git diff --check passed. Peer review
signed off on the complete five-file tests/documentation batch. No source
behavior changed; local validation used PHP 8.4.23.
---
.../Http/Middleware/ValidatePathEncodingTest.php | 10 +++++-----
tests/Integration/Mail/SendingMarkdownMailTest.php | 4 ++--
tests/Support/SupportStrTest.php | 7 +++++--
3 files changed, 12 insertions(+), 9 deletions(-)
diff --git a/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php b/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php
index f56cb0b63..16d3089e4 100644
--- a/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php
+++ b/tests/Foundation/Http/Middleware/ValidatePathEncodingTest.php
@@ -18,8 +18,8 @@ class ValidatePathEncodingTest extends TestCase
#[TestWith(['valid-path'])]
#[TestWith(['ä'])]
#[TestWith(['with%20space'])]
- #[TestWith(['汉字字符集'])]
- public function testValidPathsArePassing(string $path)
+ #[TestWith(['%E6%B1%89%E5%AD%97%E5%AD%97%E7%AC%A6%E9%9B%86'])]
+ public function testValidPathsArePassing(string $path): void
{
$middleware = new ValidatePathEncoding;
$symfonyRequest = new SymfonyRequest;
@@ -27,7 +27,7 @@ public function testValidPathsArePassing(string $path)
$symfonyRequest->server->set('REQUEST_URI', $path);
$request = Request::createFromBase($symfonyRequest);
- $response = $middleware->handle($request, fn () => new Response('OK'));
+ $response = $middleware->handle($request, fn (): Response => new Response('OK'));
$this->assertSame(200, $response->status());
$this->assertSame('OK', $response->content());
@@ -35,7 +35,7 @@ public function testValidPathsArePassing(string $path)
#[TestWith(['%C0'])]
#[TestWith(['%c0'])]
- public function testInvalidPathsAreFailing(string $path)
+ public function testInvalidPathsAreFailing(string $path): void
{
$middleware = new ValidatePathEncoding;
$symfonyRequest = new SymfonyRequest;
@@ -44,7 +44,7 @@ public function testInvalidPathsAreFailing(string $path)
$request = Request::createFromBase($symfonyRequest);
try {
- $middleware->handle($request, fn () => new Response('OK'));
+ $middleware->handle($request, fn (): Response => new Response('OK'));
$this->fail('MalformedUrlExceptions should have been thrown.');
} catch (MalformedUrlException $e) {
diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php
index 94cc9e202..fb57321a1 100644
--- a/tests/Integration/Mail/SendingMarkdownMailTest.php
+++ b/tests/Integration/Mail/SendingMarkdownMailTest.php
@@ -85,9 +85,9 @@ public function testEmbed(): void
$email = $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->getOriginalMessage()->toString();
- $cid = explode(' cid:', (new Stringable($email))->explode("\r\n")
+ $cid = rtrim(explode(' cid:', (new Stringable($email))->explode("\r\n")
->filter(fn (string $line): bool => str_contains($line, ' content: cid:'))
- ->first())[1];
+ ->first())[1], '=');
$filename = explode('Embed file: ', (new Stringable($email))->explode("\r\n")
->filter(fn (string $line): bool => str_contains($line, ' file:'))
diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php
index 43ab4bc27..dcf720b81 100644
--- a/tests/Support/SupportStrTest.php
+++ b/tests/Support/SupportStrTest.php
@@ -1817,8 +1817,11 @@ public function testWordCount(): void
$this->assertEquals(2, Str::wordCount('Hello, world!'));
$this->assertEquals(10, Str::wordCount('Hi, this is my first contribution to the Hypervel framework.'));
- $this->assertEquals(0, Str::wordCount('мама'));
- $this->assertEquals(0, Str::wordCount('мама мыла раму'));
+ // str_word_count() without $characters does not reliably handle multibyte
+ // strings — results depend on the system locale's isalpha() behavior
+ // (e.g. macOS 15+ changed LC_CTYPE defaults). See php/php-src#19828.
+ $this->assertEquals(str_word_count('мама'), Str::wordCount('мама'));
+ $this->assertEquals(str_word_count('мама мыла раму'), Str::wordCount('мама мыла раму'));
$this->assertEquals(1, Str::wordCount('мама', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'));
$this->assertEquals(3, Str::wordCount('мама мыла раму', 'абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ'));
From 5434bd1e0a7a086e2d160dc99595b2b22e74d0b3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:33:07 +0000
Subject: [PATCH 21/29] Document fluent request input and verify nonempty
defaults
Complete the public documentation and default-value coverage for Laravel
framework PR #57840:
https://github.com/laravel/framework/pull/57840
The current 13.x implementation at
01d008c9b5f32cb7c5e50a9a22273113d810b2a2 and all three upstream assertions
were already present. Add two assertions showing that a nonempty default
is used for both null and absent input, preserving the existing assertions.
Document property access, default values and selecting an array of input
keys beside the other request input getters. Laravel's local documentation
at 2914ba0b06c6be40c2f1f992555853f6266707d6 has no corresponding section;
use concise public-facing prose without framework implementation details.
Validation: HttpRequestTest passed 152 tests / 624 assertions. The combined
six-class selection passed 441 tests / 2126 assertions; full formatter and
git diff --check passed. Peer reviewed and signed off on the final batch.
No production code or API behavior changed.
---
src/docs/requests.md | 23 +++++++++++++++++++++++
tests/Http/HttpRequestTest.php | 2 ++
2 files changed, 25 insertions(+)
diff --git a/src/docs/requests.md b/src/docs/requests.md
index 2d01d9031..311c5b5b4 100644
--- a/src/docs/requests.md
+++ b/src/docs/requests.md
@@ -448,6 +448,29 @@ Input values containing arrays may be retrieved using the `array` method. This m
$versions = $request->array('versions');
```
+
+#### Retrieving Fluent Input Values
+
+The `fluent` method retrieves input as a `Hypervel\Support\Fluent` instance, allowing you to access its values as properties:
+
+```php
+$user = $request->fluent('user');
+
+$name = $user->name;
+```
+
+If the input is missing or `null`, an empty instance is returned. You may pass an array of default values as the second argument:
+
+```php
+$user = $request->fluent('user', ['name' => 'Guest']);
+```
+
+You may also pass an array of keys to build the instance from only those input values:
+
+```php
+$user = $request->fluent(['name', 'role']);
+```
+
#### Retrieving Date Input Values
diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php
index 73089e504..503e8fa7b 100644
--- a/tests/Http/HttpRequestTest.php
+++ b/tests/Http/HttpRequestTest.php
@@ -791,6 +791,8 @@ public function testFluentMethod(): void
$this->assertSame(['name' => 'Michael', 'role' => 'admin'], $request->fluent('user')->toArray());
$this->assertSame([], $request->fluent('users')->toArray());
$this->assertSame([], $request->fluent('not_found')->toArray());
+ $this->assertSame(['name' => 'Guest'], $request->fluent('users', ['name' => 'Guest'])->toArray());
+ $this->assertSame(['name' => 'Guest'], $request->fluent('not_found', ['name' => 'Guest'])->toArray());
}
public function testStringMethod(): void
From daa183cce57799d0dde552897252a7fd0210dc90 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 02:52:41 +0000
Subject: [PATCH 22/29] Port mail address validation and correct supported
recipient types
Reject line breaks in raw mail addresses before Symfony normalizes them,
including array recipients, sender overrides, return paths and mailable
address construction. Preserve the current Laravel helper boundaries and
all applicable upstream mailer and validator regression assertions.
Port Laravel PRs:
https://github.com/laravel/framework/pull/60151
https://github.com/laravel/framework/pull/60202
Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Adapt the guard to scan strings only: required Symfony Mime ^8.1 already
validates immutable Address objects. Keep the exact current Symfony test
exception instead of compatibility expectations for unsupported releases.
Use native types and correctly typed view mocks with ArrayTransport.
Also fix AI-046: nested recipient records with omitted or null names now
use the shared nullable-name construction path, and returnPath accepts and
preserves supported Symfony Address instances. Add focused regressions for
these type defects and independently normalized address entry points.
Validation: each changed test file passes; ParaTest Mail 277 tests/1161
assertions, Validation 1722/5904, and mail integration consumers 23/57.
Full source/type PHPStan and formatting pass on PHP 8.4.23. Self-reviewed
and signed off by claude-laravel-parity; no user-documentation changes.
---
src/mail/src/Mailables/Address.php | 5 ++
src/mail/src/Message.php | 71 +++++++++++++++-----
tests/Mail/MailMailerTest.php | 33 +++++++++
tests/Mail/MailMessageTest.php | 40 +++++++++++
tests/Mail/MailableAlternativeSyntaxTest.php | 11 +++
tests/Validation/ValidationValidatorTest.php | 14 ++--
6 files changed, 152 insertions(+), 22 deletions(-)
diff --git a/src/mail/src/Mailables/Address.php b/src/mail/src/Mailables/Address.php
index 211efc187..6a9794a8d 100644
--- a/src/mail/src/Mailables/Address.php
+++ b/src/mail/src/Mailables/Address.php
@@ -4,6 +4,8 @@
namespace Hypervel\Mail\Mailables;
+use InvalidArgumentException;
+
class Address
{
/**
@@ -16,5 +18,8 @@ public function __construct(
public string $address,
public ?string $name = null
) {
+ if (preg_match('/[\r\n]/', $address) > 0) {
+ throw new InvalidArgumentException('Email addresses may not contain line break characters.');
+ }
}
}
diff --git a/src/mail/src/Message.php b/src/mail/src/Message.php
index ba09abab8..f5afba8cb 100644
--- a/src/mail/src/Message.php
+++ b/src/mail/src/Message.php
@@ -7,6 +7,7 @@
use Hypervel\Contracts\Mail\Attachable;
use Hypervel\Support\Collection;
use Hypervel\Support\Traits\ForwardsCalls;
+use InvalidArgumentException;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
use Symfony\Component\Mime\Part\DataPart;
@@ -33,8 +34,8 @@ public function __construct(
public function from(array|string $address, ?string $name = null): static
{
is_array($address)
- ? $this->message->from(...$address)
- : $this->message->from(new Address($address, (string) $name));
+ ? $this->message->from(...$this->ensureAddressesAreSafe($address))
+ : $this->message->from($this->createAddress($address, (string) $name));
return $this;
}
@@ -45,8 +46,8 @@ public function from(array|string $address, ?string $name = null): static
public function sender(array|string $address, ?string $name = null): static
{
is_array($address)
- ? $this->message->sender(...$address)
- : $this->message->sender(new Address($address, (string) $name));
+ ? $this->message->sender(...$this->ensureAddressesAreSafe($address))
+ : $this->message->sender($this->createAddress($address, (string) $name));
return $this;
}
@@ -54,8 +55,10 @@ public function sender(array|string $address, ?string $name = null): static
/**
* Set the "return path" of the message.
*/
- public function returnPath(string $address): static
+ public function returnPath(Address|string $address): static
{
+ $this->ensureAddressIsSafe($address);
+
$this->message->returnPath($address);
return $this;
@@ -68,8 +71,8 @@ public function to(array|string $address, ?string $name = null, bool $override =
{
if ($override) {
is_array($address)
- ? $this->message->to(...$address)
- : $this->message->to(new Address($address, (string) $name));
+ ? $this->message->to(...$this->ensureAddressesAreSafe($address))
+ : $this->message->to($this->createAddress($address, (string) $name));
return $this;
}
@@ -99,8 +102,8 @@ public function cc(array|string $address, ?string $name = null, bool $override =
{
if ($override) {
is_array($address)
- ? $this->message->cc(...$address)
- : $this->message->cc(new Address($address, (string) $name));
+ ? $this->message->cc(...$this->ensureAddressesAreSafe($address))
+ : $this->message->cc($this->createAddress($address, (string) $name));
return $this;
}
@@ -130,8 +133,8 @@ public function bcc(array|string $address, ?string $name = null, bool $override
{
if ($override) {
is_array($address)
- ? $this->message->bcc(...$address)
- : $this->message->bcc(new Address($address, (string) $name));
+ ? $this->message->bcc(...$this->ensureAddressesAreSafe($address))
+ : $this->message->bcc($this->createAddress($address, (string) $name));
return $this;
}
@@ -170,30 +173,64 @@ protected function addAddresses(array|string $address, ?string $name, string $ty
if (is_array($address)) {
$type = lcfirst($type);
- $addresses = (new Collection($address))->map(function ($address, $key) {
+ $addresses = (new Collection($address))->map(function (Address|array|string|null $address, int|string $key): Address|string {
if (is_string($key) && is_string($address)) {
- return new Address($key, $address);
+ return $this->createAddress($key, $address);
}
if (is_array($address)) {
- return new Address($address['email'] ?? $address['address'], $address['name'] ?? null);
+ return $this->createAddress($address['email'] ?? $address['address'], $address['name'] ?? null);
}
if (is_null($address)) {
- return new Address($key);
+ return $this->createAddress($key);
}
- return $address;
+ return $this->ensureAddressIsSafe($address);
})->all();
$this->message->{"{$type}"}(...$addresses);
} else {
- $this->message->{"add{$type}"}(new Address($address, (string) $name));
+ $this->message->{"add{$type}"}($this->createAddress($address, (string) $name));
}
return $this;
}
+ /**
+ * Create a safe Symfony address instance.
+ */
+ protected function createAddress(string $address, ?string $name = null): Address
+ {
+ $this->ensureAddressIsSafe($address);
+
+ return new Address($address, (string) $name);
+ }
+
+ /**
+ * Ensure the given address cannot inject additional headers or commands.
+ */
+ protected function ensureAddressIsSafe(Address|string $address): Address|string
+ {
+ // Check raw strings before Symfony trims them; constructed Address instances are already validated.
+ if (is_string($address) && preg_match('/[\r\n]/', $address) > 0) {
+ throw new InvalidArgumentException('Email addresses may not contain line break characters.');
+ }
+
+ return $address;
+ }
+
+ /**
+ * Ensure the given addresses cannot inject additional headers or commands.
+ *
+ * @param array $addresses
+ * @return array
+ */
+ protected function ensureAddressesAreSafe(array $addresses): array
+ {
+ return array_map(fn (Address|string $address): Address|string => $this->ensureAddressIsSafe($address), $addresses);
+ }
+
/**
* Add an address debug header for a list of recipients.
*
diff --git a/tests/Mail/MailMailerTest.php b/tests/Mail/MailMailerTest.php
index 02b053295..0c8785f38 100644
--- a/tests/Mail/MailMailerTest.php
+++ b/tests/Mail/MailMailerTest.php
@@ -19,7 +19,10 @@
use Hypervel\Support\HtmlString;
use Hypervel\Support\Testing\Fakes\QueueFake;
use Hypervel\Testbench\TestCase;
+use InvalidArgumentException;
use Mockery as m;
+use Symfony\Component\Mime\Address;
+use Symfony\Component\Mime\Exception\InvalidArgumentException as MimeInvalidArgumentException;
class MailMailerTest extends TestCase
{
@@ -235,6 +238,36 @@ public function testToAllowsEmailAndName(): void
$this->assertSame('Taylor Otwell', $recipients[0]->getName());
}
+ public function testMailerRejectsAddressesContainingLineBreaks(): void
+ {
+ $renderedView = m::mock(ViewContract::class);
+ $renderedView->expects('render')->andReturn('rendered.view');
+ $view = m::mock(ViewFactory::class);
+ $view->expects('make')->andReturn($renderedView);
+ $mailer = new Mailer('array', $view, new ArrayTransport);
+
+ $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.'));
+
+ $mailer->send('foo', ['data'], function (Message $message): void {
+ $message->to("\"foo\r\nBcc: victim@example.com\"@example.com")->from('hello@hypervel.org');
+ });
+ }
+
+ public function testMailerRejectsSymfonyAddressesContainingLineBreaks(): void
+ {
+ $renderedView = m::mock(ViewContract::class);
+ $renderedView->expects('render')->andReturn('rendered.view');
+ $view = m::mock(ViewFactory::class);
+ $view->expects('make')->andReturn($renderedView);
+ $mailer = new Mailer('array', $view, new ArrayTransport);
+
+ $this->expectExceptionObject(new MimeInvalidArgumentException('Email address contains control characters.'));
+
+ $mailer->send('foo', ['data'], function (Message $message): void {
+ $message->to(new Address("\"foo\r\nBcc: victim@example.com\"@example.com"))->from('hello@hypervel.org');
+ });
+ }
+
public function testGlobalFromIsRespectedOnAllMessages(): void
{
$view = $this->mockView();
diff --git a/tests/Mail/MailMessageTest.php b/tests/Mail/MailMessageTest.php
index cbdcc9a71..7e0e9b678 100644
--- a/tests/Mail/MailMessageTest.php
+++ b/tests/Mail/MailMessageTest.php
@@ -11,6 +11,8 @@
use Hypervel\Support\Str;
use Hypervel\Testing\ParallelTesting;
use Hypervel\Tests\TestCase;
+use InvalidArgumentException;
+use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
@@ -55,6 +57,11 @@ public function testReturnPathMethod(): void
{
$this->assertInstanceOf(Message::class, $message = $this->message->returnPath('foo@bar.baz'));
$this->assertEquals(new Address('foo@bar.baz'), $message->getSymfonyMessage()->getReturnPath());
+
+ $address = new Address('person@example.test');
+ $this->message->returnPath($address);
+
+ $this->assertSame($address, $this->message->getSymfonyMessage()->getReturnPath());
}
public function testToMethod(): void
@@ -64,6 +71,12 @@ public function testToMethod(): void
$this->assertInstanceOf(Message::class, $message = $this->message->to(['bar@bar.baz' => 'Bar']));
$this->assertEquals(new Address('bar@bar.baz', 'Bar'), $message->getSymfonyMessage()->getTo()[0]);
+
+ $this->message->to([['email' => 'person@example.test']]);
+ $this->assertEquals(new Address('person@example.test'), $this->message->getSymfonyMessage()->getTo()[0]);
+
+ $this->message->to([['address' => 'another@example.test', 'name' => null]]);
+ $this->assertEquals(new Address('another@example.test'), $this->message->getSymfonyMessage()->getTo()[0]);
}
public function testToMethodWithOverride(): void
@@ -90,6 +103,33 @@ public function testReplyToMethod(): void
$this->assertEquals(new Address('foo@bar.baz', 'Foo'), $message->getSymfonyMessage()->getReplyTo()[0]);
}
+ #[DataProvider('addressesContainingLineBreaks')]
+ public function testAddressEntryPointsRejectLineBreaks(string $method, array $arguments): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.'));
+
+ $this->message->{$method}(...$arguments);
+ }
+
+ /**
+ * Provide the independently normalized address forms.
+ */
+ public static function addressesContainingLineBreaks(): iterable
+ {
+ $address = "person@example.test\n";
+
+ yield 'from list' => ['from', [[$address]]];
+ yield 'sender list' => ['sender', [[$address]]];
+ yield 'to override' => ['to', [[$address], null, true]];
+ yield 'cc override' => ['cc', [[$address], null, true]];
+ yield 'bcc override' => ['bcc', [[$address], null, true]];
+ yield 'mapped name' => ['to', [[$address => 'Person']]];
+ yield 'nested address' => ['to', [[['email' => $address]]]];
+ yield 'mapped null name' => ['to', [[$address => null]]];
+ yield 'reply-to list' => ['replyTo', [[$address]]];
+ yield 'return path' => ['returnPath', [$address]];
+ }
+
public function testSubjectMethod(): void
{
$this->assertInstanceOf(Message::class, $message = $this->message->subject('foo'));
diff --git a/tests/Mail/MailableAlternativeSyntaxTest.php b/tests/Mail/MailableAlternativeSyntaxTest.php
index c20dfe7f1..5a3b68d58 100644
--- a/tests/Mail/MailableAlternativeSyntaxTest.php
+++ b/tests/Mail/MailableAlternativeSyntaxTest.php
@@ -9,6 +9,8 @@
use Hypervel\Mail\Mailables\Content;
use Hypervel\Mail\Mailables\Envelope;
use Hypervel\Tests\TestCase;
+use InvalidArgumentException;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionClass;
class MailableAlternativeSyntaxTest extends TestCase
@@ -42,6 +44,15 @@ public function testBasicMailableInspection(): void
$this->assertEquals(1, count($mailable->bcc));
}
+ #[TestWith(["person@example.test\r"])]
+ #[TestWith(["person@example.test\n"])]
+ public function testAddressRejectsLineBreaks(string $address): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Email addresses may not contain line break characters.'));
+
+ new Address($address);
+ }
+
public function testEnvelopesCanReceiveAdditionalRecipients(): void
{
$envelope = new Envelope(to: ['taylor@example.com']);
diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php
index c470aa023..71b3b8225 100755
--- a/tests/Validation/ValidationValidatorTest.php
+++ b/tests/Validation/ValidationValidatorTest.php
@@ -46,6 +46,7 @@
use RuntimeException;
use SplFileInfo;
use stdClass;
+use Stringable as StringableInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile as SymfonyUploadedFile;
use UnitEnum;
@@ -5025,7 +5026,7 @@ public function testValidateMacAddress()
$this->assertTrue($v->passes());
}
- public function testValidateEmail()
+ public function testValidateEmail(): void
{
$trans = $this->getArrayTranslator();
$v = new Validator($trans, ['x' => 'aslsdlks'], ['x' => 'Email']);
@@ -5035,8 +5036,8 @@ public function testValidateEmail()
$this->assertFalse($v->passes());
$v = new Validator($trans, [
- 'x' => new class implements \Stringable {
- public function __toString()
+ 'x' => new class implements StringableInterface {
+ public function __toString(): string
{
return 'aslsdlks';
}
@@ -5045,8 +5046,8 @@ public function __toString()
$this->assertFalse($v->passes());
$v = new Validator($trans, [
- 'x' => new class implements \Stringable {
- public function __toString()
+ 'x' => new class implements StringableInterface {
+ public function __toString(): string
{
return 'foo@gmail.com';
}
@@ -5056,6 +5057,9 @@ public function __toString()
$v = new Validator($trans, ['x' => 'foo@gmail.com'], ['x' => 'Email']);
$this->assertTrue($v->passes());
+
+ $v = new Validator($trans, ['x' => "\"foo\r\nBcc: victim@example.com\"@example.com"], ['x' => 'Email']);
+ $this->assertFalse($v->passes());
}
public function testValidateEmailWithInternationalCharacters()
From c999b571f46376f7e50995eddff793059ca321b4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 03:24:49 +0000
Subject: [PATCH 23/29] Port global queue pause and resume controls
Add pauseAll/resumeAll and queue:pause/queue:resume --all using the current
Laravel 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Global resume leaves individually paused queues unchanged. Read the global
cache key separately before the existing per-queue batch so cluster proxies
do not receive the cross-slot batch fixed upstream. Keep Laravel cache keys
for interoperability, pooled cache access, native types, named container
resolution and listener guards without adding worker-held pause state.
Preserve the supported queue name "0" with explicit null/empty argument
checks instead of upstream's truthiness check. Keep the queue:continue alias,
disabled-pause gate and existing worker interruption behavior.
Port every applicable current manager/command regression and both command
signature fixtures, including Hypervel's existing dispatcher option. Add
focused global/individual state, alias, disabled-global and zero-name checks.
Import the public --all documentation from the pinned Laravel docs, retaining
Hypervel's established primary command spelling.
Upstream PRs:
https://github.com/laravel/framework/pull/57800
https://github.com/laravel/framework/pull/61126
https://github.com/laravel/framework/pull/61139
https://github.com/laravel/framework/pull/60023
Validation: Queue and Console ParaTest suites pass (1198 tests, 4464
assertions), full WorkCommand integration class passes with SQLite
(18 tests, 58 assertions), and the final command-test correction passes
(11 tests, 44 assertions). Full source/type-fixture PHPStan and formatting
pass on PHP 8.4.23. Self-reviewed and signed off by claude-laravel-parity.
---
src/docs/queues.md | 14 +-
src/queue/src/Console/PauseCommand.php | 29 +++-
src/queue/src/Console/ResumeCommand.php | 27 +++-
src/queue/src/Events/QueuesPaused.php | 9 ++
src/queue/src/Events/QueuesResumed.php | 9 ++
src/queue/src/QueueManager.php | 70 ++++++++--
src/support/src/Facades/Queue.php | 2 +
tests/Console/CommandSignatureTest.php | 100 +++++++++++++
tests/Console/Fixtures/command_signatures.php | 90 ++++++++++++
.../Scheduling/QueuePauseCommandTest.php | 131 ++++++++++++++++++
tests/Integration/Queue/WorkCommandTest.php | 3 +-
tests/Queue/QueuePauseResumeTest.php | 113 ++++++++++++++-
12 files changed, 572 insertions(+), 25 deletions(-)
create mode 100644 src/queue/src/Events/QueuesPaused.php
create mode 100644 src/queue/src/Events/QueuesResumed.php
create mode 100644 tests/Console/CommandSignatureTest.php
create mode 100644 tests/Console/Fixtures/command_signatures.php
create mode 100644 tests/Console/Scheduling/QueuePauseCommandTest.php
diff --git a/src/docs/queues.md b/src/docs/queues.md
index bba6ea549..46055fe9f 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -2855,13 +2855,25 @@ php artisan queue:pause database:default
In this example, `database` is the queue connection name and `default` is the queue name. Once a queue is paused, any workers processing jobs from that queue will continue to finish their current job, but will not pick up any new jobs until the queue is resumed.
+To pause job processing for every queue on every connection, use the `--all` option:
+
+```shell
+php artisan queue:pause --all
+```
+
To resume processing jobs on a paused queue, use the `queue:resume` command:
```shell
php artisan queue:resume database:default
```
-After resuming a queue, workers will begin processing new jobs from that queue immediately. The `queue:continue` command is available as an alias for `queue:resume`. Note that pausing a queue does not stop the worker process itself - it only prevents the worker from processing new jobs from the specified queue.
+To resume job processing for every queue on every connection, use the `--all` option with the `queue:resume` command:
+
+```shell
+php artisan queue:resume --all
+```
+
+After resuming a queue, workers will begin processing new jobs from that queue immediately. Resuming all queues does not resume queues that were paused individually. The `queue:continue` command is available as an alias for `queue:resume`. Note that pausing a queue does not stop the worker process itself - it only prevents the worker from processing new jobs from the specified queue.
#### Worker Restart and Pause Signals
diff --git a/src/queue/src/Console/PauseCommand.php b/src/queue/src/Console/PauseCommand.php
index 73f17b88b..3f8ce1ea9 100644
--- a/src/queue/src/Console/PauseCommand.php
+++ b/src/queue/src/Console/PauseCommand.php
@@ -19,7 +19,9 @@ class PauseCommand extends Command
/**
* The console command name.
*/
- protected ?string $signature = 'queue:pause {queue : The name of the queue to pause}';
+ protected ?string $signature = 'queue:pause
+ {queue? : The name of the queue to pause}
+ {--all : Pause job processing for all queues on all connections}';
/**
* The console command description.
@@ -31,19 +33,36 @@ class PauseCommand extends Command
*/
public function handle(QueueFactory $manager): int
{
- [$connection, $queue] = $this->parseQueue($this->argument('queue'));
-
if (! Worker::$pausable) {
$this->components->error('Queue pausing is currently disabled.');
- return 1;
+ return self::FAILURE;
}
/** @var QueueManager $manager */
+ if ($this->option('all')) {
+ $manager->pauseAll();
+
+ $this->components->info('Job processing on all queues across all connections has been paused.');
+
+ return self::SUCCESS;
+ }
+
+ /** @var null|string $queue */
+ $queue = $this->argument('queue');
+
+ if ($queue === null || $queue === '') {
+ $this->components->error('A queue name is required unless the --all option is used.');
+
+ return self::FAILURE;
+ }
+
+ [$connection, $queue] = $this->parseQueue($queue);
+
$manager->pause($connection, $queue);
$this->components->info("Job processing on queue [{$connection}:{$queue}] has been paused.");
- return 0;
+ return self::SUCCESS;
}
}
diff --git a/src/queue/src/Console/ResumeCommand.php b/src/queue/src/Console/ResumeCommand.php
index 1237dd509..6a62924c9 100644
--- a/src/queue/src/Console/ResumeCommand.php
+++ b/src/queue/src/Console/ResumeCommand.php
@@ -18,7 +18,9 @@ class ResumeCommand extends Command
/**
* The console command name.
*/
- protected ?string $signature = 'queue:resume {queue : The name of the queue that should resume processing}';
+ protected ?string $signature = 'queue:resume
+ {queue? : The name of the queue that should resume processing}
+ {--all : Resume job processing for all queues on all connections}';
/**
* The console command name aliases.
@@ -37,13 +39,30 @@ class ResumeCommand extends Command
*/
public function handle(QueueFactory $manager): int
{
- [$connection, $queue] = $this->parseQueue($this->argument('queue'));
-
/** @var QueueManager $manager */
+ if ($this->option('all')) {
+ $manager->resumeAll();
+
+ $this->components->info('Job processing on all queues across all connections has been resumed.');
+
+ return self::SUCCESS;
+ }
+
+ /** @var null|string $queue */
+ $queue = $this->argument('queue');
+
+ if ($queue === null || $queue === '') {
+ $this->components->error('A queue name is required unless the --all option is used.');
+
+ return self::FAILURE;
+ }
+
+ [$connection, $queue] = $this->parseQueue($queue);
+
$manager->resume($connection, $queue);
$this->components->info("Job processing on queue [{$connection}:{$queue}] has been resumed.");
- return 0;
+ return self::SUCCESS;
}
}
diff --git a/src/queue/src/Events/QueuesPaused.php b/src/queue/src/Events/QueuesPaused.php
new file mode 100644
index 000000000..ba7a979b0
--- /dev/null
+++ b/src/queue/src/Events/QueuesPaused.php
@@ -0,0 +1,9 @@
+app->make('events');
- if ($events->hasListeners(Events\QueuePaused::class)) {
- $events->dispatch(new Events\QueuePaused($connection, $queue));
+ if ($events->hasListeners(QueuePaused::class)) {
+ $events->dispatch(new QueuePaused($connection, $queue));
}
}
@@ -196,8 +200,26 @@ public function pauseFor(string $connection, string $queue, DateInterval|DateTim
/** @var Dispatcher $events */
$events = $this->app->make('events');
- if ($events->hasListeners(Events\QueuePaused::class)) {
- $events->dispatch(new Events\QueuePaused($connection, $queue, $ttl));
+ if ($events->hasListeners(QueuePaused::class)) {
+ $events->dispatch(new QueuePaused($connection, $queue, $ttl));
+ }
+ }
+
+ /**
+ * Pause job processing for all queues on all connections.
+ */
+ public function pauseAll(): void
+ {
+ // Use Laravel's key for cross-framework queue interoperability.
+ $this->app->make('cache')
+ ->store()
+ ->forever('illuminate:queues:paused', true);
+
+ /** @var Dispatcher $events */
+ $events = $this->app->make('events');
+
+ if ($events->hasListeners(QueuesPaused::class)) {
+ $events->dispatch(new QueuesPaused);
}
}
@@ -214,8 +236,28 @@ public function resume(string $connection, string $queue): void
/** @var Dispatcher $events */
$events = $this->app->make('events');
- if ($events->hasListeners(Events\QueueResumed::class)) {
- $events->dispatch(new Events\QueueResumed($connection, $queue));
+ if ($events->hasListeners(QueueResumed::class)) {
+ $events->dispatch(new QueueResumed($connection, $queue));
+ }
+ }
+
+ /**
+ * Resume job processing for all queues on all connections.
+ *
+ * Queues paused individually are not affected.
+ */
+ public function resumeAll(): void
+ {
+ // Use Laravel's key for cross-framework queue interoperability.
+ $this->app->make('cache')
+ ->store()
+ ->forget('illuminate:queues:paused');
+
+ /** @var Dispatcher $events */
+ $events = $this->app->make('events');
+
+ if ($events->hasListeners(QueuesResumed::class)) {
+ $events->dispatch(new QueuesResumed);
}
}
@@ -225,9 +267,10 @@ public function resume(string $connection, string $queue): void
public function isPaused(string $connection, string $queue): bool
{
// IMPORTANT: Uses Laravel's key for cross-framework queue interoperability.
- return (bool) $this->app->make('cache')
- ->store()
- ->get("illuminate:queue:paused:{$connection}:{$queue}", false);
+ $cache = $this->app->make('cache')->store();
+
+ return (bool) ($cache->get('illuminate:queues:paused', false)
+ ?: $cache->get("illuminate:queue:paused:{$connection}:{$queue}", false));
}
/**
@@ -235,12 +278,19 @@ public function isPaused(string $connection, string $queue): bool
*/
public function getPausedQueues(string $connection, array $queues): array
{
+ $cache = $this->app->make('cache')->store();
+
+ // Keep the global key separate: cluster proxies may reject cross-slot batches.
+ if ($cache->get('illuminate:queues:paused', false)) {
+ return array_values($queues);
+ }
+
$keys = array_map(
static fn (string $queue): string => "illuminate:queue:paused:{$connection}:{$queue}",
$queues,
);
- $states = $this->app->make('cache')->store()->many($keys);
+ $states = $cache->many($keys);
return array_values(array_filter(
$queues,
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 3bcfd1b4f..bbb81991a 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -27,12 +27,14 @@
* @method static bool isPaused(string $connection, string $queue)
* @method static void looping(mixed $callback)
* @method static void pause(string $connection, string $queue)
+ * @method static void pauseAll()
* @method static void pauseFor(string $connection, string $queue, \DateInterval|\DateTimeInterface|int $ttl)
* @method static void purge(string|null $name = null)
* @method static \Hypervel\Queue\QueueManager removePoolable(string $driver)
* @method static string|null resolveConnectionFromQueueRoute(object $queueable)
* @method static string|null resolveQueueFromQueueRoute(object $queueable)
* @method static void resume(string $connection, string $queue)
+ * @method static void resumeAll()
* @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null)
* @method static \Hypervel\Queue\QueueManager setApplication(\Hypervel\Contracts\Container\Container $app)
* @method static void setDefaultDriver(\UnitEnum|string $name)
diff --git a/tests/Console/CommandSignatureTest.php b/tests/Console/CommandSignatureTest.php
new file mode 100644
index 000000000..43d996bf2
--- /dev/null
+++ b/tests/Console/CommandSignatureTest.php
@@ -0,0 +1,100 @@
+assertTrue(
+ class_exists($class),
+ "Command class [{$class}] no longer exists. Update tests/Console/Fixtures/command_signatures.php."
+ );
+
+ $command = $this->makeCommandWithoutDependencies($class);
+
+ $this->assertSame($expected['name'], $command->getName(), "Command name changed for [{$class}].");
+ $this->assertSame($expected['aliases'], $command->getAliases(), "Command aliases changed for [{$class}].");
+ $this->assertSame($expected['hidden'], $command->isHidden(), "Command visibility changed for [{$class}].");
+
+ $definition = $command->getDefinition();
+
+ $arguments = [];
+
+ foreach ($definition->getArguments() as $argument) {
+ $arguments[] = [
+ 'name' => $argument->getName(),
+ 'mode' => $argument->isRequired() ? 'required' : 'optional',
+ 'isArray' => $argument->isArray(),
+ 'default' => $argument->getDefault(),
+ 'description' => $argument->getDescription(),
+ ];
+ }
+
+ $options = [];
+
+ foreach ($definition->getOptions() as $option) {
+ $options[] = [
+ 'name' => $option->getName(),
+ 'shortcut' => $option->getShortcut(),
+ 'negatable' => $option->isNegatable(),
+ 'valueRequired' => $option->isValueRequired(),
+ 'valueOptional' => $option->isValueOptional(),
+ 'isArray' => $option->isArray(),
+ 'acceptValue' => $option->acceptValue(),
+ 'default' => $option->getDefault(),
+ 'description' => $option->getDescription(),
+ ];
+ }
+
+ $this->assertSame($expected['arguments'], $arguments, "Command arguments changed for [{$class}].");
+ $this->assertSame($expected['options'], $options, "Command options changed for [{$class}].");
+ }
+
+ /**
+ * Provide the recorded command signatures.
+ */
+ public static function commands(): array
+ {
+ $commands = require __DIR__ . '/Fixtures/command_signatures.php';
+
+ $cases = [];
+
+ foreach ($commands as $class => $expected) {
+ $expected = [
+ 'aliases' => $expected['aliases'] ?? [],
+ 'hidden' => $expected['hidden'] ?? false,
+ 'arguments' => $expected['arguments'] ?? [],
+ 'options' => $expected['options'] ?? [],
+ ...$expected,
+ ];
+
+ $cases[$class] = [$class, $expected];
+ }
+
+ return $cases;
+ }
+
+ /**
+ * Create a command definition without resolving its dependencies.
+ */
+ protected function makeCommandWithoutDependencies(string $class): Command
+ {
+ $reflection = new ReflectionClass($class);
+
+ $instance = $reflection->newInstanceWithoutConstructor();
+
+ (new ReflectionMethod(Command::class, '__construct'))->invoke($instance);
+
+ return $instance;
+ }
+}
diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php
new file mode 100644
index 000000000..c09734a8a
--- /dev/null
+++ b/tests/Console/Fixtures/command_signatures.php
@@ -0,0 +1,90 @@
+ [
+ 'name' => 'queue:pause',
+ 'arguments' => [
+ [
+ 'name' => 'queue',
+ 'mode' => 'optional',
+ 'isArray' => false,
+ 'default' => null,
+ 'description' => 'The name of the queue to pause',
+ ],
+ ],
+ 'options' => [
+ [
+ 'name' => 'all',
+ 'shortcut' => null,
+ 'negatable' => false,
+ 'valueRequired' => false,
+ 'valueOptional' => false,
+ 'isArray' => false,
+ 'acceptValue' => false,
+ 'default' => false,
+ 'description' => 'Pause job processing for all queues on all connections',
+ ],
+ [
+ 'name' => 'disable-event-dispatcher',
+ 'shortcut' => null,
+ 'negatable' => false,
+ 'valueRequired' => false,
+ 'valueOptional' => false,
+ 'isArray' => false,
+ 'acceptValue' => false,
+ 'default' => false,
+ 'description' => 'Whether disable event dispatcher.',
+ ],
+ ],
+ ],
+ \Hypervel\Queue\Console\ResumeCommand::class => [
+ 'name' => 'queue:resume',
+ 'aliases' => [
+ 'queue:continue',
+ ],
+ 'arguments' => [
+ [
+ 'name' => 'queue',
+ 'mode' => 'optional',
+ 'isArray' => false,
+ 'default' => null,
+ 'description' => 'The name of the queue that should resume processing',
+ ],
+ ],
+ 'options' => [
+ [
+ 'name' => 'all',
+ 'shortcut' => null,
+ 'negatable' => false,
+ 'valueRequired' => false,
+ 'valueOptional' => false,
+ 'isArray' => false,
+ 'acceptValue' => false,
+ 'default' => false,
+ 'description' => 'Resume job processing for all queues on all connections',
+ ],
+ [
+ 'name' => 'disable-event-dispatcher',
+ 'shortcut' => null,
+ 'negatable' => false,
+ 'valueRequired' => false,
+ 'valueOptional' => false,
+ 'isArray' => false,
+ 'acceptValue' => false,
+ 'default' => false,
+ 'description' => 'Whether disable event dispatcher.',
+ ],
+ ],
+ ],
+];
diff --git a/tests/Console/Scheduling/QueuePauseCommandTest.php b/tests/Console/Scheduling/QueuePauseCommandTest.php
new file mode 100644
index 000000000..b95b37e4c
--- /dev/null
+++ b/tests/Console/Scheduling/QueuePauseCommandTest.php
@@ -0,0 +1,131 @@
+make('config')->set('cache.default', 'array');
+ }
+
+ public function testDispatchesEvent(): void
+ {
+ Event::fake();
+
+ $this->artisan('queue:pause default')->assertSuccessful();
+
+ Event::assertDispatched(QueuePaused::class);
+ }
+
+ public function testPauseAllDispatchesEvent(): void
+ {
+ Event::fake();
+
+ $this->artisan('queue:pause --all')->assertSuccessful();
+
+ Event::assertDispatched(QueuesPaused::class);
+ }
+
+ public function testResumeAllDispatchesEvent(): void
+ {
+ Event::fake();
+
+ $this->artisan('queue:resume --all')->assertSuccessful();
+
+ Event::assertDispatched(QueuesResumed::class);
+ }
+
+ public function testDisabledError(): void
+ {
+ Event::fake();
+
+ Worker::$pausable = false;
+
+ $this->artisan('queue:pause default')->assertFailed();
+
+ Event::assertNotDispatched(QueuePaused::class);
+ }
+
+ public function testContinueAliasResumesAllQueues(): void
+ {
+ Queue::pauseAll();
+ $this->assertTrue(Queue::isPaused('redis', 'default'));
+
+ $this->artisan('queue:continue --all')->assertSuccessful();
+
+ $this->assertFalse(Queue::isPaused('redis', 'default'));
+ }
+
+ #[DataProvider('commandsWithoutQueue')]
+ public function testQueueNameIsRequiredWithoutAll(string $command, array $arguments): void
+ {
+ Event::fake();
+ $connection = Queue::getDefaultDriver();
+ Queue::pause($connection, 'emails');
+
+ $this->artisan($command, $arguments)
+ ->expectsOutputToContain('A queue name is required unless the --all option is used.')
+ ->assertFailed();
+
+ $this->assertTrue(Queue::isPaused($connection, 'emails'));
+ $this->assertFalse(Queue::isPaused($connection, 'default'));
+ Event::assertNotDispatched(QueuesPaused::class);
+ Event::assertNotDispatched(QueuesResumed::class);
+ }
+
+ /**
+ * Provide missing and empty queue arguments for both commands.
+ */
+ public static function commandsWithoutQueue(): array
+ {
+ return [
+ ['queue:pause', []],
+ ['queue:pause', ['queue' => '']],
+ ['queue:resume', []],
+ ['queue:resume', ['queue' => '']],
+ ];
+ }
+
+ public function testDisabledErrorPreventsGlobalPause(): void
+ {
+ Event::fake();
+ Worker::$pausable = false;
+
+ $this->artisan('queue:pause --all')
+ ->expectsOutputToContain('Queue pausing is currently disabled.')
+ ->assertFailed();
+
+ $this->assertFalse(Queue::isPaused('redis', 'default'));
+ Event::assertNotDispatched(QueuesPaused::class);
+ }
+
+ public function testZeroQueueNameCanBePausedAndResumed(): void
+ {
+ $connection = Queue::getDefaultDriver();
+
+ $this->artisan('queue:pause', ['queue' => '0'])->assertSuccessful();
+
+ $this->assertTrue(Queue::isPaused($connection, '0'));
+ $this->assertFalse(Queue::isPaused($connection, 'default'));
+
+ $this->artisan('queue:resume', ['queue' => '0'])->assertSuccessful();
+
+ $this->assertFalse(Queue::isPaused($connection, '0'));
+ }
+}
diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php
index 70d2e27db..fd3ff99cb 100644
--- a/tests/Integration/Queue/WorkCommandTest.php
+++ b/tests/Integration/Queue/WorkCommandTest.php
@@ -293,7 +293,7 @@ public function testMemoryExitCode()
Worker::$memoryExceededExitCode = null;
}
- public function testDisableLastRestartCheck()
+ public function testDisableLastRestartCheck(): void
{
$this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']);
@@ -301,6 +301,7 @@ public function testDisableLastRestartCheck()
$cache = m::mock(Repository::class);
$cache->shouldNotReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY);
+ $cache->shouldReceive('get')->with('illuminate:queues:paused', false)->andReturn(false);
$cache->shouldReceive('many')
->with(['illuminate:queue:paused:database:default'])
->andReturn(['illuminate:queue:paused:database:default' => false]);
diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php
index 34b0c9bb1..5c1e38bbd 100644
--- a/tests/Queue/QueuePauseResumeTest.php
+++ b/tests/Queue/QueuePauseResumeTest.php
@@ -13,9 +13,12 @@
use Hypervel\Queue\Console\Concerns\ParsesQueue;
use Hypervel\Queue\Events\QueuePaused;
use Hypervel\Queue\Events\QueueResumed;
+use Hypervel\Queue\Events\QueuesPaused;
+use Hypervel\Queue\Events\QueuesResumed;
use Hypervel\Queue\QueueManager;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Tests\TestCase;
+use RuntimeException;
class QueuePauseResumeTest extends TestCase
{
@@ -25,12 +28,24 @@ class QueuePauseResumeTest extends TestCase
protected Dispatcher $events;
+ /**
+ * Set up the test environment.
+ */
protected function setUp(): void
{
parent::setUp();
- $container = new Container;
$this->cache = new CacheRepository(new ArrayStore);
+
+ $this->manager = $this->createManager($this->cache);
+ }
+
+ /**
+ * Create a queue manager using the given cache repository.
+ */
+ protected function createManager(CacheRepository $cache): QueueManager
+ {
+ $container = new Container;
$this->events = new Dispatcher($container);
$container->instance('config', new ConfigRepository([
@@ -42,12 +57,18 @@ protected function setUp(): void
],
],
]));
- $container->instance('cache', new class($this->cache) {
+ $container->instance('cache', new class($cache) {
+ /**
+ * Create a cache manager fixture.
+ */
public function __construct(
private readonly CacheRepository $repository,
) {
}
+ /**
+ * Get the cache repository.
+ */
public function store(?string $name = null): CacheRepository
{
return $this->repository;
@@ -56,7 +77,7 @@ public function store(?string $name = null): CacheRepository
$container->instance('events', $this->events);
$container->instance(DispatcherContract::class, $this->events);
- $this->manager = new QueueManager($container);
+ return new QueueManager($container);
}
public function testPauseQueueWithConnection()
@@ -177,7 +198,7 @@ public function testPassiveObserversDoNotCauseQueueStateEventsToDispatch(): void
{
$observed = [];
$this->events->observe(
- [QueuePaused::class, QueueResumed::class],
+ [QueuePaused::class, QueueResumed::class, QueuesPaused::class, QueuesResumed::class],
static function (string $event) use (&$observed): void {
$observed[] = $event;
},
@@ -187,6 +208,9 @@ static function (string $event) use (&$observed): void {
$this->manager->pauseFor('redis', 'emails', 60);
$this->manager->resume('redis', 'default');
+ $this->manager->pauseAll();
+ $this->manager->resumeAll();
+
$this->assertSame([], $observed);
}
@@ -203,6 +227,87 @@ public function testGetPausedQueues(): void
);
}
+ public function testPauseAllPausesEveryQueueAndResumeAllResumesThem(): void
+ {
+ $this->manager->pauseAll();
+
+ $this->assertTrue($this->manager->isPaused('redis', 'default'));
+ $this->assertTrue($this->manager->isPaused('database', 'emails'));
+ $this->assertSame(
+ ['default', 'emails'],
+ $this->manager->getPausedQueues('redis', ['default', 'emails'])
+ );
+
+ $this->manager->resumeAll();
+
+ $this->assertFalse($this->manager->isPaused('redis', 'default'));
+ $this->assertSame([], $this->manager->getPausedQueues('redis', ['default', 'emails']));
+ }
+
+ public function testResumeAllPreservesIndividuallyPausedQueues(): void
+ {
+ $this->manager->pause('redis', 'emails');
+ $this->manager->pauseAll();
+ $this->manager->resumeAll();
+
+ $this->assertTrue($this->manager->isPaused('redis', 'emails'));
+ $this->assertFalse($this->manager->isPaused('database', 'emails'));
+ $this->assertSame(['emails'], $this->manager->getPausedQueues('redis', ['default', 'emails']));
+ }
+
+ public function testPauseChecksDoNotBatchTheGlobalKeyWithQueueKeys(): void
+ {
+ $store = new class extends ArrayStore {
+ /**
+ * Retrieve multiple keys without crossing the global pause key's slot.
+ */
+ public function many(array $keys): array
+ {
+ if (count($keys) > 1 && in_array('illuminate:queues:paused', $keys, true)) {
+ throw new RuntimeException("CROSSSLOT Keys in request don't hash to the same slot");
+ }
+
+ return parent::many($keys);
+ }
+ };
+
+ $manager = $this->createManager(new CacheRepository($store));
+
+ $this->assertFalse($manager->isPaused('redis', 'default'));
+ $this->assertSame([], $manager->getPausedQueues('redis', ['default']));
+
+ $manager->pauseAll();
+
+ $this->assertTrue($manager->isPaused('redis', 'default'));
+ $this->assertSame(['default'], $manager->getPausedQueues('redis', ['default']));
+ }
+
+ public function testPauseAllDispatchesQueuesPausedEvent(): void
+ {
+ $dispatchedEvent = null;
+
+ $this->events->listen(QueuesPaused::class, function (QueuesPaused $event) use (&$dispatchedEvent): void {
+ $dispatchedEvent = $event;
+ });
+
+ $this->manager->pauseAll();
+
+ $this->assertInstanceOf(QueuesPaused::class, $dispatchedEvent);
+ }
+
+ public function testResumeAllDispatchesQueuesResumedEvent(): void
+ {
+ $dispatchedEvent = null;
+
+ $this->events->listen(QueuesResumed::class, function (QueuesResumed $event) use (&$dispatchedEvent): void {
+ $dispatchedEvent = $event;
+ });
+
+ $this->manager->resumeAll();
+
+ $this->assertInstanceOf(QueuesResumed::class, $dispatchedEvent);
+ }
+
public function testParsingQueueString()
{
$parser = new class {
From 90f08ff07fec97f4343da3e62c2511907ed4fac5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 03:50:56 +0000
Subject: [PATCH 24/29] Report paused and resumed queues from workers
Port Laravel #61142 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2:
https://github.com/laravel/framework/pull/61142
Add worker pause/resume events and CLI/JSON status output with native types,
guarded dispatch, and static listeners resolving the current command.
Keep pause history on the worker across fresh polling coroutines and reset
it for each daemon run. Limit history to the current connection and queue
selection so programmatic reuse cannot falsely report another queue resumed.
Returning to a previous selection reports its current paused state again.
Seed WorkerOptions coroutine context during once-mode polling, matching the
daemon and job paths while preserving caller context through finally cleanup.
This also corrects JobPopping and JobPopped listener context under --once.
Document the public output behavior and cover selection transitions, listener
absence, daemon reuse, command cloning, output formats and suppression.
Validation: Queue ParaTest 671 tests / 2839 assertions; SQLite command
integration 18 / 58; worker resource-lifetime integration 3 / 25. Full source
and type-fixture PHPStan checks, formatting and diff checks pass. Independently
reviewed and approved by claude-laravel-parity.
---
src/docs/queues.md | 2 +
src/queue/src/Console/WorkCommand.php | 42 ++++++-
src/queue/src/Events/WorkerQueuePaused.php | 19 ++++
src/queue/src/Events/WorkerQueueResumed.php | 19 ++++
src/queue/src/Worker.php | 67 ++++++++++-
tests/Queue/QueueWorkerTest.php | 120 +++++++++++++++++++-
tests/Queue/WorkCommandTest.php | 78 +++++++++++++
7 files changed, 341 insertions(+), 6 deletions(-)
create mode 100644 src/queue/src/Events/WorkerQueuePaused.php
create mode 100644 src/queue/src/Events/WorkerQueueResumed.php
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 46055fe9f..bc81f0098 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -2875,6 +2875,8 @@ php artisan queue:resume --all
After resuming a queue, workers will begin processing new jobs from that queue immediately. Resuming all queues does not resume queues that were paused individually. The `queue:continue` command is available as an alias for `queue:resume`. Note that pausing a queue does not stop the worker process itself - it only prevents the worker from processing new jobs from the specified queue.
+Queue workers report paused and resumed queues in their console output.
+
#### Worker Restart and Pause Signals
diff --git a/src/queue/src/Console/WorkCommand.php b/src/queue/src/Console/WorkCommand.php
index 2754b5835..034033368 100644
--- a/src/queue/src/Console/WorkCommand.php
+++ b/src/queue/src/Console/WorkCommand.php
@@ -14,6 +14,8 @@
use Hypervel\Queue\Events\JobProcessed;
use Hypervel\Queue\Events\JobProcessing;
use Hypervel\Queue\Events\JobReleasedAfterException;
+use Hypervel\Queue\Events\WorkerQueuePaused;
+use Hypervel\Queue\Events\WorkerQueueResumed;
use Hypervel\Queue\Events\WorkerStopping;
use Hypervel\Queue\Failed\FailedJobProviderInterface;
use Hypervel\Queue\InvalidPayloadException;
@@ -198,6 +200,14 @@ protected function listenForEvents(): void
$command?->writeOutput($event->job, 'failed', $event->exception);
});
+ $events->listen(WorkerQueuePaused::class, static function (WorkerQueuePaused $event): void {
+ static::currentCommand()?->writeQueueStatus($event->queue, 'paused');
+ });
+
+ $events->listen(WorkerQueueResumed::class, static function (WorkerQueueResumed $event): void {
+ static::currentCommand()?->writeQueueStatus($event->queue, 'resumed');
+ });
+
$events->listen(WorkerStopping::class, static function (WorkerStopping $event): void {
// Graceful stopping runs outside the configured job coroutine context.
$command = $event->workerOptions?->coroutineContext[self::CURRENT_COMMAND_CONTEXT_KEY] ?? null;
@@ -224,6 +234,36 @@ protected function writeOutput(Job $job, string $status, ?Throwable $exception =
: $this->writeOutputForCli($job, $status);
}
+ /**
+ * Write the status output for a paused or resumed queue.
+ */
+ protected function writeQueueStatus(string $queue, string $status): void
+ {
+ if ($this->output->isQuiet() || $this->output->isSilent()) {
+ return;
+ }
+
+ if ($this->outputUsingJson()) {
+ $this->output->writeln(json_encode([
+ 'level' => 'warning',
+ 'queue' => $queue,
+ 'status' => $status,
+ 'timestamp' => $this->now()->format('Y-m-d\TH:i:s.uP'),
+ ]));
+
+ return;
+ }
+
+ $this->output->writeln(sprintf(
+ ' %s> Queue %s> %s',
+ $this->now()->format('Y-m-d H:i:s'),
+ $queue,
+ $status === 'paused'
+ ? 'PAUSED>'
+ : 'RESUMED>',
+ ));
+ }
+
/**
* Write the status output for a queue worker that is stopping.
*/
@@ -425,7 +465,7 @@ protected function outputUsingJson(): bool
}
/**
- * Get the queue work command for the currently running job coroutine.
+ * Get the queue work command for the current coroutine.
*/
protected static function currentCommand(): ?self
{
diff --git a/src/queue/src/Events/WorkerQueuePaused.php b/src/queue/src/Events/WorkerQueuePaused.php
new file mode 100644
index 000000000..0a57396d7
--- /dev/null
+++ b/src/queue/src/Events/WorkerQueuePaused.php
@@ -0,0 +1,19 @@
+
+ */
+ protected array $pausedQueues = [];
+
+ /**
+ * The connection used for the last pause-state observation.
+ */
+ protected ?string $lastPolledConnection = null;
+
+ /**
+ * The queue list used for the last pause-state observation.
+ */
+ protected ?string $lastPolledQueues = null;
+
/**
* The callbacks used to pop jobs from queues.
*
@@ -239,6 +258,11 @@ public function daemon(string $connectionName, string $queue, WorkerOptions $opt
$this->lastJobProcessedAt = null;
$this->stopReason = null;
+ // A new daemon run must report initially paused queues even when this worker is reused.
+ $this->pausedQueues = [];
+ $this->lastPolledConnection = null;
+ $this->lastPolledQueues = null;
+
$lifecycleWaiter = new Waiter(-1);
$lastRestart = $lifecycleWaiter->wait(fn (): ?int => $this->withCoroutineContext(
$options,
@@ -635,9 +659,12 @@ protected function stopIfNecessary(
*/
public function runNextJob(string $connectionName, string $queue, WorkerOptions $options): null
{
- $job = $this->getNextJob(
- $this->manager->connection($connectionName),
- $queue
+ $job = $this->withCoroutineContext(
+ $options,
+ fn (): ?JobContract => $this->getNextJob(
+ $this->manager->connection($connectionName),
+ $queue,
+ ),
);
// If we're able to pull a job off of the stack, we will process it and then return
@@ -675,7 +702,19 @@ protected function getNextJob(QueueContract $connection, string $queue): ?JobCon
}
$queues = explode(',', $queue);
- $paused = array_flip($this->getPausedQueues($connection->getConnectionName(), $queues));
+ $connectionName = $connection->getConnectionName();
+ $paused = $this->getPausedQueues($connectionName, $queues);
+
+ // A different selection says nothing about whether the old queues resumed.
+ if ($this->lastPolledConnection !== $connectionName || $this->lastPolledQueues !== $queue) {
+ $this->pausedQueues = [];
+ $this->lastPolledConnection = $connectionName;
+ $this->lastPolledQueues = $queue;
+ }
+
+ $this->raisePausedQueueEvents($connectionName, $paused);
+
+ $paused = array_flip($paused);
foreach ($queues as $index => $queue) {
if (isset($paused[$queue])) {
@@ -720,6 +759,26 @@ protected function getPausedQueues(string $connectionName, array $queues): array
return $manager->getPausedQueues($connectionName, $queues);
}
+ /**
+ * Raise events for any queues that have been paused or resumed since the last check.
+ */
+ protected function raisePausedQueueEvents(string $connectionName, array $paused): void
+ {
+ if ($this->events->hasListeners(WorkerQueuePaused::class)) {
+ foreach (array_diff($paused, $this->pausedQueues) as $queue) {
+ $this->events->dispatch(new WorkerQueuePaused($connectionName, $queue));
+ }
+ }
+
+ if ($this->events->hasListeners(WorkerQueueResumed::class)) {
+ foreach (array_diff($this->pausedQueues, $paused) as $queue) {
+ $this->events->dispatch(new WorkerQueueResumed($connectionName, $queue));
+ }
+ }
+
+ $this->pausedQueues = $paused;
+ }
+
/**
* Process the given job.
*/
diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php
index 8fb4242bd..88858b6ca 100644
--- a/tests/Queue/QueueWorkerTest.php
+++ b/tests/Queue/QueueWorkerTest.php
@@ -41,6 +41,8 @@
use Hypervel\Queue\Events\WorkerIdle;
use Hypervel\Queue\Events\WorkerInterrupted;
use Hypervel\Queue\Events\WorkerPausing;
+use Hypervel\Queue\Events\WorkerQueuePaused;
+use Hypervel\Queue\Events\WorkerQueueResumed;
use Hypervel\Queue\Events\WorkerResuming;
use Hypervel\Queue\Events\WorkerStarting;
use Hypervel\Queue\Events\WorkerStopping;
@@ -293,7 +295,7 @@ public function testInvalidPayloadIsNotReportedWhenJobExceptionReportingIsDisabl
$this->assertTrue($job->isDeleted());
}
- public function testWorkerOptionsCoroutineContextIsScopedToJob()
+ public function testWorkerOptionsCoroutineContextIsScopedToJob(): void
{
CoroutineContext::set('queue.worker.test.previous', 'previous');
@@ -304,6 +306,16 @@ public function testWorkerOptionsCoroutineContextIsScopedToJob()
'queue.worker.test.new' => 'fresh',
];
+ $seenDuringPop = [];
+ $this->events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$seenDuringPop): void {
+ if ($event instanceof JobPopping || $event instanceof JobPopped) {
+ $seenDuringPop[$event::class] = [
+ CoroutineContext::get('queue.worker.test.previous'),
+ CoroutineContext::get('queue.worker.test.new'),
+ ];
+ }
+ });
+
$worker = $this->getWorker('default', ['queue' => [
new WorkerFakeJob(function () use (&$seen) {
$seen = [
@@ -316,6 +328,10 @@ public function testWorkerOptionsCoroutineContextIsScopedToJob()
$worker->runNextJob('default', 'queue', $options);
$this->assertSame(['seeded', 'fresh'], $seen);
+ $this->assertSame([
+ JobPopping::class => ['seeded', 'fresh'],
+ JobPopped::class => ['seeded', 'fresh'],
+ ], $seenDuringPop);
$this->assertSame('previous', CoroutineContext::get('queue.worker.test.previous'));
$this->assertFalse(CoroutineContext::has('queue.worker.test.new'));
}
@@ -970,6 +986,108 @@ public function testLoopingEventCarriesWorkerOptions(): void
$this->assertFalse($worker->daemonShouldRunForTest($options, 'default', 'queue'));
}
+ public function testQueuePauseEventsTrackOnlyTheCurrentSelection(): void
+ {
+ $paused = ['emails'];
+ $manager = m::mock(QueueManager::class);
+ $manager->shouldReceive('connection')->with('first')->andReturn(
+ new WorkerFakeConnection('first', ['emails' => [], 'default' => []]),
+ );
+ $manager->shouldReceive('connection')->with('second')->andReturn(
+ new WorkerFakeConnection('second', ['emails' => []]),
+ );
+ $manager->shouldReceive('getPausedQueues')->andReturnUsing(
+ static function (string $connection, array $queues) use (&$paused): array {
+ return $connection === 'first'
+ ? array_values(array_intersect($queues, $paused))
+ : [];
+ },
+ );
+ $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false);
+ $worker->setCache(m::mock(CacheContract::class));
+ $options = new WorkerOptions(sleep: 0);
+ $observed = [];
+ $this->events->shouldReceive('dispatch')->andReturnUsing(function (object $event) use (&$observed): void {
+ if ($event instanceof WorkerQueuePaused || $event instanceof WorkerQueueResumed) {
+ $observed[] = [$event::class, $event->connectionName, $event->queue];
+ }
+ });
+
+ $worker->runNextJob('first', 'emails', $options);
+ $worker->runNextJob('first', 'emails', $options);
+ $worker->runNextJob('second', 'emails', $options);
+ $worker->runNextJob('first', 'emails', $options);
+ $worker->runNextJob('first', 'default', $options);
+ $worker->runNextJob('first', 'emails', $options);
+
+ // Returning to a selection reports its current pause state without retaining other selections.
+ $this->assertSame([
+ [WorkerQueuePaused::class, 'first', 'emails'],
+ [WorkerQueuePaused::class, 'first', 'emails'],
+ [WorkerQueuePaused::class, 'first', 'emails'],
+ ], $observed);
+
+ $paused = [];
+ $worker->runNextJob('first', 'emails', $options);
+ $worker->runNextJob('first', 'emails', $options);
+
+ $this->assertCount(4, $observed);
+ $this->assertSame([WorkerQueueResumed::class, 'first', 'emails'], $observed[3]);
+ }
+
+ public function testPauseHistoryIsRetainedWithoutEventListeners(): void
+ {
+ $manager = m::mock(QueueManager::class);
+ $manager->shouldReceive('connection')->with('default')->andReturn(
+ new WorkerFakeConnection('default', ['queue' => []]),
+ );
+ $manager->shouldReceive('getPausedQueues')->with('default', ['queue'])->andReturn(['queue'], []);
+ $listening = false;
+ $this->events->shouldReceive('hasListeners')->andReturnUsing(
+ static function (string $event) use (&$listening): bool {
+ return $listening && $event === WorkerQueueResumed::class;
+ },
+ );
+ $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false);
+ $worker->setCache(m::mock(CacheContract::class));
+
+ $worker->runNextJob('default', 'queue', new WorkerOptions(sleep: 0));
+ $this->events->shouldNotHaveReceived('dispatch');
+
+ $listening = true;
+ $worker->runNextJob('default', 'queue', new WorkerOptions(sleep: 0));
+
+ $this->events->shouldHaveReceived('dispatch')->with(m::on(
+ static fn (object $event): bool => $event instanceof WorkerQueueResumed
+ && $event->connectionName === 'default'
+ && $event->queue === 'queue',
+ ))->once();
+ }
+
+ public function testEachDaemonRunReportsInitiallyPausedQueues(): void
+ {
+ $manager = m::mock(QueueManager::class);
+ $manager->shouldReceive('connection')->with('default')->andReturn(
+ new WorkerFakeConnection('default', ['queue' => []]),
+ );
+ $manager->shouldReceive('getPausedQueues')->with('default', ['queue'])->andReturn(['queue']);
+ $cache = m::mock(CacheContract::class);
+ $cache->shouldReceive('get')->with(Worker::RESTART_SIGNAL_CACHE_KEY)->andReturn(null);
+ $worker = new InsomniacWorker($manager, $this->events, $this->exceptionHandler, static fn (): bool => false);
+ $worker->setCache($cache);
+ $options = new WorkerOptions(sleep: 0, stopWhenEmpty: true, memory: 1024);
+
+ $this->assertSame(Worker::EXIT_SUCCESS, $worker->daemon('default', 'queue', $options));
+ $this->assertSame(Worker::EXIT_SUCCESS, $worker->daemon('default', 'queue', $options));
+
+ $this->events->shouldHaveReceived('dispatch')->with(m::on(
+ static fn (object $event): bool => $event instanceof WorkerQueuePaused
+ && $event->connectionName === 'default'
+ && $event->queue === 'queue',
+ ))->twice();
+ $this->events->shouldNotHaveReceived('dispatch', [m::type(WorkerQueueResumed::class)]);
+ }
+
public function testJobCanBeFiredBasedOnPriority()
{
$worker = $this->getWorker('default', [
diff --git a/tests/Queue/WorkCommandTest.php b/tests/Queue/WorkCommandTest.php
index 564511530..6b4174258 100644
--- a/tests/Queue/WorkCommandTest.php
+++ b/tests/Queue/WorkCommandTest.php
@@ -12,6 +12,8 @@
use Hypervel\Queue\WorkerOptions;
use Hypervel\Queue\WorkerStopReason;
use Hypervel\Support\CarbonImmutable;
+use Hypervel\Support\Facades\Artisan;
+use Hypervel\Support\Facades\Queue;
use Hypervel\Testbench\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -31,6 +33,82 @@ protected function defineEnvironment(Application $app): void
$config->set('cache.default', 'array');
}
+ #[DataProvider('queueStatusOutputProvider')]
+ public function testQueueStatusOutputUsesTheCurrentCommand(bool $json): void
+ {
+ $this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11));
+ $arguments = ['--once' => true, '--sleep' => 0, '--json' => $json];
+
+ Queue::pause('sync', 'default');
+ $firstOutput = new BufferedOutput;
+ $this->assertSame(0, Artisan::call('queue:work', $arguments, $firstOutput));
+
+ if ($json) {
+ $this->assertSame([
+ 'level' => 'warning',
+ 'queue' => 'default',
+ 'status' => 'paused',
+ 'timestamp' => '2023-01-18T10:10:11.000000+00:00',
+ ], json_decode($firstOutput->fetch(), true, 512, JSON_THROW_ON_ERROR));
+ } else {
+ $this->assertSame(" 2023-01-18 10:10:11 Queue default PAUSED\n", $firstOutput->fetch());
+ }
+
+ Queue::resume('sync', 'default');
+ $secondOutput = new BufferedOutput;
+ $this->assertSame(0, Artisan::call('queue:work', $arguments, $secondOutput));
+
+ $this->assertSame('', $firstOutput->fetch());
+
+ if ($json) {
+ $this->assertSame([
+ 'level' => 'warning',
+ 'queue' => 'default',
+ 'status' => 'resumed',
+ 'timestamp' => '2023-01-18T10:10:11.000000+00:00',
+ ], json_decode($secondOutput->fetch(), true, 512, JSON_THROW_ON_ERROR));
+ } else {
+ $this->assertSame(" 2023-01-18 10:10:11 Queue default RESUMED\n", $secondOutput->fetch());
+ }
+ }
+
+ /**
+ * Provide the queue status output formats.
+ */
+ public static function queueStatusOutputProvider(): array
+ {
+ return [
+ 'CLI' => [false],
+ 'JSON' => [true],
+ ];
+ }
+
+ #[DataProvider('suppressedQueueStatusOutputProvider')]
+ public function testQueueStatusOutputIsSuppressed(string $option): void
+ {
+ $output = new BufferedOutput;
+ $arguments = ['--once' => true, '--sleep' => 0, '--json' => true, $option => true];
+
+ Queue::pause('sync', 'default');
+ $this->assertSame(0, Artisan::call('queue:work', $arguments, $output));
+
+ Queue::resume('sync', 'default');
+ $this->assertSame(0, Artisan::call('queue:work', $arguments, $output));
+
+ $this->assertSame('', $output->fetch());
+ }
+
+ /**
+ * Provide verbosity options that suppress queue status output.
+ */
+ public static function suppressedQueueStatusOutputProvider(): array
+ {
+ return [
+ 'quiet' => ['--quiet'],
+ 'silent' => ['--silent'],
+ ];
+ }
+
public function testStopOutputUsesTheCurrentCommand(): void
{
$this->travelTo(CarbonImmutable::create(2023, 1, 18, 10, 10, 11));
From 2d048a46fa461f4255e892d4a12de1d172032bc9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 04:21:46 +0000
Subject: [PATCH 25/29] Align Redis option errors and port native backoff
coverage
Port Laravel framework PR #57860 from 13.x source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
InvalidRedisOptionException now extends InvalidArgumentException, restoring the upstream invalid-backoff contract while retaining the specific exception for all invalid Redis option configuration. Document the parser exception without changing its mapping or native retry policy.
Merge all current PhpRedisBackoffTest cases into the existing pooled RedisConnectorTest: six friendly names, six numeric algorithms, invalid numeric fallback and invalid-name failure. Acquire the pooled connection to exercise native client creation, preserve per-worker database isolation, and skip the standalone-only class for Cluster. Existing Redis-backed cache workflow and cache tests already cover the rest of the PR.
Validation: real standalone connector tests 22/49; Redis unit ParaTest 676/2501 (two existing skips); full source/type PHPStan and formatting pass. Self-reviewed and approved by claude-laravel-parity.
Upstream: https://github.com/laravel/framework/pull/57860
---
.../InvalidRedisOptionException.php | 4 +-
src/redis/src/RedisConnection.php | 2 +
.../Integration/Redis/RedisConnectorTest.php | 96 ++++++++++++++++---
3 files changed, 87 insertions(+), 15 deletions(-)
diff --git a/src/redis/src/Exceptions/InvalidRedisOptionException.php b/src/redis/src/Exceptions/InvalidRedisOptionException.php
index d08fb7a5a..3b60f70c4 100644
--- a/src/redis/src/Exceptions/InvalidRedisOptionException.php
+++ b/src/redis/src/Exceptions/InvalidRedisOptionException.php
@@ -4,8 +4,8 @@
namespace Hypervel\Redis\Exceptions;
-use RuntimeException;
+use InvalidArgumentException;
-class InvalidRedisOptionException extends RuntimeException
+class InvalidRedisOptionException extends InvalidArgumentException
{
}
diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php
index 528771e96..7c75af06c 100644
--- a/src/redis/src/RedisConnection.php
+++ b/src/redis/src/RedisConnection.php
@@ -640,6 +640,8 @@ protected function phpRedisOption(string $name): int
/**
* Parse a friendly phpredis backoff algorithm name.
+ *
+ * @throws InvalidRedisOptionException
*/
protected function parseBackoffAlgorithm(mixed $algorithm): int
{
diff --git a/tests/Integration/Redis/RedisConnectorTest.php b/tests/Integration/Redis/RedisConnectorTest.php
index 25b59ef38..e18de1f4f 100644
--- a/tests/Integration/Redis/RedisConnectorTest.php
+++ b/tests/Integration/Redis/RedisConnectorTest.php
@@ -10,6 +10,9 @@
use Hypervel\Redis\RedisConnection;
use Hypervel\Support\Facades\Redis;
use Hypervel\Testbench\TestCase;
+use InvalidArgumentException;
+use PHPUnit\Framework\Attributes\DataProvider;
+use Redis as PhpRedis;
/**
* Tests that Redis connection configuration is correctly applied to the
@@ -20,6 +23,18 @@ class RedisConnectorTest extends TestCase
{
use InteractsWithRedis;
+ /**
+ * Set up the standalone Redis configuration tests.
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ if ($this->usingRedisCluster()) {
+ $this->markTestSkipped('These connection options require standalone phpredis.');
+ }
+ }
+
protected function defineEnvironment(ApplicationContract $app): void
{
parent::defineEnvironment($app);
@@ -33,7 +48,7 @@ public function testDefaultConfiguration(): void
$host = $this->app->make('config')->get('database.redis.default.host');
$port = $this->app->make('config')->get('database.redis.default.port');
- $this->withClient('default', function (\Redis $client) use ($host, $port): void {
+ $this->withClient('default', function (PhpRedis $client) use ($host, $port): void {
$this->assertSame($host, $client->getHost());
$this->assertSame($port, $client->getPort());
});
@@ -50,7 +65,7 @@ public function testUrl(): void
'database' => $this->getParallelRedisDb(),
]);
- $this->withClient($name, function (\Redis $client) use ($host, $port): void {
+ $this->withClient($name, function (PhpRedis $client) use ($host, $port): void {
// redis:// URL maps to tcp:// scheme via ConfigurationUrlParser driver aliases
$this->assertSame("tcp://{$host}", $client->getHost());
$this->assertEquals($port, $client->getPort());
@@ -68,7 +83,7 @@ public function testUrlWithScheme(): void
'database' => $this->getParallelRedisDb(),
]);
- $this->withClient($name, function (\Redis $client) use ($host, $port): void {
+ $this->withClient($name, function (PhpRedis $client) use ($host, $port): void {
$this->assertSame("tcp://{$host}", $client->getHost());
$this->assertEquals($port, $client->getPort());
});
@@ -87,7 +102,7 @@ public function testScheme(): void
'database' => $this->getParallelRedisDb(),
]);
- $this->withClient($name, function (\Redis $client) use ($host, $port): void {
+ $this->withClient($name, function (PhpRedis $client) use ($host, $port): void {
$this->assertSame("tcp://{$host}", $client->getHost());
$this->assertEquals($port, $client->getPort());
});
@@ -111,8 +126,8 @@ public function testPerConnectionPrefixOverridesGlobalPrefix(): void
// Must purge + re-resolve since config changed after initial resolution
$this->app->make('redis')->purge($name);
- $this->withClient($name, function (\Redis $client): void {
- $this->assertSame('per_connection_', $client->getOption(\Redis::OPT_PREFIX));
+ $this->withClient($name, function (PhpRedis $client): void {
+ $this->assertSame('per_connection_', $client->getOption(PhpRedis::OPT_PREFIX));
});
}
@@ -132,8 +147,8 @@ public function testTopLevelConnectionPrefixOverridesGlobalAndLocalPrefix(): voi
$this->app->make('config')->set('database.redis.options.prefix', 'global_');
$this->app->make('redis')->purge($name);
- $this->withClient($name, function (\Redis $client): void {
- $this->assertSame('top_level_', $client->getOption(\Redis::OPT_PREFIX));
+ $this->withClient($name, function (PhpRedis $client): void {
+ $this->assertSame('top_level_', $client->getOption(PhpRedis::OPT_PREFIX));
});
}
@@ -147,7 +162,7 @@ public function testClientNameIsApplied(): void
'name' => 'hypervel-connector-test',
]);
- $this->withClient($name, function (\Redis $client): void {
+ $this->withClient($name, function (PhpRedis $client): void {
$this->assertSame('hypervel-connector-test', $client->client('GETNAME'));
});
}
@@ -164,22 +179,77 @@ public function testTcpKeepaliveOptionIsApplied(): void
],
]);
- $this->withClient($name, function (\Redis $client): void {
- $this->assertSame(1, $client->getOption(\Redis::OPT_TCP_KEEPALIVE));
+ $this->withClient($name, function (PhpRedis $client): void {
+ $this->assertSame(1, $client->getOption(PhpRedis::OPT_TCP_KEEPALIVE));
+ });
+ }
+
+ #[DataProvider('phpRedisBackoffAlgorithmsProvider')]
+ public function testPhpRedisBackoffAlgorithmParsing(string $friendlyAlgorithmName, int $expectedAlgorithm): void
+ {
+ $name = $this->addTestConnection(['backoff_algorithm' => $friendlyAlgorithmName]);
+
+ $this->withClient($name, function (PhpRedis $client) use ($expectedAlgorithm): void {
+ $this->assertSame($expectedAlgorithm, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM));
+ });
+ }
+
+ #[DataProvider('phpRedisBackoffAlgorithmsProvider')]
+ public function testPhpRedisBackoffAlgorithm(string $friendlyAlgorithm, int $expectedAlgorithm): void
+ {
+ $name = $this->addTestConnection(['backoff_algorithm' => $expectedAlgorithm]);
+
+ $this->withClient($name, function (PhpRedis $client) use ($expectedAlgorithm): void {
+ $this->assertSame($expectedAlgorithm, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM));
+ });
+ }
+
+ /**
+ * Provide friendly backoff names and their native algorithms.
+ */
+ public static function phpRedisBackoffAlgorithmsProvider(): array
+ {
+ return [
+ ['default', PhpRedis::BACKOFF_ALGORITHM_DEFAULT],
+ ['decorrelated_jitter', PhpRedis::BACKOFF_ALGORITHM_DECORRELATED_JITTER],
+ ['equal_jitter', PhpRedis::BACKOFF_ALGORITHM_EQUAL_JITTER],
+ ['exponential', PhpRedis::BACKOFF_ALGORITHM_EXPONENTIAL],
+ ['uniform', PhpRedis::BACKOFF_ALGORITHM_UNIFORM],
+ ['constant', PhpRedis::BACKOFF_ALGORITHM_CONSTANT],
+ ];
+ }
+
+ public function testAnInvalidPhpRedisBackoffAlgorithmIsConvertedToDefault(): void
+ {
+ $name = $this->addTestConnection(['backoff_algorithm' => 7]);
+
+ $this->withClient($name, function (PhpRedis $client): void {
+ $this->assertSame(PhpRedis::BACKOFF_ALGORITHM_DEFAULT, $client->getOption(PhpRedis::OPT_BACKOFF_ALGORITHM));
+ });
+ }
+
+ public function testItFailsWithAnInvalidPhpRedisAlgorithm(): void
+ {
+ $this->expectExceptionObject(new InvalidArgumentException('Algorithm [foo] is not a valid PhpRedis backoff algorithm'));
+
+ $name = $this->addTestConnection(['backoff_algorithm' => 'foo']);
+
+ // Acquiring the pooled connection builds the native client and applies its options.
+ Redis::connection($name)->withConnection(static function (RedisConnection $connection): void {
});
}
/**
* Execute a callback with the underlying phpredis client for a named connection.
*
- * @param Closure(\Redis): void $callback
+ * @param Closure(PhpRedis): void $callback
*/
private function withClient(string $name, Closure $callback): void
{
Redis::connection($name)->withConnection(
function (RedisConnection $connection) use ($callback): void {
$client = $connection->client();
- $this->assertInstanceOf(\Redis::class, $client);
+ $this->assertInstanceOf(PhpRedis::class, $client);
$callback($client);
},
From 2e3b07193c8cbd68e89ac83954799ac1f9829c66 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 04:21:46 +0000
Subject: [PATCH 26/29] Complete sliding-window parameter type parity
Complete Laravel framework PR #57875 using 13.x source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2.
Restore positive-int annotations for LazyCollection::sliding size and step, which native int types cannot express. Use a strict comparison for the two integer operands while retaining the existing lazy generator and newInstance behavior. Collection validation and every upstream invalid-argument test were already present.
Validation: Collection and LazyCollection ParaTest 784 tests / 2451 assertions; full source/type PHPStan and formatting pass. Self-reviewed and approved by claude-laravel-parity.
Upstream: https://github.com/laravel/framework/pull/57875
---
src/collections/src/LazyCollection.php | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php
index acc97302b..d6418fcea 100644
--- a/src/collections/src/LazyCollection.php
+++ b/src/collections/src/LazyCollection.php
@@ -1116,6 +1116,8 @@ public function shuffle(): static
/**
* Create chunks representing a "sliding window" view of the items in the collection.
*
+ * @param positive-int $size
+ * @param positive-int $step
* @return static
*
* @throws InvalidArgumentException
@@ -1137,7 +1139,7 @@ public function sliding(int $size = 2, int $step = 1): static
while ($iterator->valid()) {
$chunk[$iterator->key()] = $iterator->current();
- if (count($chunk) == $size) {
+ if (count($chunk) === $size) {
yield $this->newInstance($chunk)->tap(function () use (&$chunk, $step) {
$chunk = array_slice($chunk, $step, null, true);
});
From cfb365f1ac48017bc4c581969f844110b6063f72 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 04:21:46 +0000
Subject: [PATCH 27/29] Port password-reset notification integration coverage
Complete Laravel framework PR #57882 using 13.x source 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The notification subject was already current; integration coverage was missing.
Merge all four default-route tests and port the full three-test class without a default route. Verify default reset URLs, reset-link events, both notification callbacks, and the missing-route exception. Both upstream fixtures register routes explicitly and apply to Hypervel despite the absence of Auth::routes().
Preserve the existing Hypervel event-rebinding tests, use AuthTestUser with the guard-declared password broker, compare the actual user identifier, and let the framework subscriber reset global callbacks. Retain upstream message text and URL assertions without adding a shared fixture base or compatibility code.
Validation: immediate SQLite tests 6/15 and 3/5; Auth integration ParaTest 56/140 (seven unconfigured-service skips); formatting and full source/type analysis pass for the batch. Self-reviewed and approved by claude-laravel-parity.
Upstream: https://github.com/laravel/framework/pull/57882
---
tests/Integration/Auth/ForgotPasswordTest.php | 108 ++++++++++++++
...ForgotPasswordWithoutDefaultRoutesTest.php | 139 ++++++++++++++++++
2 files changed, 247 insertions(+)
create mode 100644 tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php
diff --git a/tests/Integration/Auth/ForgotPasswordTest.php b/tests/Integration/Auth/ForgotPasswordTest.php
index 32843b041..21c78c079 100644
--- a/tests/Integration/Auth/ForgotPasswordTest.php
+++ b/tests/Integration/Auth/ForgotPasswordTest.php
@@ -9,6 +9,8 @@
use Hypervel\Contracts\Auth\PasswordBroker as PasswordBrokerContract;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Foundation\Testing\RefreshDatabase;
+use Hypervel\Notifications\Messages\MailMessage;
+use Hypervel\Routing\Router;
use Hypervel\Support\Facades\Event;
use Hypervel\Support\Facades\Notification;
use Hypervel\Support\Facades\Password;
@@ -35,6 +37,112 @@ protected function defineEnvironment(ApplicationContract $app): void
]);
}
+ /**
+ * Define the password reset routes.
+ */
+ protected function defineRoutes(Router $router): void
+ {
+ $router->get('password/reset/{token}', function (string $token): string {
+ return 'Reset password!';
+ })->name('password.reset');
+
+ $router->get('custom/password/reset/{token}', function (string $token): string {
+ return 'Custom reset password!';
+ })->name('custom.password.reset');
+ }
+
+ public function testItCanSendForgotPasswordEmail(): void
+ {
+ Notification::fake();
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('password.reset', ['token' => $notification->token, 'email' => $user->email]);
+ }
+ );
+ }
+
+ public function testItCanTriggerPasswordResetSentEvent(): void
+ {
+ Event::fake([PasswordResetLinkSent::class]);
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Event::assertDispatched(PasswordResetLinkSent::class, function (PasswordResetLinkSent $event) use ($user): bool {
+ $this->assertSame($user->getAuthIdentifier(), $event->user->getAuthIdentifier());
+
+ return true;
+ });
+ }
+
+ public function testItCanSendForgotPasswordEmailViaCreateUrlUsing(): void
+ {
+ Notification::fake();
+
+ ResetPassword::createUrlUsing(function (mixed $user, string $token): string {
+ return route('custom.password.reset', $token);
+ });
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]);
+ }
+ );
+ }
+
+ public function testItCanSendForgotPasswordEmailViaToMailUsing(): void
+ {
+ Notification::fake();
+
+ ResetPassword::toMailUsing(function (mixed $notifiable, string $token): MailMessage {
+ return (new MailMessage)
+ ->subject(__('Reset your password'))
+ ->line(__('You are receiving this email because we received a password reset request for your account.'))
+ ->action(__('Reset Password'), route('custom.password.reset', $token))
+ ->line(__('If you did not request a password reset, no further action is required.'));
+ });
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]);
+ }
+ );
+ }
+
public function testResolvedBrokerFollowsEventFakesAndTheirRestoration(): void
{
Notification::fake();
diff --git a/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php
new file mode 100644
index 000000000..bb3df2661
--- /dev/null
+++ b/tests/Integration/Auth/ForgotPasswordWithoutDefaultRoutesTest.php
@@ -0,0 +1,139 @@
+make('config');
+ $config->set([
+ 'app.key' => '12345678901234567890123456789012',
+ 'auth.providers.users.model' => AuthTestUser::class,
+ 'auth.passwords.users.throttle' => 0,
+ 'auth.timebox_duration' => 0,
+ 'hashing.bcrypt.rounds' => 4,
+ ]);
+ }
+
+ /**
+ * Define the custom password reset route.
+ */
+ protected function defineRoutes(Router $router): void
+ {
+ $router->get('custom/password/reset/{token}', function (string $token): string {
+ return 'Custom reset password!';
+ })->name('custom.password.reset');
+ }
+
+ public function testItCannotSendForgotPasswordEmail(): void
+ {
+ $this->expectExceptionObject(new RouteNotFoundException('Route [password.reset] not defined.'));
+
+ Notification::fake();
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token, 'email' => $user->email]);
+ }
+ );
+ }
+
+ public function testItCanSendForgotPasswordEmailViaCreateUrlUsing(): void
+ {
+ Notification::fake();
+
+ ResetPassword::createUrlUsing(function (mixed $user, string $token): string {
+ return route('custom.password.reset', $token);
+ });
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]);
+ }
+ );
+ }
+
+ public function testItCanSendForgotPasswordEmailViaToMailUsing(): void
+ {
+ Notification::fake();
+
+ ResetPassword::toMailUsing(function (mixed $notifiable, string $token): MailMessage {
+ return (new MailMessage)
+ ->subject(__('Reset your password'))
+ ->line(__('You are receiving this email because we received a password reset request for your account.'))
+ ->action(__('Reset Password'), route('custom.password.reset', $token))
+ ->line(__('If you did not request a password reset, no further action is required.'));
+ });
+
+ $user = $this->createUser();
+
+ Password::broker()->sendResetLink([
+ 'email' => $user->email,
+ ]);
+
+ Notification::assertSentTo(
+ $user,
+ function (ResetPassword $notification, array $channels) use ($user): bool {
+ $message = $notification->toMail($user);
+
+ return $notification->token !== ''
+ && $message->actionUrl === route('custom.password.reset', ['token' => $notification->token]);
+ }
+ );
+ }
+
+ /**
+ * Create a password-resettable user.
+ */
+ private function createUser(): AuthTestUser
+ {
+ return AuthTestUser::forceCreate([
+ 'name' => 'Auth User',
+ 'email' => 'auth@example.com',
+ 'password' => 'password',
+ ]);
+ }
+}
From 7516443c342bee31a630dead101390adbcc5dbac Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 06:14:46 +0000
Subject: [PATCH 28/29] Fix validation wildcard message lookup for literal and
numeric keys
Keep encoded attribute identity through inline, fallback and translated
message matching, and decode only for exact lookup and final display.
Wildcard-bearing segments must match whole attribute segments, so foo.*
cannot select the literal key foo.bar. Preserve existing literal-parent,
partial-wildcard and translated multi-segment matching without introducing
new message-key syntax or mutable lookup state.
Normalize numeric source keys before string matching in both local lookup
loops. PHP converts numeric array keys to integers; strict string calls
previously threw for root-list custom messages and wildcard labels.
Extend regression coverage for all message sources, literal parents,
partial wildcards, numeric keys, labels, positions and size-message types.
Add Request imports to all four JSON:API method examples while preserving
the generated-class example and stub.
Follow-up to the array_keys and attribute-identity port:
https://github.com/laravel/framework/pull/60918
Addresses the message-matching and documentation findings on backup PR #36.
The separately proposed rule-serialization changes are not included.
Validation: changed test file and full Validation ParaTest suite pass;
both composer analyse configurations, composer lint:fix and diff checks
pass. The two numeric-key regressions failed before the correction.
---
src/docs/eloquent-resources.md | 8 ++
.../src/Concerns/FormatsMessages.php | 66 ++++++---
tests/Validation/ValidationValidatorTest.php | 129 +++++++++++++++++-
3 files changed, 184 insertions(+), 19 deletions(-)
diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md
index d6ee8f439..fa13b3eda 100644
--- a/src/docs/eloquent-resources.md
+++ b/src/docs/eloquent-resources.md
@@ -1053,6 +1053,8 @@ If an attribute is expensive to calculate, you may return it from `toAttributes`
Or, for full control over the resource's attributes, you may override the `toAttributes` method on the resource:
```php
+use Hypervel\Http\Request;
+
/**
* Get the resource's attributes.
*
@@ -1100,6 +1102,8 @@ public array $relationships = [
Alternatively, you may override the `toRelationships` method on the resource:
```php
+use Hypervel\Http\Request;
+
/**
* Get the resource's relationships.
*/
@@ -1200,6 +1204,8 @@ By default, the resource's `type` is derived from the resource class name. For e
If you need to customize these values, you may override the `toType` and `toId` methods on your resource:
```php
+use Hypervel\Http\Request;
+
/**
* Get the resource's type.
*/
@@ -1257,6 +1263,8 @@ return $post->load('author', 'comments')
You may add links and meta information to your JSON:API resource objects by overriding the `toLinks` and `toMeta` methods on the resource:
```php
+use Hypervel\Http\Request;
+
/**
* Get the resource's links.
*/
diff --git a/src/validation/src/Concerns/FormatsMessages.php b/src/validation/src/Concerns/FormatsMessages.php
index 6e7130819..1b06876f1 100644
--- a/src/validation/src/Concerns/FormatsMessages.php
+++ b/src/validation/src/Concerns/FormatsMessages.php
@@ -31,7 +31,7 @@ protected function getMessage(string $attribute, string $rule): string
$lowerRule = Str::snake($rule);
- $customKey = 'validation.custom.' . $this->replacePlaceholderInString($attribute) . ".{$lowerRule}";
+ $customKey = "validation.custom.{$attribute}.{$lowerRule}";
$customMessage = $this->getCustomMessageFromTranslator(
in_array($rule, $this->sizeRules, true)
@@ -96,10 +96,10 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
$displayAttribute = $this->replacePlaceholderInString($attribute);
- $keys = ["{$displayAttribute}.{$lowerRule}", $lowerRule, $displayAttribute];
+ $keys = ["{$attribute}.{$lowerRule}", $lowerRule, $attribute];
if ($this->getAttributeType($attribute) !== 'file') {
- $shortRule = "{$displayAttribute}." . Str::snake(class_basename($lowerRule));
+ $shortRule = "{$attribute}." . Str::snake(class_basename($lowerRule));
if (! in_array($shortRule, $keys)) {
$keys[] = $shortRule;
@@ -110,11 +110,13 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
// message for the fields, then we will check for a general custom line
// that is not attribute specific. If we find either we'll return it.
foreach ($keys as $key) {
+ $displayKey = $this->replacePlaceholderInString($key);
+
foreach (array_keys($source) as $sourceKey) {
- if (str_contains($sourceKey, '*')) {
- $pattern = str_replace('\*', '([^.]*)', preg_quote($sourceKey, '#'));
+ $sourceKey = (string) $sourceKey;
- if (preg_match('#^' . $pattern . '\z#u', $key) === 1) {
+ if (str_contains($sourceKey, '*')) {
+ if (preg_match($this->getWildcardMessagePattern($sourceKey), $key) === 1) {
$message = $source[$sourceKey];
if (is_array($message) && isset($message[$lowerRule])) {
@@ -127,7 +129,7 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
continue;
}
- if (Str::is($sourceKey, $key)) {
+ if ($sourceKey === $displayKey) {
$message = $source[$sourceKey];
if ($sourceKey === $displayAttribute && is_array($message)) {
@@ -148,7 +150,9 @@ protected function getFromLocalArray(string $attribute, string $lowerRule, ?arra
protected function getCustomMessageFromTranslator(array|string $keys): string
{
foreach (Arr::wrap($keys) as $key) {
- if (($message = $this->translator->string($key)) !== $key) {
+ $displayKey = $this->replacePlaceholderInString($key);
+
+ if (($message = $this->translator->string($displayKey)) !== $displayKey) {
return $message;
}
@@ -178,9 +182,12 @@ protected function getCustomMessageFromTranslator(array|string $keys): string
*/
protected function getWildcardCustomMessages(array $messages, string $search, string $default): string
{
+ $displaySearch = $this->replacePlaceholderInString($search);
+
foreach ($messages as $key => $message) {
$key = (string) $key;
- if ($search === $key || (Str::contains($key, ['*']) && Str::is($key, $search))) {
+ if ($displaySearch === $key || (str_contains($key, '*')
+ && preg_match($this->getWildcardMessagePattern($key, multipleSegments: true), $search) === 1)) {
return $message;
}
}
@@ -188,6 +195,27 @@ protected function getWildcardCustomMessages(array $messages, string $search, st
return $default;
}
+ /**
+ * Build a wildcard message pattern that preserves literal path segments.
+ */
+ protected function getWildcardMessagePattern(string $key, bool $multipleSegments = false): string
+ {
+ $segments = [];
+
+ foreach (explode('.', $key) as $segment) {
+ $pattern = str_replace('\*', $multipleSegments ? '.*' : '[^.]*', preg_quote($segment, '#'));
+
+ // Fixed dots may name literal keys, but a wildcard segment must not split one.
+ $segments[] = str_contains($segment, '*')
+ ? '(?replacePlaceholderInString($attribute), $this->replacePlaceholderInString($primaryAttribute)]
- : [$this->replacePlaceholderInString($attribute)];
-
- $attribute = $expectedAttributes[0];
+ ? [$attribute, $primaryAttribute]
+ : [$attribute];
foreach ($expectedAttributes as $name) {
// The developer may dynamically specify the array of custom attributes on this
@@ -283,6 +309,8 @@ public function getDisplayableAttribute(string $attribute): string
}
}
+ $attribute = $this->replacePlaceholderInString($attribute);
+
// When no language line has been specified for the attribute and it is also
// an implicit attribute we will display the raw attribute's name and not
// modify it with any of these replacements before we display the name.
@@ -314,15 +342,17 @@ protected function getAttributeFromLocalArray(string $attribute, ?array $source
{
$source = $source ?: $this->customAttributes;
- if (isset($source[$attribute])) {
- return $source[$attribute];
+ $displayAttribute = $this->replacePlaceholderInString($attribute);
+
+ if (isset($source[$displayAttribute])) {
+ return $source[$displayAttribute];
}
foreach (array_keys($source) as $sourceKey) {
- if (str_contains($sourceKey, '*')) {
- $pattern = str_replace('\*', '([^.]*)', preg_quote($sourceKey, '#'));
+ $sourceKey = (string) $sourceKey;
- if (preg_match('#^' . $pattern . '\z#u', $attribute) === 1) {
+ if (str_contains($sourceKey, '*')) {
+ if (preg_match($this->getWildcardMessagePattern($sourceKey), $attribute) === 1) {
return $source[$sourceKey];
}
}
diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php
index 71b3b8225..acee2c49f 100755
--- a/tests/Validation/ValidationValidatorTest.php
+++ b/tests/Validation/ValidationValidatorTest.php
@@ -5880,6 +5880,32 @@ public function testNumericKeys()
$this->assertTrue($v->passes());
}
+ public function testNumericKeysUseCustomMessageArrays(): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['Taylor', ''],
+ ['*' => 'required'],
+ ['1' => ['required' => 'Second item required.']],
+ );
+
+ $this->assertSame('Second item required.', $validator->errors()->first('1'));
+ }
+
+ public function testNumericKeysUseExactAndWildcardAttributeNames(): void
+ {
+ $validator = new Validator(
+ $this->getArrayTranslator(),
+ ['', ''],
+ ['*' => 'required'],
+ ['required' => 'Required :attribute.'],
+ ['0' => 'First item', '*' => 'Other item'],
+ );
+
+ $this->assertSame('Required First item.', $validator->errors()->first('0'));
+ $this->assertSame('Required Other item.', $validator->errors()->first('1'));
+ }
+
public function testMergeRules()
{
$trans = $this->getArrayTranslator();
@@ -7949,13 +7975,111 @@ public function testLiteralFieldMessagesUseTheCorrectInput(string $attribute, st
$this->assertSame('Version: Invalid version', $validator->errors()->first($attribute));
}
+ #[TestWith(['inline'])]
+ #[TestWith(['fallback'])]
+ #[TestWith(['translation'])]
+ #[TestWith(['flat_translation'])]
+ public function testWildcardMessagesDoNotSplitLiteralKeys(string $source): void
+ {
+ $translator = new Translator(new ArrayLoader, 'en');
+ $messages = ['foo.*.required' => 'Nested message.', 'required' => 'Default message.'];
+
+ if ($source !== 'fallback') {
+ $translator->addLines(['validation.required' => 'Default message.'], 'en');
+ }
+
+ if ($source === 'translation') {
+ $translator->addLines(['validation.custom.foo.*.required' => 'Nested message.'], 'en');
+ } elseif ($source === 'flat_translation') {
+ $translator->addLines(['validation.custom' => ['foo.*.required' => 'Nested message.']], 'en');
+ }
+
+ foreach ([true, false] as $literal) {
+ $validator = new Validator(
+ $translator,
+ $literal ? ['foo.bar' => ''] : ['foo' => ['bar' => '']],
+ [$literal ? 'foo\.bar' : 'foo.bar' => 'required'],
+ $source === 'inline' ? $messages : [],
+ );
+
+ if ($source === 'fallback') {
+ $validator->setFallbackMessages($messages);
+ }
+
+ $this->assertSame(
+ $literal ? 'Default message.' : 'Nested message.',
+ $validator->errors()->first(),
+ );
+ }
+ }
+
+ #[TestWith(['inline'])]
+ #[TestWith(['translation'])]
+ public function testWildcardAttributesDoNotSplitLiteralKeys(string $source): void
+ {
+ $translator = new Translator(new ArrayLoader, 'en');
+ $translator->addLines(['validation.required' => 'Required :attribute.'], 'en');
+
+ if ($source === 'translation') {
+ $translator->addLines(['validation.attributes.foo.*' => 'Nested label'], 'en');
+ }
+
+ foreach ([true, false] as $literal) {
+ $validator = new Validator(
+ $translator,
+ $literal ? ['foo.bar' => ''] : ['foo' => ['bar' => '']],
+ [$literal ? 'foo\.bar' : 'foo.bar' => 'required'],
+ attributes: $source === 'inline' ? ['foo.*' => 'Nested label'] : [],
+ );
+
+ $this->assertSame(
+ $literal ? 'Required foo.bar.' : 'Required Nested label.',
+ $validator->errors()->first(),
+ );
+ }
+ }
+
+ #[TestWith(['items.list.*', ['items.list' => ['']], 'items\.list.*'])]
+ #[TestWith(['items.list.*', ['items' => ['list' => ['']]], 'items.list.*'])]
+ #[TestWith(['*', ['foo.bar' => ''], 'foo\.bar'])]
+ #[TestWith(['foo*bar', ['foo.bar' => ''], 'foo\.bar'])]
+ #[TestWith(['user*', ['username' => ''], 'username'])]
+ #[TestWith(['*name', ['username' => ''], 'username'])]
+ #[TestWith(['user*.email', ['user1' => ['email' => '']], 'user1.email'])]
+ #[TestWith(['settings*version', ['settings*version' => ''], 'settings\*version'])]
+ public function testWildcardMessagesAndLabelsPreserveLiteralSegments(string $pattern, array $data, string $attribute): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ $data,
+ [$attribute => 'required'],
+ [$pattern . '.required' => 'Required :attribute.'],
+ [$pattern => 'Custom label'],
+ );
+
+ $this->assertSame('Required Custom label.', $validator->errors()->first());
+ }
+
+ #[TestWith(['items.list.*.required', ['items.list' => ['']], 'items\.list.*'])]
+ #[TestWith(['a.*.required', ['a' => ['b' => ['c' => '']]], 'a.b.c'])]
+ #[TestWith(['a.*.required', ['a' => ["line\nbreak" => '']], "a.line\nbreak"])]
+ public function testTranslatedWildcardMessagesPreserveLiteralAndNestedSegments(string $pattern, array $data, string $attribute): void
+ {
+ $translator = new Translator(new ArrayLoader, 'en');
+ $translator->addLines(['validation.custom' => [$pattern => 'Custom message.']], 'en');
+
+ $validator = new Validator($translator, $data, [$attribute => 'required']);
+
+ $this->assertSame('Custom message.', $validator->errors()->first());
+ }
+
public function testLiteralWildcardSegmentsPreserveLabelsAndPositions(): void
{
$validator = new Validator(
$this->getArrayTranslator(),
['versions' => ['1.2' => [3 => 'invalid']]],
['versions.*.*' => 'integer'],
- ['integer' => ':attribute: :index / :position / :second-index'],
+ ['versions.*.*.integer' => ':attribute: :index / :position / :second-index'],
['versions.*.*' => 'Version'],
);
@@ -7999,6 +8123,7 @@ public function testComparisonMessagesPreserveBothLiteralFieldPaths(): void
#[TestWith(['inline'])]
#[TestWith(['translation'])]
+ #[TestWith(['flat_translation'])]
public function testLiteralFieldMessagesRetainTheirNumericType(string $source): void
{
$translator = $this->getArrayTranslator();
@@ -8006,6 +8131,8 @@ public function testLiteralFieldMessagesRetainTheirNumericType(string $source):
if ($source === 'translation') {
$translator->addLines(['validation.custom.value.amount.min.numeric' => 'Numeric minimum.'], 'en');
+ } elseif ($source === 'flat_translation') {
+ $translator->addLines(['validation.custom' => ['value.amount.min.numeric' => 'Numeric minimum.']], 'en');
}
$validator = new Validator(
From 859ea230e7e6b2f028c391597c5660675cc6fb94 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Mon, 7 Sep 2026 06:58:01 +0000
Subject: [PATCH 29/29] Preserve literal validation parameters across rule
serialization
Array keys, date formats and bounds, numeric field references, and string
prefixes or suffixes could be split at commas. Existing quoted builders
also lost parameter boundaries when a value ended with a backslash.
Composite rule expansion split literal pipes into additional rules.
Use standard CSV with doubled quotes and literal backslashes throughout
the builders and native parser. Align Unique's ignored-ID serialization
and remove backslash stripping from both the delegated and compiled
presence-check consumers. Existing where filters now retain their values.
Expand Date, Numeric and StringRule through their constraint arrays rather
than joining and splitting strings. Preserve constraint ordering and
deduplication without adding an iterator or collection contract.
Fix the same writer defect in Data validation attributes: retain normalized
parameter boundaries until serialization, prefix named fields before
quoting, and preserve regex, reference, enum, date and null handling.
Document the approved handwritten CSV syntax change. Quotes inside quoted
parameters are doubled rather than backslash-escaped. Composite subclasses
that customize only __toString() must customize the constraint-array path
when changing the rules consumed by Validator.
This completes the literal-key correction found while reviewing the port
of https://github.com/laravel/framework/pull/60918. No additional upstream
PR is claimed by these fixes.
Validation: focused changed tests, the Validation and Data ParaTest suites,
SQLite compiled/delegated presence-check integration tests, both PHPStan
configurations, formatting and diff checks pass. Other database drivers
receive the inherited regression cases in CI; they were not run locally.
---
.../Support/Validation/RuleDenormalizer.php | 23 ++++++--
src/docs/porting-from-laravel.md | 6 ++
src/docs/validation.md | 16 ++++++
src/validation/README.md | 1 +
.../src/Concerns/ValidatesAttributes.php | 2 +-
src/validation/src/Rules/ArrayKeys.php | 2 +-
src/validation/src/Rules/ArrayRule.php | 2 +-
src/validation/src/Rules/Date.php | 20 +++++--
src/validation/src/Rules/Numeric.php | 24 +++++---
src/validation/src/Rules/StringRule.php | 33 +++++++++--
src/validation/src/Rules/Unique.php | 2 +-
src/validation/src/ValidationRuleParser.php | 8 ++-
src/validation/src/Validator.php | 2 +-
.../Validation/ValidationAttributeTest.php | 44 ++++++++++++--
...ValidationBatchDatabaseCheckerTestCase.php | 39 +++++++++++--
.../ValidationArrayKeysRuleTest.php | 27 +++++++--
tests/Validation/ValidationArrayRuleTest.php | 36 +++++++++---
tests/Validation/ValidationDateRuleTest.php | 57 +++++++++++++------
.../Validation/ValidationNumericRuleTest.php | 53 ++++++++++++-----
tests/Validation/ValidationRuleParserTest.php | 55 +++++++++++++++++-
tests/Validation/ValidationStringRuleTest.php | 40 ++++++++++---
tests/Validation/ValidationUniqueRuleTest.php | 8 +--
22 files changed, 400 insertions(+), 100 deletions(-)
diff --git a/src/data/src/Support/Validation/RuleDenormalizer.php b/src/data/src/Support/Validation/RuleDenormalizer.php
index 39434d6bc..f2dd0a2e1 100644
--- a/src/data/src/Support/Validation/RuleDenormalizer.php
+++ b/src/data/src/Support/Validation/RuleDenormalizer.php
@@ -14,6 +14,7 @@
use Hypervel\Data\Attributes\Validation\StringValidationAttribute;
use Hypervel\Data\Support\Validation\References\ExternalReference;
use Hypervel\Data\Support\Validation\References\FieldReference;
+use Hypervel\Support\Arr;
class RuleDenormalizer
{
@@ -73,6 +74,7 @@ protected function normalizeStringValidationAttribute(
ValidationPath $path,
): array {
$parameters = [];
+ $quoteParameters = ! in_array($rule->keyword(), ['regex', 'not_regex'], true);
foreach ($rule->parameters() as $key => $value) {
$parameter = $this->normalizeRuleParameter($value, $path);
@@ -81,7 +83,16 @@ protected function normalizeStringValidationAttribute(
continue;
}
- $parameters[] = is_string($key) ? "{$key}={$parameter}" : $parameter;
+ foreach (Arr::wrap($parameter) as $index => $field) {
+ if (is_string($key) && $index === 0) {
+ $field = "{$key}={$field}";
+ }
+
+ // Quote after adding the name so the entire parameter remains one CSV field.
+ $parameters[] = $quoteParameters && strpbrk($field, ',"') !== false
+ ? '"' . str_replace('"', '""', $field) . '"'
+ : $field;
+ }
}
if ($parameters === []) {
@@ -92,12 +103,14 @@ protected function normalizeStringValidationAttribute(
}
/**
- * Convert one rule parameter into Validator string form.
+ * Normalize one rule parameter while preserving its field boundaries.
+ *
+ * @return null|list|string
*/
protected function normalizeRuleParameter(
mixed $parameter,
ValidationPath $path,
- ): ?string {
+ ): array|string|null {
if ($parameter === null) {
return null;
}
@@ -117,11 +130,11 @@ protected function normalizeRuleParameter(
if (is_array($parameter)) {
// ValidatesAttributes::convertValuesToNull() decodes list values from this literal token.
$subParameters = array_map(
- fn (mixed $subParameter): string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null',
+ fn (mixed $subParameter): array|string => $this->normalizeRuleParameter($subParameter, $path) ?? 'null',
$parameter
);
- return implode(',', $subParameters);
+ return Arr::flatten($subParameters);
}
if ($parameter instanceof DateTimeInterface) {
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index 72c75d44f..a86cbf65d 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -25,6 +25,7 @@
- [HTTP Client and Concurrency](#http-client-and-concurrency)
- [Scout](#scout)
- [JSON Schema](#json-schema)
+ - [Validation](#validation)
- [Data Objects](#data-objects)
- [Rate Limiting](#rate-limiting)
- [Pagination](#pagination)
@@ -500,6 +501,11 @@ Hypervel compiles integer and float values passed to Scout's Algolia `where`, `w
When porting schemas that place sibling assertions beside a local `$ref` or use nullable composition, make overlapping assertions identical. Hypervel rejects conflicts instead of silently replacing referenced constraints. See the [JSON Schema documentation](/docs/{{version}}/json-schema#reconstructing-schemas).
+
+### Validation
+
+Handwritten validation parameters use standard CSV quoting. Replace backslash-escaped quotes inside quoted parameters with doubled quotes; backslashes are literal. Fluent rule builders handle quoting for you. See [rule parameters](/docs/{{version}}/validation#rule-parameters).
+
### Data Objects
diff --git a/src/docs/validation.md b/src/docs/validation.md
index ba0d38634..2545a4f32 100644
--- a/src/docs/validation.md
+++ b/src/docs/validation.md
@@ -147,6 +147,22 @@ $validatedData = $request->validateWithBag('post', [
]);
```
+
+#### Rule Parameters
+
+Fluent rule builders quote parameter values for you. When writing a rule string yourself, enclose values containing commas or quotes in double quotes and double any quotes within the value. Backslashes are preserved literally:
+
+```php
+use Hypervel\Validation\Rule;
+
+$request->validate([
+ 'name' => [Rule::in(['Taylor, "Otwell"'])],
+ 'alias' => ['in:"Taylor, ""Otwell"""'],
+]);
+```
+
+If a parameter contains `|`, use a rule object or an array of individual rules instead of joining the rules into a single string. Regular expression parameters retain their regular expression syntax.
+
#### Stopping on First Validation Failure
diff --git a/src/validation/README.md b/src/validation/README.md
index 7a06d1a62..b24e7c4a6 100644
--- a/src/validation/README.md
+++ b/src/validation/README.md
@@ -7,6 +7,7 @@ Documentation: https://hypervel.org/docs/validation
## Differences From Laravel
+- String rule parameters use standard CSV quoting with literal backslashes. See [rule parameters](https://hypervel.org/docs/validation#rule-parameters).
- Scalar `in` and `not_in` rules compare the submitted value with the rule's literal values as strings. Numeric strings are not loosely coerced.
- Date comparison rules allow a referenced field to be missing or `null` unless it is also required. Unparseable date strings and invalid referenced values fail validation instead of being compared with `null`.
- Rule keys may escape a literal asterisk as `\*`, matching the existing `\.` literal-dot syntax.
diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php
index 6b9836672..7a662d175 100644
--- a/src/validation/src/Concerns/ValidatesAttributes.php
+++ b/src/validation/src/Concerns/ValidatesAttributes.php
@@ -1066,7 +1066,7 @@ public function validateUnique(string $attribute, mixed $value, mixed $parameter
[$idColumn, $id] = $this->getUniqueIds($idColumn, $parameters);
if (! is_null($id)) {
- $id = stripslashes((string) $id);
+ $id = (string) $id;
}
}
diff --git a/src/validation/src/Rules/ArrayKeys.php b/src/validation/src/Rules/ArrayKeys.php
index 8cd100adb..df5dfc5c6 100644
--- a/src/validation/src/Rules/ArrayKeys.php
+++ b/src/validation/src/Rules/ArrayKeys.php
@@ -35,7 +35,7 @@ public function __construct(array|Arrayable|UnitEnum|int|string $keys)
public function __toString(): string
{
$keys = array_map(
- static fn ($key) => enum_value($key),
+ static fn ($key): string => '"' . str_replace('"', '""', (string) enum_value($key)) . '"',
$this->keys,
);
diff --git a/src/validation/src/Rules/ArrayRule.php b/src/validation/src/Rules/ArrayRule.php
index 10f4dd655..2c74cce97 100644
--- a/src/validation/src/Rules/ArrayRule.php
+++ b/src/validation/src/Rules/ArrayRule.php
@@ -38,7 +38,7 @@ public function __toString(): string
}
$keys = array_map(
- static fn ($key) => enum_value($key),
+ static fn ($key): string => '"' . str_replace('"', '""', (string) enum_value($key)) . '"',
$this->keys,
);
diff --git a/src/validation/src/Rules/Date.php b/src/validation/src/Rules/Date.php
index ac3d23665..174ae0fa4 100644
--- a/src/validation/src/Rules/Date.php
+++ b/src/validation/src/Rules/Date.php
@@ -162,9 +162,11 @@ protected function addRule(array|string $rules): static
*/
protected function formatDate(DateTimeInterface|string $date): string
{
- return $date instanceof DateTimeInterface
+ $date = $date instanceof DateTimeInterface
? $date->format($this->format ?? 'Y-m-d')
: $date;
+
+ return '"' . str_replace('"', '""', $date) . '"';
}
/**
@@ -172,10 +174,20 @@ protected function formatDate(DateTimeInterface|string $date): string
*/
public function __toString(): string
{
- return implode('|', [
- $this->format === null ? 'date' : 'date_format:' . $this->format,
+ return implode('|', $this->toArray());
+ }
+
+ /**
+ * Convert the rule to an array of validation rules.
+ *
+ * @return list
+ */
+ public function toArray(): array
+ {
+ return [
+ $this->format === null ? 'date' : 'date_format:"' . str_replace('"', '""', $this->format) . '"',
...$this->constraints,
- ]);
+ ];
}
/**
diff --git a/src/validation/src/Rules/Numeric.php b/src/validation/src/Rules/Numeric.php
index 61155b6c4..811e20dbc 100644
--- a/src/validation/src/Rules/Numeric.php
+++ b/src/validation/src/Rules/Numeric.php
@@ -44,7 +44,7 @@ public function decimal(int $min, ?int $max = null): static
*/
public function different(string $field): static
{
- return $this->addRule('different:' . $field);
+ return $this->addRule('different:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -68,7 +68,7 @@ public function digitsBetween(int $min, int $max): static
*/
public function greaterThan(string $field): static
{
- return $this->addRule('gt:' . $field);
+ return $this->addRule('gt:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -76,7 +76,7 @@ public function greaterThan(string $field): static
*/
public function greaterThanOrEqualTo(string $field): static
{
- return $this->addRule('gte:' . $field);
+ return $this->addRule('gte:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -92,7 +92,7 @@ public function integer(): static
*/
public function lessThan(string $field): static
{
- return $this->addRule('lt:' . $field);
+ return $this->addRule('lt:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -100,7 +100,7 @@ public function lessThan(string $field): static
*/
public function lessThanOrEqualTo(string $field): static
{
- return $this->addRule('lte:' . $field);
+ return $this->addRule('lte:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -148,7 +148,7 @@ public function multipleOf(float|int $value): static
*/
public function same(string $field): static
{
- return $this->addRule('same:' . $field);
+ return $this->addRule('same:"' . str_replace('"', '""', $field) . '"');
}
/**
@@ -164,7 +164,17 @@ public function exactly(int $value): static
*/
public function __toString(): string
{
- return implode('|', array_unique($this->constraints));
+ return implode('|', $this->toArray());
+ }
+
+ /**
+ * Convert the rule to an array of validation rules.
+ *
+ * @return list
+ */
+ public function toArray(): array
+ {
+ return array_values(array_unique($this->constraints));
}
/**
diff --git a/src/validation/src/Rules/StringRule.php b/src/validation/src/Rules/StringRule.php
index 03003c5d1..d636d1d77 100644
--- a/src/validation/src/Rules/StringRule.php
+++ b/src/validation/src/Rules/StringRule.php
@@ -62,7 +62,7 @@ public function between(int $min, int $max): static
*/
public function doesntEndWith(string ...$values): static
{
- return $this->addRule('doesnt_end_with:' . implode(',', $values));
+ return $this->addRule('doesnt_end_with:' . $this->formatValues($values));
}
/**
@@ -70,7 +70,7 @@ public function doesntEndWith(string ...$values): static
*/
public function doesntStartWith(string ...$values): static
{
- return $this->addRule('doesnt_start_with:' . implode(',', $values));
+ return $this->addRule('doesnt_start_with:' . $this->formatValues($values));
}
/**
@@ -78,7 +78,7 @@ public function doesntStartWith(string ...$values): static
*/
public function endsWith(string ...$values): static
{
- return $this->addRule('ends_with:' . implode(',', $values));
+ return $this->addRule('ends_with:' . $this->formatValues($values));
}
/**
@@ -118,7 +118,7 @@ public function min(int $value): static
*/
public function startsWith(string ...$values): static
{
- return $this->addRule('starts_with:' . implode(',', $values));
+ return $this->addRule('starts_with:' . $this->formatValues($values));
}
/**
@@ -134,7 +134,30 @@ public function uppercase(): static
*/
public function __toString(): string
{
- return implode('|', array_unique($this->constraints));
+ return implode('|', $this->toArray());
+ }
+
+ /**
+ * Convert the rule to an array of validation rules.
+ *
+ * @return list
+ */
+ public function toArray(): array
+ {
+ return array_values(array_unique($this->constraints));
+ }
+
+ /**
+ * Format literal values as CSV parameters.
+ *
+ * @param list $values
+ */
+ protected function formatValues(array $values): string
+ {
+ return implode(',', array_map(
+ static fn (string $value): string => '"' . str_replace('"', '""', $value) . '"',
+ $values,
+ ));
}
/**
diff --git a/src/validation/src/Rules/Unique.php b/src/validation/src/Rules/Unique.php
index 3f897fb67..3134a7846 100644
--- a/src/validation/src/Rules/Unique.php
+++ b/src/validation/src/Rules/Unique.php
@@ -58,7 +58,7 @@ public function __toString(): string
'unique:%s,%s,%s,%s,%s',
$this->table,
$this->column,
- $this->ignore !== null ? '"' . addslashes((string) $this->ignore) . '"' : 'NULL',
+ $this->ignore !== null ? '"' . str_replace('"', '""', (string) $this->ignore) . '"' : 'NULL',
$this->idColumn,
$this->formatWheres()
), ',');
diff --git a/src/validation/src/ValidationRuleParser.php b/src/validation/src/ValidationRuleParser.php
index c59e94c67..670c7c55b 100644
--- a/src/validation/src/ValidationRuleParser.php
+++ b/src/validation/src/ValidationRuleParser.php
@@ -89,7 +89,8 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array
if (is_object($rule)) {
if ($rule instanceof Date || $rule instanceof Numeric || $rule instanceof StringRule) {
- return explode('|', (string) $rule);
+ // Composite rules already separate constraints; literal parameters may contain pipes.
+ return $rule->toArray();
}
return Arr::wrap($this->prepareRule($rule, $attribute));
@@ -99,7 +100,7 @@ protected function explodeExplicitRule(mixed $rule, string $attribute): array
foreach ($rule as $value) {
if ($value instanceof Date || $value instanceof Numeric || $value instanceof StringRule) {
- $rules = array_merge($rules, explode('|', (string) $value));
+ $rules = array_merge($rules, $value->toArray());
} else {
$rules[] = $this->prepareRule($value, $attribute);
}
@@ -421,7 +422,8 @@ protected static function parseStringRule(string|Stringable $rule): array
*/
protected static function parseParameters(string $rule, string $parameter): array
{
- return static::ruleIsRegex($rule) ? [$parameter] : str_getcsv($parameter, escape: '\\');
+ // Builders use doubled quotes; a backslash escape corrupts trailing backslashes.
+ return static::ruleIsRegex($rule) ? [$parameter] : str_getcsv($parameter, escape: '');
}
/**
diff --git a/src/validation/src/Validator.php b/src/validation/src/Validator.php
index ab90b75d8..f33b03d6f 100644
--- a/src/validation/src/Validator.php
+++ b/src/validation/src/Validator.php
@@ -849,7 +849,7 @@ private function extractPresenceRuleMeta(
[$idColumn, $ignore] = $this->getUniqueIds($modelIdColumn, $parameters);
if ($ignore !== null) {
- $ignore = stripslashes((string) $ignore);
+ $ignore = (string) $ignore;
}
}
if (isset($parameters[4])) {
diff --git a/tests/Data/Attributes/Validation/ValidationAttributeTest.php b/tests/Data/Attributes/Validation/ValidationAttributeTest.php
index 67981922c..8a58d75cc 100644
--- a/tests/Data/Attributes/Validation/ValidationAttributeTest.php
+++ b/tests/Data/Attributes/Validation/ValidationAttributeTest.php
@@ -124,6 +124,8 @@
use Hypervel\Data\Support\Validation\ValidationPath;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Tests\TestCase;
+use Hypervel\Translation\ArrayLoader;
+use Hypervel\Translation\Translator;
use Hypervel\Validation\Rules\AnyOf as AnyOfRule;
use Hypervel\Validation\Rules\Can as CanRule;
use Hypervel\Validation\Rules\Dimensions as DimensionsRule;
@@ -132,6 +134,7 @@
use Hypervel\Validation\Rules\ProhibitedIf as ProhibitedIfRule;
use Hypervel\Validation\Rules\RequiredIf as RequiredIfRule;
use Hypervel\Validation\ValidationRuleParser;
+use Hypervel\Validation\Validator;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
use ReflectionProperty;
@@ -146,17 +149,14 @@ public function testGetsAStringRepresentationOfRules(): void
$this->assertSame('string', (string) new StringType);
}
- /**
- * Test rule parameters normalize to Validator string values.
- */
#[DataProvider('normalizedValues')]
- public function testNormalizesValues(mixed $input, string $output): void
+ public function testNormalizesValues(mixed $input, string $output, ?string $key = null): void
{
- $attribute = new class([$input]) extends StringValidationAttribute {
+ $attribute = new class($key === null ? [$input] : [$key => $input]) extends StringValidationAttribute {
/**
* Create a test validation attribute.
*
- * @param list $parameters
+ * @param array $parameters
*/
public function __construct(protected array $parameters)
{
@@ -202,6 +202,12 @@ public static function normalizedValues(): iterable
yield [false, 'false'];
yield [['a', 'b', 'c'], 'a,b,c'];
yield [[null], 'null'];
+ yield ['last,first', '"last,first"'];
+ yield ['a"b', '"a""b"'];
+ yield ['path\\', 'path\\'];
+ yield [[['a,b'], 'c'], '"a,b",c'];
+ yield [new ValidationAttributeExternalReference(['a,b', 'c']), '"a,b",c'];
+ yield ['a,b', '"name=a,b"', 'name'];
yield [
CarbonImmutable::create(
2020,
@@ -221,6 +227,32 @@ public static function normalizedValues(): iterable
];
}
+ #[DataProvider('literalParameterRules')]
+ public function testValidatesLiteralAttributeParameters(
+ StringValidationAttribute $attribute,
+ array $data,
+ bool $passes,
+ ): void {
+ $rules = (new RuleDenormalizer)->execute($attribute, ValidationPath::create());
+ $validator = new Validator(new Translator(new ArrayLoader, 'en'), $data, ['value' => $rules]);
+
+ $this->assertSame($passes, $validator->passes());
+ }
+
+ /**
+ * Provide attributes whose literal parameters contain rule delimiters.
+ */
+ public static function literalParameterRules(): iterable
+ {
+ yield 'RFC2822 date' => [new DateFormat(DATE_RFC2822), ['value' => 'Tue, 02 Jan 2024 12:00:00 +0000'], true];
+ yield 'literal array key' => [new ArrayType('last,first'), ['value' => ['last,first' => 'Taylor']], true];
+ yield 'split array key' => [new ArrayType('last,first'), ['value' => ['last' => 'Taylor']], false];
+ yield 'matching dependent value' => [new RequiredIf('status', 'a,b'), ['status' => 'a,b'], false];
+ yield 'partial dependent value' => [new RequiredIf('status', 'a,b'), ['status' => 'a'], true];
+ yield 'raw regex' => [new Regex('/^a,"b"\|c$/'), ['value' => 'a,"b"|c'], true];
+ yield 'raw negative regex' => [new NotRegex('/^a,"b"\|c$/'), ['value' => 'a,"b"|c'], false];
+ }
+
/**
* Test simple attributes compile from objects and parsed string parameters.
*/
diff --git a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php
index c42f2db10..4124d32aa 100644
--- a/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php
+++ b/tests/Integration/Validation/Database/ValidationBatchDatabaseCheckerTestCase.php
@@ -23,6 +23,7 @@
use Hypervel\Validation\Rules\Exists;
use Hypervel\Validation\Rules\Unique;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Stringable;
@@ -997,10 +998,11 @@ function ($query) use (&$uniqueCallbackCalls): void {
$this->assertSame(2, $uniqueCallbackCalls);
}
- public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): void
+ #[TestWith(['slash\id@example.com', false])]
+ #[TestWith(['quote"\id,@example.com\\', false])]
+ #[TestWith(['quote"\id,@example.com\\', true])]
+ public function testStringFormUniqueRulePreservesIgnoredValue(string $email, bool $stopOnFirstFailure): void
{
- $email = 'slash\id@example.com';
-
$this->app->make('db')->table('batch_test_users')->insert([
'external_id' => 3,
'email' => $email,
@@ -1017,6 +1019,7 @@ public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): v
]],
['items.*.email' => ['required', $rule]],
);
+ $validator->stopOnFirstFailure($stopOnFirstFailure);
DB::enableQueryLog();
@@ -1033,7 +1036,35 @@ public function testStringFormUniqueRuleUnescapesIgnoredValueBeforeBatching(): v
return str_contains($entry['query'], 'batch_test_users');
});
- $this->assertCount(1, $uniqueQueries);
+ $this->assertCount($stopOnFirstFailure ? 2 : 1, $uniqueQueries);
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testPresenceFiltersPreserveLiteralValues(bool $stopOnFirstFailure): void
+ {
+ $status = 'active,"quoted"\\';
+ DB::table('batch_test_users')->where('email', 'user1@example.com')->update(['status' => $status]);
+
+ $data = ['items' => [
+ ['email' => 'user1@example.com'],
+ ['email' => 'user2@example.com'],
+ ]];
+ $exists = $this->makeValidator($data, [
+ 'items.*.email' => [(new Exists('batch_test_users', 'email'))->where('status', $status)],
+ ]);
+ $exists->stopOnFirstFailure($stopOnFirstFailure);
+
+ $this->assertFalse($exists->passes());
+ $this->assertSame(['items.1.email'], $exists->errors()->keys());
+
+ $unique = $this->makeValidator($data, [
+ 'items.*.email' => [(new Unique('batch_test_users', 'email'))->where('status', $status)],
+ ]);
+ $unique->stopOnFirstFailure($stopOnFirstFailure);
+
+ $this->assertFalse($unique->passes());
+ $this->assertSame(['items.0.email'], $unique->errors()->keys());
}
public function testArrayFormExistsRuleCanConsumeFactsFromIdenticalWildcardShape(): void
diff --git a/tests/Validation/ValidationArrayKeysRuleTest.php b/tests/Validation/ValidationArrayKeysRuleTest.php
index 68cc9682f..ecb53792c 100644
--- a/tests/Validation/ValidationArrayKeysRuleTest.php
+++ b/tests/Validation/ValidationArrayKeysRuleTest.php
@@ -20,27 +20,27 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void
{
$rule = Rule::arrayKeys('key_1', 'key_2', 'key_3');
- $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::arrayKeys(['key_1', 'key_2', 'key_3']);
- $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::arrayKeys(collect(['key_1', 'key_2', 'key_3']));
- $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::arrayKeys([ArrayKeys::key_1, ArrayKeys::key_2, ArrayKeys::key_3]);
- $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::arrayKeys([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2, ArrayKeysBacked::Key3]);
- $this->assertSame('array_keys:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array_keys:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::arrayKeys([1, 2, 3]);
- $this->assertSame('array_keys:1,2,3', (string) $rule);
+ $this->assertSame('array_keys:"1","2","3"', (string) $rule);
}
public function testArrayKeysValidation(): void
@@ -155,6 +155,10 @@ public function testUnexpectedKeysArePlaceholderSafeAndEmptyForNonArrays(): void
#[TestWith(['a.b'])]
#[TestWith(['a*b'])]
+ #[TestWith(['a,b'])]
+ #[TestWith(['a"b'])]
+ #[TestWith(['a\\'])]
+ #[TestWith(['a\"b'])]
public function testArrayKeysAcceptsLiteralKeys(string $key): void
{
$validator = new Validator(
@@ -166,6 +170,17 @@ public function testArrayKeysAcceptsLiteralKeys(string $key): void
$this->assertTrue($validator->passes());
}
+ public function testArrayKeysDoesNotAcceptPartsOfCommaSeparatedLiteralKeys(): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['options' => ['a' => 1, 'b' => 2]],
+ ['options' => Rule::arrayKeys('a,b')],
+ );
+
+ $this->assertTrue($validator->fails());
+ }
+
#[TestWith(['options', 'options'])]
#[TestWith(['options.group', 'options\.group'])]
#[TestWith(['options*group', 'options\*group'])]
diff --git a/tests/Validation/ValidationArrayRuleTest.php b/tests/Validation/ValidationArrayRuleTest.php
index dcb96673f..9b851c754 100644
--- a/tests/Validation/ValidationArrayRuleTest.php
+++ b/tests/Validation/ValidationArrayRuleTest.php
@@ -9,12 +9,13 @@
use Hypervel\Translation\Translator;
use Hypervel\Validation\Rule;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
include_once 'Enums.php';
class ValidationArrayRuleTest extends TestCase
{
- public function testItCorrectlyFormatsAStringVersionOfTheRule()
+ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void
{
$rule = Rule::array();
@@ -25,29 +26,48 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule()
$rule = Rule::array('key_1', 'key_2', 'key_3');
- $this->assertSame('array:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::array(['key_1', 'key_2', 'key_3']);
- $this->assertSame('array:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::array(collect(['key_1', 'key_2', 'key_3']));
- $this->assertSame('array:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::array([ArrayKeys::key_1, ArrayKeys::key_2, ArrayKeys::key_3]);
- $this->assertSame('array:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::array([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2, ArrayKeysBacked::Key3]);
- $this->assertSame('array:key_1,key_2,key_3', (string) $rule);
+ $this->assertSame('array:"key_1","key_2","key_3"', (string) $rule);
$rule = Rule::array(['key_1', 'key_1']);
- $this->assertSame('array:key_1,key_1', (string) $rule);
+ $this->assertSame('array:"key_1","key_1"', (string) $rule);
$rule = Rule::array([1, 2, 3]);
- $this->assertSame('array:1,2,3', (string) $rule);
+ $this->assertSame('array:"1","2","3"', (string) $rule);
+ }
+
+ #[TestWith(['a,b'])]
+ #[TestWith(['a"b'])]
+ #[TestWith(['a\\'])]
+ #[TestWith(['a\"b'])]
+ public function testArrayRulePreservesLiteralKeys(string $key): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['options' => [$key => 'value']],
+ ['options' => Rule::array($key)],
+ );
+
+ $this->assertTrue($validator->passes());
+
+ $validator->setData(['options' => ['a' => 'value']]);
+
+ $this->assertTrue($validator->fails());
}
public function testArrayValidation()
diff --git a/tests/Validation/ValidationDateRuleTest.php b/tests/Validation/ValidationDateRuleTest.php
index abaca620e..f0a39d4b4 100644
--- a/tests/Validation/ValidationDateRuleTest.php
+++ b/tests/Validation/ValidationDateRuleTest.php
@@ -11,6 +11,7 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\Date;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
class ValidationDateRuleTest extends TestCase
{
@@ -26,76 +27,76 @@ public function testDefaultDateRule(): void
public function testDateFormatRule(): void
{
$rule = Rule::date()->format('d/m/Y');
- $this->assertEquals('date_format:d/m/Y', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"', (string) $rule);
}
public function testAfterTodayRule(): void
{
$rule = Rule::date()->afterToday();
- $this->assertEquals('date|after:today', (string) $rule);
+ $this->assertEquals('date|after:"today"', (string) $rule);
$rule = Rule::date()->todayOrAfter();
- $this->assertEquals('date|after_or_equal:today', (string) $rule);
+ $this->assertEquals('date|after_or_equal:"today"', (string) $rule);
}
public function testBeforeTodayRule(): void
{
$rule = Rule::date()->beforeToday();
- $this->assertEquals('date|before:today', (string) $rule);
+ $this->assertEquals('date|before:"today"', (string) $rule);
$rule = Rule::date()->todayOrBefore();
- $this->assertEquals('date|before_or_equal:today', (string) $rule);
+ $this->assertEquals('date|before_or_equal:"today"', (string) $rule);
}
public function testAfterSpecificDateRule(): void
{
$rule = Rule::date()->after(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date|after:2024-01-01', (string) $rule);
+ $this->assertEquals('date|after:"2024-01-01"', (string) $rule);
$rule = Rule::date()->format('d/m/Y')->after(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date_format:d/m/Y|after:01/01/2024', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"|after:"01/01/2024"', (string) $rule);
}
public function testBeforeSpecificDateRule(): void
{
$rule = Rule::date()->before(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date|before:2024-01-01', (string) $rule);
+ $this->assertEquals('date|before:"2024-01-01"', (string) $rule);
$rule = Rule::date()->format('d/m/Y')->before(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date_format:d/m/Y|before:01/01/2024', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"|before:"01/01/2024"', (string) $rule);
}
public function testAfterOrEqualSpecificDateRule(): void
{
$rule = Rule::date()->afterOrEqual(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date|after_or_equal:2024-01-01', (string) $rule);
+ $this->assertEquals('date|after_or_equal:"2024-01-01"', (string) $rule);
$rule = Rule::date()->format('d/m/Y')->afterOrEqual(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date_format:d/m/Y|after_or_equal:01/01/2024', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"|after_or_equal:"01/01/2024"', (string) $rule);
}
public function testBeforeOrEqualSpecificDateRule(): void
{
$rule = Rule::date()->beforeOrEqual(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date|before_or_equal:2024-01-01', (string) $rule);
+ $this->assertEquals('date|before_or_equal:"2024-01-01"', (string) $rule);
$rule = Rule::date()->format('d/m/Y')->beforeOrEqual(CarbonImmutable::parse('2024-01-01'));
- $this->assertEquals('date_format:d/m/Y|before_or_equal:01/01/2024', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"|before_or_equal:"01/01/2024"', (string) $rule);
}
public function testBetweenDatesRule(): void
{
$rule = Rule::date()->between(CarbonImmutable::parse('2024-01-01'), CarbonImmutable::parse('2024-02-01'));
- $this->assertEquals('date|after:2024-01-01|before:2024-02-01', (string) $rule);
+ $this->assertEquals('date|after:"2024-01-01"|before:"2024-02-01"', (string) $rule);
$rule = Rule::date()->format('d/m/Y')->between(CarbonImmutable::parse('2024-01-01'), CarbonImmutable::parse('2024-02-01'));
- $this->assertEquals('date_format:d/m/Y|after:01/01/2024|before:01/02/2024', (string) $rule);
+ $this->assertEquals('date_format:"d/m/Y"|after:"01/01/2024"|before:"01/02/2024"', (string) $rule);
}
public function testBetweenOrEqualDatesRule(): void
{
$rule = Rule::date()->betweenOrEqual('2024-01-01', '2024-02-01');
- $this->assertEquals('date|after_or_equal:2024-01-01|before_or_equal:2024-02-01', (string) $rule);
+ $this->assertEquals('date|after_or_equal:"2024-01-01"|before_or_equal:"2024-02-01"', (string) $rule);
}
public function testChainedRules(): void
@@ -104,7 +105,7 @@ public function testChainedRules(): void
->format('Y-m-d')
->after('2024-01-01 00:00:00')
->before('2025-01-01 00:00:00');
- $this->assertEquals('date_format:Y-m-d|after:2024-01-01 00:00:00|before:2025-01-01 00:00:00', (string) $rule);
+ $this->assertEquals('date_format:"Y-m-d"|after:"2024-01-01 00:00:00"|before:"2025-01-01 00:00:00"', (string) $rule);
$rule = Rule::date()
->format('Y-m-d')
@@ -114,7 +115,27 @@ public function testChainedRules(): void
->unless(true, function ($rule) {
$rule->before('2025-01-01');
});
- $this->assertSame('date_format:Y-m-d|after:2024-01-01', (string) $rule);
+ $this->assertSame('date_format:"Y-m-d"|after:"2024-01-01"', (string) $rule);
+ }
+
+ #[TestWith([DATE_RFC2822])]
+ #[TestWith(['Y-m-d"H:i:s'])]
+ #[TestWith(['Y-m-d\|H:i:s'])]
+ public function testDateFormatsAndBoundsPreserveLiteralSeparators(string $format): void
+ {
+ $date = CarbonImmutable::parse('2024-01-02 12:00:00', 'UTC');
+ $rule = Rule::date()->format($format)->after($date->subDay())->before($date->addDay());
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['date' => $date->format($format)],
+ ['date' => $rule],
+ );
+
+ $this->assertTrue($validator->passes());
+
+ $validator->setData(['date' => $date->addDays(2)->format($format)]);
+
+ $this->assertTrue($validator->fails());
}
public function testDateValidation(): void
diff --git a/tests/Validation/ValidationNumericRuleTest.php b/tests/Validation/ValidationNumericRuleTest.php
index d962f1017..b360e6ce0 100644
--- a/tests/Validation/ValidationNumericRuleTest.php
+++ b/tests/Validation/ValidationNumericRuleTest.php
@@ -10,6 +10,7 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\Numeric;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
class ValidationNumericRuleTest extends TestCase
{
@@ -40,10 +41,10 @@ public function testDecimalRule()
$this->assertEquals('numeric|decimal:2', (string) $rule);
}
- public function testDifferentRule()
+ public function testDifferentRule(): void
{
$rule = Rule::numeric()->different('some_field');
- $this->assertEquals('numeric|different:some_field', (string) $rule);
+ $this->assertEquals('numeric|different:"some_field"', (string) $rule);
}
public function testDigitsRule()
@@ -58,16 +59,16 @@ public function testDigitsBetweenRule()
$this->assertEquals('numeric|integer|digits_between:2,10', (string) $rule);
}
- public function testGreaterThanRule()
+ public function testGreaterThanRule(): void
{
$rule = Rule::numeric()->greaterThan('some_field');
- $this->assertEquals('numeric|gt:some_field', (string) $rule);
+ $this->assertEquals('numeric|gt:"some_field"', (string) $rule);
}
- public function testGreaterThanOrEqualRule()
+ public function testGreaterThanOrEqualRule(): void
{
$rule = Rule::numeric()->greaterThanOrEqualTo('some_field');
- $this->assertEquals('numeric|gte:some_field', (string) $rule);
+ $this->assertEquals('numeric|gte:"some_field"', (string) $rule);
}
public function testIntegerRule()
@@ -76,16 +77,16 @@ public function testIntegerRule()
$this->assertEquals('numeric|integer', (string) $rule);
}
- public function testLessThanRule()
+ public function testLessThanRule(): void
{
$rule = Rule::numeric()->lessThan('some_field');
- $this->assertEquals('numeric|lt:some_field', (string) $rule);
+ $this->assertEquals('numeric|lt:"some_field"', (string) $rule);
}
- public function testLessThanOrEqualRule()
+ public function testLessThanOrEqualRule(): void
{
$rule = Rule::numeric()->lessThanOrEqualTo('some_field');
- $this->assertEquals('numeric|lte:some_field', (string) $rule);
+ $this->assertEquals('numeric|lte:"some_field"', (string) $rule);
}
public function testMaxRule()
@@ -124,10 +125,10 @@ public function testMultipleOfRule()
$this->assertEquals('numeric|multiple_of:10', (string) $rule);
}
- public function testSameRule()
+ public function testSameRule(): void
{
$rule = Rule::numeric()->same('some_field');
- $this->assertEquals('numeric|same:some_field', (string) $rule);
+ $this->assertEquals('numeric|same:"some_field"', (string) $rule);
}
public function testSizeRule()
@@ -136,14 +137,14 @@ public function testSizeRule()
$this->assertEquals('numeric|integer|size:10', (string) $rule);
}
- public function testChainedRules()
+ public function testChainedRules(): void
{
$rule = Rule::numeric()
->integer()
->multipleOf(10)
->lessThanOrEqualTo('some_field')
->max(100);
- $this->assertEquals('numeric|integer|multiple_of:10|lte:some_field|max:100', (string) $rule);
+ $this->assertEquals('numeric|integer|multiple_of:10|lte:"some_field"|max:100', (string) $rule);
$rule = Rule::numeric()
->decimal(2)
@@ -153,7 +154,29 @@ public function testChainedRules()
->unless(true, function ($rule) {
$rule->different('some_field_2');
});
- $this->assertSame('numeric|decimal:2|same:some_field', (string) $rule);
+ $this->assertSame('numeric|decimal:2|same:"some_field"', (string) $rule);
+ }
+
+ #[TestWith(['different', 4, 5, 4])]
+ #[TestWith(['greaterThan', 6, 5, 7])]
+ #[TestWith(['greaterThanOrEqualTo', 5, 5, 6])]
+ #[TestWith(['lessThan', 4, 5, 3])]
+ #[TestWith(['lessThanOrEqualTo', 5, 5, 4])]
+ #[TestWith(['same', 5, 5, 6])]
+ public function testFieldReferencesPreserveLiteralSeparators(string $method, int $value, int $other, int $invalidOther): void
+ {
+ $field = 'other,value|"quoted"\\';
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['value' => $value, $field => $other],
+ ['value' => [Rule::numeric()->{$method}($field)]],
+ );
+
+ $this->assertTrue($validator->passes());
+
+ $validator->setData(['value' => $value, $field => $invalidOther, 'other' => $other]);
+
+ $this->assertTrue($validator->fails());
}
public function testNumericValidation()
diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php
index 49816d28f..5976186e7 100644
--- a/tests/Validation/ValidationRuleParserTest.php
+++ b/tests/Validation/ValidationRuleParserTest.php
@@ -10,6 +10,7 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\ValidationRuleParser;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
class ValidationRuleParserTest extends TestCase
{
@@ -410,7 +411,7 @@ public function testExplodeHandlesDateRuleWithAdditionalRules(): void
$this->assertEquals([
'date' => [
'date',
- 'after:today',
+ 'after:"today"',
],
], $results->rules);
}
@@ -555,6 +556,53 @@ public function testExplodeCanonicalizesStringableFluentRules(): void
], $results->rules['value']);
}
+ #[TestWith(['a,b'])]
+ #[TestWith(['a"b'])]
+ #[TestWith(['directory\\'])]
+ #[TestWith(['a\"b'])]
+ public function testLiteralRuleParametersRoundTripThroughCsv(string $value): void
+ {
+ foreach ([Rule::in([$value]), Rule::notIn([$value]), Rule::contains([$value]), Rule::doesntContain([$value])] as $rule) {
+ $this->assertSame([$value], ValidationRuleParser::parse((string) $rule)[1]);
+ }
+ }
+
+ public function testStringifiedEnumValuesRoundTripThroughCsv(): void
+ {
+ $this->assertSame(
+ ['In', [CsvRuleValue::Literal->value]],
+ ValidationRuleParser::parse((string) Rule::enum(CsvRuleValue::class)),
+ );
+ }
+
+ #[TestWith(['direct'])]
+ #[TestWith(['array'])]
+ #[TestWith(['wildcard'])]
+ public function testCompositeRulesPreserveLiteralPipesDuringExpansion(string $form): void
+ {
+ $rules = [
+ [Rule::date()->format('Y-m-d\|H:i:s'), ['date_format:"Y-m-d\|H:i:s"']],
+ [Rule::numeric()->same('other|value'), ['numeric', 'same:"other|value"']],
+ [Rule::string()->startsWith('INFO|'), ['string', 'starts_with:"INFO|"']],
+ ];
+
+ foreach ($rules as [$rule, $expected]) {
+ $parser = new ValidationRuleParser(['items' => [['value' => 'value']]]);
+ $attribute = $form === 'wildcard' ? 'items.*.value' : 'items.0.value';
+ $result = $parser->explode([$attribute => $form === 'direct' ? $rule : [$rule]]);
+
+ $this->assertSame($expected, $result->rules['items.0.value']);
+ }
+ }
+
+ public function testCsvParsingDoesNotAlterRegexParameters(): void
+ {
+ $pattern = '/^[a,b"\\\|]+$/';
+
+ $this->assertSame(['Regex', [$pattern]], ValidationRuleParser::parse('regex:' . $pattern));
+ $this->assertSame(['NotRegex', [$pattern]], ValidationRuleParser::parse('not_regex:' . $pattern));
+ }
+
public function testExplodePreservesCallbackBearingPresenceRules(): void
{
$exists = Rule::exists('users', 'email')->where(static fn ($query) => $query);
@@ -772,3 +820,8 @@ public static function dateFieldReferenceProvider(): array
];
}
}
+
+enum CsvRuleValue: string
+{
+ case Literal = 'a,"b"\\';
+}
diff --git a/tests/Validation/ValidationStringRuleTest.php b/tests/Validation/ValidationStringRuleTest.php
index 0fd7eb4a5..149140266 100644
--- a/tests/Validation/ValidationStringRuleTest.php
+++ b/tests/Validation/ValidationStringRuleTest.php
@@ -10,6 +10,7 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\StringRule;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
class ValidationStringRuleTest extends TestCase
{
@@ -94,37 +95,37 @@ public function testLowercaseRule(): void
public function testStartsWithRule(): void
{
$rule = Rule::string()->startsWith('foo');
- $this->assertSame('string|starts_with:foo', (string) $rule);
+ $this->assertSame('string|starts_with:"foo"', (string) $rule);
$rule = Rule::string()->startsWith('foo', 'bar');
- $this->assertSame('string|starts_with:foo,bar', (string) $rule);
+ $this->assertSame('string|starts_with:"foo","bar"', (string) $rule);
}
public function testEndsWithRule(): void
{
$rule = Rule::string()->endsWith('.com');
- $this->assertSame('string|ends_with:.com', (string) $rule);
+ $this->assertSame('string|ends_with:".com"', (string) $rule);
$rule = Rule::string()->endsWith('.com', '.org');
- $this->assertSame('string|ends_with:.com,.org', (string) $rule);
+ $this->assertSame('string|ends_with:".com",".org"', (string) $rule);
}
public function testDoesntStartWithRule(): void
{
$rule = Rule::string()->doesntStartWith('foo');
- $this->assertSame('string|doesnt_start_with:foo', (string) $rule);
+ $this->assertSame('string|doesnt_start_with:"foo"', (string) $rule);
$rule = Rule::string()->doesntStartWith('foo', 'bar');
- $this->assertSame('string|doesnt_start_with:foo,bar', (string) $rule);
+ $this->assertSame('string|doesnt_start_with:"foo","bar"', (string) $rule);
}
public function testDoesntEndWithRule(): void
{
$rule = Rule::string()->doesntEndWith('.exe');
- $this->assertSame('string|doesnt_end_with:.exe', (string) $rule);
+ $this->assertSame('string|doesnt_end_with:".exe"', (string) $rule);
$rule = Rule::string()->doesntEndWith('.exe', '.bat');
- $this->assertSame('string|doesnt_end_with:.exe,.bat', (string) $rule);
+ $this->assertSame('string|doesnt_end_with:".exe",".bat"', (string) $rule);
}
public function testChainedRules(): void
@@ -144,7 +145,28 @@ public function testChainedRules(): void
->unless(true, function ($rule) {
$rule->endsWith('suffix');
});
- $this->assertSame('string|between:1,100|starts_with:prefix', (string) $rule);
+ $this->assertSame('string|between:1,100|starts_with:"prefix"', (string) $rule);
+ }
+
+ #[TestWith(['startsWith', 'a,b', 'a,b rest', 'a rest'])]
+ #[TestWith(['endsWith', 'a,b', 'rest a,b', 'rest b'])]
+ #[TestWith(['doesntStartWith', 'a,b', 'a rest', 'a,b rest'])]
+ #[TestWith(['doesntEndWith', 'a,b', 'rest b', 'rest a,b'])]
+ #[TestWith(['startsWith', 'INFO|', 'INFO|record', 'INFOrecord'])]
+ #[TestWith(['startsWith', 'a\"b\\', 'a\"b\rest', 'a rest'])]
+ public function testLiteralPrefixesAndSuffixes(string $method, string $parameter, string $valid, string $invalid): void
+ {
+ $validator = new Validator(
+ new Translator(new ArrayLoader, 'en'),
+ ['field' => $valid],
+ ['field' => [Rule::string()->{$method}($parameter)]],
+ );
+
+ $this->assertTrue($validator->passes());
+
+ $validator->setData(['field' => $invalid]);
+
+ $this->assertTrue($validator->fails());
}
public function testStringValidation(): void
diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php
index 0d9c62427..9b8b6e251 100644
--- a/tests/Validation/ValidationUniqueRuleTest.php
+++ b/tests/Validation/ValidationUniqueRuleTest.php
@@ -30,7 +30,7 @@ protected function migrateFreshUsing(): array
];
}
- public function testItCorrectlyFormatsAStringVersionOfTheRule()
+ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void
{
$rule = new Unique('table');
$rule->where('foo', 'bar');
@@ -78,9 +78,9 @@ public function testItCorrectlyFormatsAStringVersionOfTheRule()
$rule = new Unique('table', 'column');
$rule->ignore('Taylor, Otwell"\'..-"', 'id_column');
$rule->where('foo', 'bar');
- $this->assertSame('unique:table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', (string) $rule);
- $this->assertSame('Taylor, Otwell"\'..-"', stripslashes(str_getcsv('table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', escape: '\\')[2]));
- $this->assertSame('id_column', stripslashes(str_getcsv('table,column,"Taylor, Otwell\"\\\'..-\"",id_column,foo,"bar"', escape: '\\')[3]));
+ $this->assertSame('unique:table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', (string) $rule);
+ $this->assertSame('Taylor, Otwell"\'..-"', str_getcsv('table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', escape: '')[2]);
+ $this->assertSame('id_column', str_getcsv('table,column,"Taylor, Otwell""\'..-""",id_column,foo,"bar"', escape: '')[3]);
$rule = new Unique('table', 'column');
$rule->ignore(null, 'id_column');