From f348b89db4a294098786d6d2c1007301d5d3f27e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:57:48 +0000 Subject: [PATCH 01/29] fix(redis): reconnect pooled clients after primary endpoint failover Invalidate synchronized READONLY and MASTERDOWN replies regardless of Sentinel configuration. Managed primary endpoints can move while an existing socket remains attached to a replica. Rebuild the native client on its next acquisition and propagate the original command failure without replay. Port current Laravel reconnection coverage for standalone and Cluster clients, including cluster response errors and the INCR/SET-with-options no-retry cases. Preserve the established Hypervel policy excluding automatic command replay and command_retries; record that difference at the source/test boundaries and in the Laravel porting guide. Laravel PRs: https://github.com/laravel/framework/pull/57685 https://github.com/laravel/framework/pull/61161 https://github.com/laravel/framework/pull/61175 https://github.com/laravel/framework/pull/61214 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: changed test files passed; Redis ParaTest passed (675 tests, 2497 assertions, two existing conditional skips); formatting, full source/types PHPStan and diff checks passed. Self-reviewed and signed off by claude-laravel-parity. --- src/docs/porting-from-laravel.md | 2 +- src/redis/src/RedisConnection.php | 6 +- tests/Redis/PhpRedisClusterConnectionTest.php | 50 ++++++++++ tests/Redis/RedisConnectionTest.php | 93 ++++++++++++++++--- 4 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 01a894f96..72c75d44f 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -575,7 +575,7 @@ Hypervel's `migrate:fresh` command discovers the connection declared by each mig Hypervel's Redis integration uses the PhpRedis extension exclusively. Its default `config/database.php` file does not contain a `client` option or `REDIS_CLIENT` environment variable. Remove those Laravel settings when porting configuration. A copied `client` option with any value other than `phpredis` is rejected; Predis is not supported. -Laravel's top-level `database.redis.clusters` configuration is also rejected. Each Hypervel Redis connection selects its standalone, Sentinel, or Cluster topology within the named connection, so begin with the matching Hypervel example instead of adapting Laravel's connection shape. Optional advanced members use their documented defaults when omitted. Hypervel does not support Laravel's `retry_interval` setting; configure retries with `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). +Laravel's top-level `database.redis.clusters` configuration is also rejected. Each Hypervel Redis connection selects its standalone, Sentinel, or Cluster topology within the named connection, so begin with the matching Hypervel example instead of adapting Laravel's connection shape. Optional advanced members use their documented defaults when omitted. Hypervel does not support Laravel's `retry_interval` or `command_retries` settings and does not replay failed commands; configure PhpRedis connection retries with `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. Configure Redis Cluster by adding a `cluster` array to a named Redis connection. See the [Redis configuration](/docs/{{version}}/redis#configuration) and [cluster documentation](/docs/{{version}}/redis#clusters). ### Cache diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 3600c039e..d8149ba05 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -396,6 +396,7 @@ public function __call($name, $arguments) $name = strtolower($name); $result = $this->executeCommand($name, $arguments); } catch (RedisException|RedisClusterException $exception) { + // REMOVED: Laravel's command retry loop can replay writes Redis already committed. if ($this->shouldInvalidateAfter($exception)) { $this->markInvalid(); } @@ -969,12 +970,9 @@ protected function shouldInvalidateAfter(RedisException|RedisClusterException $e return true; } - if (! ($this->config['sentinel']['enabled'] ?? false)) { - return false; - } - $errorCode = explode(' ', $exception->getMessage(), 2)[0]; + // Managed primary endpoints can fail over without Sentinel; reopen to resolve the current primary. return in_array($errorCode, ['READONLY', 'MASTERDOWN'], true); } diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 35223a3a1..3ddcf0f17 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -660,6 +660,56 @@ public function testDefaultNodeThrowsWhenNoMasters(): void $connection->scan($cursor, ['match' => '*']); } + public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand(): void + { + $exception = new RedisException('Connection lost'); + $failedClient = m::mock(RedisCluster::class); + $healthyClient = m::mock(RedisCluster::class); + $this->expectDefaultConnectionOptions($failedClient); + $this->expectDefaultConnectionOptions($healthyClient); + $failedClient->expects('get')->once()->with('foo')->andThrow($exception); + $failedClient->expects('getLastError')->andReturnNull(); + $healthyClient->expects('get')->once()->with('foo')->andReturn('bar'); + + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->clusterConfig(), [$failedClient, $healthyClient]) extends PhpRedisClusterConnection { + /** + * Create a connection with replacement native clients. + * + * @param RedisCluster[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + /** + * Return the next native client. + */ + protected function createRedisCluster(): RedisCluster + { + return array_shift($this->clients); + } + }; + + try { + $connection->__call('get', ['foo']); + $this->fail('Expected the command failure to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertFalse($connection->check()); + $this->assertSame($failedClient, $connection->client()); + $this->assertSame($connection, $connection->getActiveConnection()); + $this->assertSame($healthyClient, $connection->client()); + $this->assertTrue($connection->check()); + $this->assertSame('bar', $connection->__call('get', ['foo'])); + } + public function testReconnectClearsCachedDefaultNode(): void { $pool = m::mock(PoolInterface::class); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 25d088862..dc350a414 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -1461,10 +1461,80 @@ public function testClusterTransportFailureInvalidatesWithoutReplayingCommand(): $this->assertTrue($connection->isInvalidForTest()); } + // REMOVED: Automatic read/write retries and configured command retries can replay committed commands. + #[DataProvider('connectionFailureProvider')] + public function testConnectionRebuildsItsClientOnNextAcquisitionWithoutReplayingCommand( + RedisException|RedisClusterException $exception, + string $command, + array $arguments, + bool $synchronized, + ): void { + $failedClient = m::mock(Redis::class); + $healthyClient = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($failedClient); + $this->expectDefaultConnectionOptions($healthyClient); + $failedClient->expects($command)->once()->with(...$arguments)->andThrow($exception); + $failedClient->expects('getLastError')->andReturn($synchronized ? $exception->getMessage() : null); + $failedClient->shouldNotReceive('isConnected'); + $healthyClient->expects('get')->once()->with('foo')->andReturn('bar'); + + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), [$failedClient, $healthyClient]) extends PhpRedisConnection { + /** + * Create a connection with replacement native clients. + * + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + /** + * Return the next native client. + */ + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + }; + + try { + $connection->__call($command, $arguments); + $this->fail('Expected the command failure to propagate.'); + } catch (RedisException|RedisClusterException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertFalse($connection->check()); + $this->assertSame($failedClient, $connection->client()); + $this->assertSame($connection, $connection->getActiveConnection()); + $this->assertSame($healthyClient, $connection->client()); + $this->assertTrue($connection->check()); + $this->assertSame('bar', $connection->__call('get', ['foo'])); + } + + /** + * Provide failover and transport errors across command types. + */ + public static function connectionFailureProvider(): array + { + return [ + 'read-only replica' => [new RedisException('READONLY replica is read-only'), 'set', ['foo', 'bar'], true], + 'disconnected replica' => [new RedisException('MASTERDOWN link is down'), 'get', ['foo'], true], + 'read failure' => [new RedisException('Connection lost'), 'get', ['foo'], false], + 'non-idempotent write' => [new RedisException('Connection lost'), 'incr', ['foo'], false], + 'write with options' => [new RedisException('Connection lost'), 'set', ['foo', 'bar', ['ex' => 60]], false], + 'cluster response error' => [new RedisClusterException('Error processing response from Redis node!'), 'get', ['foo'], false], + ]; + } + #[DataProvider('synchronizedServerErrorDispositionProvider')] public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( string $message, - bool $sentinel, bool $invalid, ): void { $exception = new RedisException($message); @@ -1474,7 +1544,6 @@ public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( $connection = new PhpRedisConnectionStub( $this->getContainer(), $this->getMockedPool(), - ['sentinel' => ['enabled' => $sentinel]], ); $connection->setActiveConnection($redis); @@ -1488,19 +1557,19 @@ public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( $this->assertSame($invalid, $connection->isInvalidForTest()); } + /** + * Provide server errors that invalidate or retain a synchronized connection. + */ public static function synchronizedServerErrorDispositionProvider(): array { return [ - 'standalone READONLY' => ['READONLY replica is read-only', false, false], - 'standalone MASTERDOWN' => ['MASTERDOWN link is down', false, false], - 'standalone LOADING' => ['LOADING data is loading', false, false], - 'standalone OOM' => ['OOM command not allowed', false, false], - 'standalone MISCONF' => ['MISCONF persistence error', false, false], - 'standalone CROSSSLOT' => ["CROSSSLOT Keys in request don't hash to the same slot", false, false], - 'Sentinel READONLY' => ['READONLY replica is read-only', true, true], - 'Sentinel MASTERDOWN' => ['MASTERDOWN link is down', true, true], - 'Sentinel LOADING' => ['LOADING data is loading', true, false], - 'Sentinel non-exact READONLY prefix' => ['READONLY_STATE custom error', true, false], + 'READONLY' => ['READONLY replica is read-only', true], + 'MASTERDOWN' => ['MASTERDOWN link is down', true], + 'LOADING' => ['LOADING data is loading', false], + 'OOM' => ['OOM command not allowed', false], + 'MISCONF' => ['MISCONF persistence error', false], + 'CROSSSLOT' => ["CROSSSLOT Keys in request don't hash to the same slot", false], + 'non-exact READONLY prefix' => ['READONLY_STATE custom error', false], ]; } From d95eb550abb4cb83d887f0acd38f182b68389a5b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:25:43 +0000 Subject: [PATCH 02/29] fix(redis): distinguish failed callbacks from abandoned transactions Port the real Redis callback-failure regression tests from Laravel #61183. Both transaction and pipeline cases retain all 13 applicable connection variants and assert that the original exception propagates, queued writes stay unapplied, and the next command succeeds. Use the existing pooled connection cleanup instead of adding a native DISCARD recovery path. Translate persistent transport to pool ownership, retain compression client names at the top-level configuration boundary, and use the isolated test database and configured credentials. Also fix misleading CRITICAL abandonment warnings during cleanup of already-invalidated operations (AI-035). Keep connection disposal unconditional and preserve diagnostics for genuinely abandoned MULTI, PIPELINE, and WATCH state. The new unit regression fails before the correction. Upstream: https://github.com/laravel/framework/pull/61183 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Historical merge: 62a35df888f5fc12efa5784bf3f4c48ea2f1fdaa Validation: Redis units 676 tests / 2501 assertions (2 existing conditional skips); complete proxy integration class 41 tests / 422 assertions against isolated Redis; changed unit class passed separately; formatting and full source/type PHPStan passed. Self-reviewed and signed off by claude-laravel-parity. --- src/redis/src/RedisConnection.php | 27 ++-- .../Redis/RedisProxyIntegrationTest.php | 143 ++++++++++++++++++ tests/Redis/RedisConnectionTest.php | 18 +++ 3 files changed, 176 insertions(+), 12 deletions(-) diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index d8149ba05..528771e96 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -795,18 +795,21 @@ public function release(): void } if ($queueing || $this->watching) { - try { - $this->log( - $queueing - ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' - : 'Discarding Redis connection left in WATCH state.', - LogLevel::CRITICAL - ); - } catch (CanceledException $cancellation) { - // Native close must not start while cancellation is unwinding. - $this->releaseAfterCancellation($cancellation); - } catch (Throwable) { - // Reporting must not prevent terminal ownership cleanup. + // An invalidated operation is undergoing failure cleanup, not silently abandoning its state. + if (! $this->invalid) { + try { + $this->log( + $queueing + ? 'Discarding Redis connection left in MULTI or PIPELINE mode.' + : 'Discarding Redis connection left in WATCH state.', + LogLevel::CRITICAL + ); + } catch (CanceledException $cancellation) { + // Native close must not start while cancellation is unwinding. + $this->releaseAfterCancellation($cancellation); + } catch (Throwable) { + // Reporting must not prevent terminal ownership cleanup. + } } $this->resetReleaseState(false); diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index d0a22cb87..622ee90b4 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Redis; +use Exception; use Hypervel\Context\CoroutineContext; use Hypervel\Coroutine\Coroutine; use Hypervel\Engine\Channel; @@ -1095,6 +1096,148 @@ public function testConcurrentTransactionCallbacksWithLimitedConnectionPool(): v } } + public function testItKeepsTheConnectionUsableWhenATransactionFails(): void + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'taylor'); + $exception = new Exception('Something went wrong.'); + + try { + $redis->transaction(function (PhpRedis $transaction) use ($exception): never { + $transaction->set('name', 'mohamed'); + + throw $exception; + }); + $this->fail('Expected the transaction callback exception to propagate.'); + } catch (Exception $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame('taylor', $redis->get('name')); + } + } + + public function testItKeepsTheConnectionUsableWhenAPipelineFails(): void + { + foreach ($this->connections() as $redis) { + $redis->set('name', 'taylor'); + $exception = new Exception('Something went wrong.'); + + try { + $redis->pipeline(function (PhpRedis $pipeline) use ($exception): never { + $pipeline->set('name', 'mohamed'); + + throw $exception; + }); + $this->fail('Expected the pipeline callback exception to propagate.'); + } catch (Exception $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame('taylor', $redis->get('name')); + } + } + + /** + * Get the configured PhpRedis connection variants. + * + * @return array + */ + public function connections(): array + { + $connections = ['phpredis' => Redis::connection()]; + $default = config('database.redis.default'); + $configurations = [ + 'url' => [ + 'url' => "redis://{$default['host']}:{$default['port']}", + 'host' => 'overwrittenByUrl', + 'port' => 'overwrittenByUrl', + 'options' => ['prefix' => 'hypervel:'], + ], + // The connection pool owns persistence instead of native pconnect(). + 'pooled' => [ + 'options' => ['prefix' => 'hypervel:'], + ], + 'serializer_json' => [ + 'options' => ['serializer' => PhpRedis::SERIALIZER_JSON], + ], + 'scan_retry' => [ + 'options' => ['scan' => PhpRedis::SCAN_RETRY], + ], + ]; + + if (defined('Redis::COMPRESSION_LZF')) { + $configurations['compression_lzf'] = [ + 'name' => 'compression_lzf', + 'options' => ['compression' => PhpRedis::COMPRESSION_LZF], + ]; + } + + if (defined('Redis::COMPRESSION_ZSTD')) { + $configurations['compression_zstd'] = [ + 'name' => 'compression_zstd', + 'options' => ['compression' => PhpRedis::COMPRESSION_ZSTD], + ]; + $configurations['compression_zstd_default'] = [ + 'name' => 'compression_zstd_default', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_ZSTD, + 'compression_level' => PhpRedis::COMPRESSION_ZSTD_DEFAULT, + ], + ]; + $configurations['compression_zstd_max'] = [ + 'name' => 'compression_zstd_max', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_ZSTD, + 'compression_level' => PhpRedis::COMPRESSION_ZSTD_MAX, + ], + ]; + } + + if (defined('Redis::COMPRESSION_LZ4')) { + $configurations['compression_lz4'] = [ + 'name' => 'compression_lz4', + 'options' => ['compression' => PhpRedis::COMPRESSION_LZ4], + ]; + $configurations['compression_lz4_default'] = [ + 'name' => 'compression_lz4_default', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 0, + ], + ]; + $configurations['compression_lz4_min'] = [ + 'name' => 'compression_lz4_min', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 1, + ], + ]; + $configurations['compression_lz4_max'] = [ + 'name' => 'compression_lz4_max', + 'options' => [ + 'compression' => PhpRedis::COMPRESSION_LZ4, + 'compression_level' => 12, + ], + ]; + } + + foreach ($configurations as $name => $configuration) { + $connectionName = $this->createRedisConnectionWithOptions('test_' . $name, $configuration['options']); + config([ + "database.redis.{$connectionName}" => array_replace( + config("database.redis.{$connectionName}"), + $configuration, + ['timeout' => 0.5], + ), + ]); + + $connections[$name] = Redis::connection($connectionName); + } + + return $connections; + } + /** * Get the exact native client held by a Redis proxy. */ diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index dc350a414..69ee7a961 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -183,6 +183,24 @@ public function testReleaseDiscardsAConnectionInMultiMode(): void $connection->release(); } + public function testReleaseDiscardsAnInvalidatedTransactionWithoutReportingAbandonment(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldNotReceive('log'); + $container = $this->getContainer(); + $container->instance(StdoutLoggerInterface::class, $logger); + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $pool->shouldNotReceive('release'); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::MULTI); + $connection = $this->mockRedisConnection(container: $container, pool: $pool); + $connection->setActiveConnection($redis); + $connection->invalidate(); + + $connection->release(); + } + public function testReleaseDiscardsAConnectionInPipelineMode(): void { $pool = $this->getMockedPool(); From 40ecbf24b1b1bd7b6b1a10599a98d8147eb6b775 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:56:05 +0000 Subject: [PATCH 03/29] fix(http): complete JSON:API request and generator updates Ignore array-valued JSON:API fieldsets and include parameters instead of raising server errors, while retaining explicit empty-fieldset semantics. Clamp the remaining include depth at zero so maxRelationshipDepth(0) does not select trailing relationship segments through Arr::take's negative limit. Remove the unused Request import from generated JSON:API resources and apply native array types to their attribute and relationship properties. Keep the documented generated class and property examples consistent. Port all four upstream regression tests with Hypervel's native typing and existing test lifecycle. Request parsing matches Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2; the encountered generator follow-up uses 2009b316784f29f14e053a47e349c7f51aa1a901. The original #57646 relationship port is already covered. The separately deferred #61322 remains untouched. Upstream: https://github.com/laravel/framework/pull/61206 https://github.com/laravel/framework/pull/61297 https://github.com/laravel/framework/pull/61434 Validation: all four regressions failed before their fixes and pass after; focused JSON:API and generator ParaTest suites pass (148 tests, 824 assertions); composer lint:fix, composer analyse and git diff --check pass. Self-reviewed and independently signed off by claude-laravel-parity. --- src/docs/eloquent-resources.md | 13 +++---- .../src/Console/stubs/resource-json-api.stub | 5 ++- .../src/Resources/JsonApi/JsonApiRequest.php | 6 ++-- .../Generators/ResourceMakeCommandTest.php | 22 ++++++++++-- .../Resources/JsonApi/JsonApiRequestTest.php | 36 +++++++++++++++++++ 5 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index 3fb9f6dc8..d6ee8f439 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -943,9 +943,10 @@ The generated class will extend `Hypervel\Http\Resources\JsonApi\JsonApiResource ```php UserResource::class, 'comments', ]; diff --git a/src/foundation/src/Console/stubs/resource-json-api.stub b/src/foundation/src/Console/stubs/resource-json-api.stub index a6cd0eb07..b163dd065 100644 --- a/src/foundation/src/Console/stubs/resource-json-api.stub +++ b/src/foundation/src/Console/stubs/resource-json-api.stub @@ -4,7 +4,6 @@ declare(strict_types=1); namespace {{ namespace }}; -use Hypervel\Http\Request; use Hypervel\Http\Resources\JsonApi\JsonApiResource; class {{ class }} extends JsonApiResource @@ -12,14 +11,14 @@ class {{ class }} extends JsonApiResource /** * The resource's attributes. */ - public $attributes = [ + public array $attributes = [ // ... ]; /** * The resource's relationships. */ - public $relationships = [ + public array $relationships = [ // ... ]; } diff --git a/src/http/src/Resources/JsonApi/JsonApiRequest.php b/src/http/src/Resources/JsonApi/JsonApiRequest.php index b48457721..e9a4a8310 100644 --- a/src/http/src/Resources/JsonApi/JsonApiRequest.php +++ b/src/http/src/Resources/JsonApi/JsonApiRequest.php @@ -27,7 +27,7 @@ public function sparseFields(string $key): array { if (is_null($this->cachedSparseFields)) { $this->cachedSparseFields = (new Collection($this->array('fields'))) - ->transform(fn ($fieldsets) => empty($fieldsets) ? [] : explode(',', $fieldsets)) + ->transform(fn ($fieldsets) => ! is_string($fieldsets) || empty($fieldsets) ? [] : explode(',', $fieldsets)) ->all(); } @@ -52,7 +52,7 @@ public function hasSparseFieldset(string $key): bool public function sparseIncluded(?string $key = null): ?array { if (is_null($this->cachedSparseIncluded)) { - $included = (string) $this->string('include', ''); + $included = is_string($included = $this->input('include')) ? $included : ''; $this->cachedSparseIncluded = (new Collection(empty($included) ? [] : explode(',', $included))) ->transform(function ($item) { @@ -80,7 +80,7 @@ public function sparseIncluded(?string $key = null): ?array return null; } - $item = implode('.', Arr::take(explode('.', $item), JsonApiResource::$maxRelationshipDepth - 1)); + $item = implode('.', Arr::take(explode('.', $item), max(0, JsonApiResource::$maxRelationshipDepth - 1))); return ! empty($item) ? $item : null; })->filter()->all(); diff --git a/tests/Integration/Generators/ResourceMakeCommandTest.php b/tests/Integration/Generators/ResourceMakeCommandTest.php index 2b5d3db09..2812f3415 100644 --- a/tests/Integration/Generators/ResourceMakeCommandTest.php +++ b/tests/Integration/Generators/ResourceMakeCommandTest.php @@ -6,12 +6,12 @@ class ResourceMakeCommandTest extends TestCase { - protected $files = [ + protected array $files = [ 'app/Http/Resources/FooResource.php', 'app/Http/Resources/FooResourceCollection.php', ]; - public function testItCanGenerateResourceFile() + public function testItCanGenerateResourceFile(): void { $this->artisan('make:resource', ['name' => 'FooResource']) ->assertExitCode(0); @@ -23,7 +23,7 @@ public function testItCanGenerateResourceFile() ], 'app/Http/Resources/FooResource.php'); } - public function testItCanGenerateResourceCollectionFile() + public function testItCanGenerateResourceCollectionFile(): void { $this->artisan('make:resource', ['name' => 'FooResourceCollection', '--collection' => true]) ->assertExitCode(0); @@ -34,4 +34,20 @@ public function testItCanGenerateResourceCollectionFile() 'class FooResourceCollection extends ResourceCollection', ], 'app/Http/Resources/FooResourceCollection.php'); } + + public function testItCanGenerateJsonApiResourceFile(): void + { + $this->artisan('make:resource', ['name' => 'FooResource', '--json-api' => true]) + ->assertExitCode(0); + + $this->assertFileContains([ + 'namespace App\Http\Resources;', + 'use Hypervel\Http\Resources\JsonApi\JsonApiResource;', + 'class FooResource extends JsonApiResource', + ], 'app/Http/Resources/FooResource.php'); + + $this->assertFileNotContains([ + 'use Hypervel\Http\Request;', + ], 'app/Http/Resources/FooResource.php'); + } } diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php index ab06d4709..d6ce23075 100644 --- a/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php +++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRequestTest.php @@ -44,6 +44,18 @@ public function testItCanDetermineIfSparseFieldsetWasProvided(): void $this->assertFalse($request->hasSparseFieldset('posts')); } + public function testItIgnoresNonStringSparseFields(): void + { + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'fields' => [ + 'users' => ['name', 'email'], + ], + ])); + + $this->assertSame([], $request->sparseFields('users')); + $this->assertTrue($request->hasSparseFieldset('users')); + } + public function testItCanResolveSparseIncluded(): void { $request = JsonApiRequest::create(uri: '/?' . http_build_query([ @@ -70,10 +82,34 @@ public function testItCanResolveSparseIncludedWithMaxRelationshipNesting(): void $this->assertSame(['user'], $request->sparseIncluded('profile')); } + public function testItDropsNestedSparseIncludedWithZeroMaxRelationshipNesting(): void + { + JsonApiResource::maxRelationshipDepth(0); + + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'include' => 'teams,posts.author,profile.user.profile', + ])); + + $this->assertSame(['teams', 'posts', 'profile'], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + $this->assertSame([], $request->sparseIncluded('posts')); + $this->assertSame([], $request->sparseIncluded('profile')); + } + public function testItCanResolveEmptySparseIncluded(): void { $request = JsonApiRequest::create(uri: '/'); $this->assertSame([], $request->sparseIncluded()); } + + public function testItIgnoresNonStringSparseIncluded(): void + { + $request = JsonApiRequest::create(uri: '/?' . http_build_query([ + 'include' => ['teams', 'posts'], + ])); + + $this->assertSame([], $request->sparseIncluded()); + $this->assertSame([], $request->sparseIncluded('teams')); + } } From 6a8e4733ac17c4e465b23054757df6a96d907494 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:14:52 +0000 Subject: [PATCH 04/29] fix(http): release pending requests without cyclic collection Port Laravel framework PR #61438 from 13.x at cdbd17f7e3257e8ae4207d3c3bef6058452d5f72: https://github.com/laravel/framework/pull/61438 Make the constructor's stored request callback static so it does not retain the PendingRequest through an implicit receiver binding. Correct the same retention in default throw, callable throwUnless, dump and dd callbacks; their bodies use explicit arguments or captures, not $this. Preserve user-supplied callables, protected access and fluent APIs, and apply native closure return types without adding cleanup machinery. Add one deterministic regression based on the upstream PR's reproduction. With cyclic GC disabled, a completed stubbed request using throwUnless must be freed immediately after its owning references are unset. Restore GC in finally. The test failed before the source correction and passes afterward; existing callback, event and coroutine behavior stays covered. Validation: HttpClientTest 368 tests / 842 assertions; focused client and connection ParaTest 413 tests / 957 assertions; composer lint:fix, composer analyse and git diff --check pass. Independently reviewed and signed off by claude-laravel-parity. --- src/http/src/Client/PendingRequest.php | 11 ++++++----- tests/Http/HttpClientTest.php | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 231deb20b..e9efc7d6a 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -245,8 +245,9 @@ public function __construct( 'timeout' => 30, ], $options); + // A bound callback would keep the request alive until cyclic garbage collection runs. $this->beforeSendingCallbacks = new Collection([ - function (Request $request, array $options, PendingRequest $pendingRequest) { + static function (Request $request, array $options, PendingRequest $pendingRequest): void { $pendingRequest->request = $request; $pendingRequest->cookies = $options['cookies']; @@ -700,7 +701,7 @@ public function afterResponse(callable $callback): static */ public function throw(?callable $callback = null): static { - $this->throwCallback = $callback === null ? fn () => null : $callback(...); + $this->throwCallback = $callback === null ? static fn (): null => null : $callback(...); return $this; } @@ -727,7 +728,7 @@ public function throwIf(bool|callable $condition, ?callable $callback = null): s public function throwUnless(bool|callable $condition, ?callable $callback = null): static { if (is_callable($condition)) { - return $this->throwIf(fn (Response $response) => ! $condition($response), $callback); + return $this->throwIf(static fn (Response $response): bool => ! $condition($response), $callback); } return $this->throwIf(! $condition, $callback); @@ -740,7 +741,7 @@ public function dump(): static { $values = func_get_args(); - return $this->beforeSending(function (Request $request, array $options) use ($values) { + return $this->beforeSending(static function (Request $request, array $options) use ($values): void { foreach (array_merge($values, [$request, $options]) as $value) { VarDumper::dump($value); } @@ -754,7 +755,7 @@ public function dd(): static { $values = func_get_args(); - return $this->beforeSending(function (Request $request, array $options) use ($values) { + return $this->beforeSending(static function (Request $request, array $options) use ($values): never { foreach (array_merge($values, [$request, $options]) as $value) { VarDumper::dump($value); } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index e90c09df7..a86790c8b 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -67,6 +67,7 @@ use Swoole\Coroutine\CanceledException; use Symfony\Component\VarDumper\VarDumper; use Throwable; +use WeakReference; use function Hypervel\Coroutine\parallel; use function Hypervel\Coroutine\run; @@ -4956,6 +4957,31 @@ public function testTooManyRedirectsWithFakedRedirectChain(): void $this->factory->maxRedirects(1)->get('https://1.example.com'); } + public function testPendingRequestsAreFreedOnceUnset(): void + { + $garbageCollectionEnabled = gc_enabled(); + gc_disable(); + + try { + $request = (new PendingRequest) + ->throwUnless(static fn (Response $response): bool => false) + ->stub(static fn (): PromiseInterface => Factory::response('ok')); + + $reference = WeakReference::create($request); + $response = $request->post('http://localhost/memory-test'); + + $this->assertSame('ok', $response->body()); + + unset($request, $response); + + $this->assertNull($reference->get()); + } finally { + if ($garbageCollectionEnabled) { + gc_enable(); + } + } + } + public function testRequestExceptionIsNotThrownIfThePendingRequestIsSetToThrowOnFailureButTheResponseIsSuccessful(): void { $this->factory->fake([ From 2460f4e38c52607292f4e947a9a42236b94ce586 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:30:03 +0000 Subject: [PATCH 05/29] fix(foundation): memoize event cache state and order core aliases Complete Laravel framework PRs: https://github.com/laravel/framework/pull/57706 https://github.com/laravel/framework/pull/57709 Original merges: a6e9bb5287d3ec7b1c9a7d7e172460e6a0c1ec0b and f0841232d3bd708b5282eefd468887549c7ff27e. Port from current 13.x source at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Honor the events.cached container override and retain the first filesystem result on the Application, matching config and route cache inspection. Keep Hypervel's direct is_file path and application-owned lifetime; this provides consistent boot state without adding invalidation machinery or changing event cache publication and clearing commands. Sort core service keys while preserving every alias mapping and inner array order. Keep the Response class-key at its semantic service position. Runtime aliases and abstractAliases are identical before and after sorting. Port both upstream event-cache tests using real isolated files, retaining all applicable assertions and checking false/true overrides, memoization, fresh applications and file deletion. Reuse existing cleanup for the two file-presence tests. Explicitly select cached state in two provider tests whose fixture files are created after the application has booted. Preserve the six upstream alias assertions with imported contracts and native types. Validation: both new regressions failed before the source fix. Focused application/event/container ParaTest: 454 tests, 1065 assertions, 10 existing PHP 8.5-only skips on PHP 8.4. Changed test files pass individually; formatting, full source/type PHPStan and diff checks pass. Independently reviewed and signed off by claude-laravel-parity. --- src/foundation/src/Application.php | 42 ++++++----- .../Foundation/FoundationApplicationTest.php | 72 +++++++++++++------ .../Providers/EventServiceProviderTest.php | 8 ++- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 18a8119bf..1c41f184d 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -707,7 +707,11 @@ public function getCachedRoutesPath(): string */ public function eventsAreCached(): bool { - return is_file($this->getCachedEventsPath()); + if ($this->bound('events.cached')) { + return (bool) $this->make('events.cached'); + } + + return $this->instance('events.cached', is_file($this->getCachedEventsPath())); } /** @@ -1357,6 +1361,7 @@ protected function registerCoreContainerAliases(): void \Hypervel\Auth\Passwords\PasswordBroker::class, \Hypervel\Contracts\Auth\PasswordBroker::class, ], + 'blade.compiler' => [\Hypervel\View\Compilers\BladeCompiler::class], 'cache' => [ \Hypervel\Cache\CacheManager::class, \Hypervel\Contracts\Cache\Factory::class, @@ -1366,6 +1371,7 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Cache\Repository::class, \Psr\SimpleCache\CacheInterface::class, ], + 'composer' => [\Hypervel\Support\Composer::class], 'config' => [ \Hypervel\Config\Repository::class, \Hypervel\Contracts\Config\Repository::class, @@ -1375,7 +1381,6 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Cookie\Factory::class, \Hypervel\Contracts\Cookie\QueueingFactory::class, ], - 'composer' => [\Hypervel\Support\Composer::class], 'db' => [ \Hypervel\Database\DatabaseManager::class, \Hypervel\Database\ConnectionResolverInterface::class, @@ -1437,6 +1442,9 @@ protected function registerCoreContainerAliases(): void 'queue.failer' => [\Hypervel\Queue\Failed\FailedJobProviderInterface::class], 'queue.listener' => [\Hypervel\Queue\Listener::class], 'queue.worker' => [\Hypervel\Queue\Worker::class], + 'redirect' => [ + \Hypervel\Routing\Redirector::class, + ], 'redis' => [ \Hypervel\Redis\RedisManager::class, \Hypervel\Contracts\Redis\Factory::class, @@ -1456,38 +1464,34 @@ protected function registerCoreContainerAliases(): void \Hypervel\Contracts\Routing\Registrar::class, \Hypervel\Contracts\Routing\BindingRegistrar::class, ], - 'redirect' => [ - \Hypervel\Routing\Redirector::class, + 'session' => [\Hypervel\Session\SessionManager::class], + 'session.store' => [ + \Hypervel\Session\Store::class, + \Hypervel\Contracts\Session\Session::class, + ], + 'translation.loader' => [ + \Hypervel\Translation\FileLoader::class, + \Hypervel\Contracts\Translation\Loader::class, + ], + 'translator' => [ + \Hypervel\Translation\Translator::class, + \Hypervel\Contracts\Translation\Translator::class, ], 'url' => [ \Hypervel\Routing\UrlGenerator::class, \Hypervel\Contracts\Routing\UrlGenerator::class, ], + 'validation.presence' => [\Hypervel\Validation\DatabasePresenceVerifierInterface::class], 'validator' => [ \Hypervel\Validation\Factory::class, \Hypervel\Contracts\Validation\Factory::class, ], - 'validation.presence' => [\Hypervel\Validation\DatabasePresenceVerifierInterface::class], 'view' => [ \Hypervel\View\Factory::class, \Hypervel\Contracts\View\Factory::class, ], 'view.engine.resolver' => [\Hypervel\View\Engines\EngineResolver::class], 'view.finder' => [\Hypervel\View\ViewFinderInterface::class], - 'blade.compiler' => [\Hypervel\View\Compilers\BladeCompiler::class], - 'session' => [\Hypervel\Session\SessionManager::class], - 'session.store' => [ - \Hypervel\Session\Store::class, - \Hypervel\Contracts\Session\Session::class, - ], - 'translator' => [ - \Hypervel\Translation\Translator::class, - \Hypervel\Contracts\Translation\Translator::class, - ], - 'translation.loader' => [ - \Hypervel\Translation\FileLoader::class, - \Hypervel\Contracts\Translation\Loader::class, - ], ] as $key => $aliases) { foreach ($aliases as $alias) { $this->alias($key, $alias); diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index a21293d58..09558d166 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Foundation\FoundationApplicationTest; use Hypervel\Config\Repository; +use Hypervel\Contracts\Auth\PasswordBroker; +use Hypervel\Contracts\Auth\PasswordBrokerFactory; use Hypervel\Contracts\Events\Dispatcher as DispatcherContract; use Hypervel\Contracts\Translation\Translator as TranslatorContract; use Hypervel\Events\Dispatcher as EventDispatcher; @@ -953,42 +955,68 @@ public function testRoutesAreCachedMemoizesFilesystemResult(): void $this->assertTrue($freshApp->routesAreCached()); } - public function testEventsAreCachedReturnsFalseWhenNoCacheFile() + public function testEventsAreCachedReturnsFalseWhenNoCacheFile(): void { - $app = new Application(sys_get_temp_dir() . '/hypervel-test-app-' . uniqid()); + $app = $this->makeCacheApplication(); $this->assertFalse($app->eventsAreCached()); } - public function testEventsAreCachedReturnsTrueWhenCacheFileExists() + public function testEventsAreCachedReturnsTrueWhenCacheFileExists(): void { - $basePath = sys_get_temp_dir() . '/hypervel-test-app-' . uniqid(); - $cachePath = $basePath . '/bootstrap/cache/events.php'; + $app = $this->makeCacheApplication(); + file_put_contents($app->getCachedEventsPath(), 'assertTrue($app->eventsAreCached()); + } + + public function testEventsAreCachedUsesContainerInstance(): void + { + $app = $this->makeCacheApplication(); + $app->instance('events.cached', true); + + $this->assertTrue($app->eventsAreCached()); + $this->assertFileDoesNotExist($app->getCachedEventsPath()); + + file_put_contents($app->getCachedEventsPath(), 'instance('events.cached', false); + + $this->assertFalse($app->eventsAreCached()); + } + + public function testEventsAreCachedChecksFilesystemIfNotSet(): void + { + $app = $this->makeCacheApplication(); + $cachePath = $app->getCachedEventsPath(); + + $this->assertFalse($app->eventsAreCached()); + $this->assertStringContainsString('events.php', $cachePath); + $this->assertTrue($app->bound('events.cached')); + $this->assertFalse($app->make('events.cached')); - mkdir(dirname($cachePath), 0755, true); file_put_contents($cachePath, 'assertTrue($app->eventsAreCached()); - } finally { - unlink($cachePath); - rmdir(dirname($cachePath)); - rmdir(dirname($cachePath, 2)); - rmdir($basePath); - } + $this->assertFalse($app->eventsAreCached()); + + $freshApp = new Application($this->cacheApplicationPath); + + $this->assertTrue($freshApp->eventsAreCached()); + + unlink($cachePath); + + $this->assertTrue($freshApp->eventsAreCached()); } - public function testCoreContainerAliasesAreRegisteredByDefault() + public function testCoreContainerAliasesAreRegisteredByDefault(): void { $app = new Application; - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Translation\Translator::class)); - $this->assertSame('translator', $app->getAlias(\Hypervel\Contracts\Translation\Translator::class)); - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Auth\PasswordBrokerFactory::class)); - $this->assertSame('auth.password', $app->getAlias(\Hypervel\Contracts\Auth\PasswordBrokerFactory::class)); - $this->assertTrue($app->isAlias(\Hypervel\Contracts\Auth\PasswordBroker::class)); - $this->assertSame('auth.password.broker', $app->getAlias(\Hypervel\Contracts\Auth\PasswordBroker::class)); + $this->assertTrue($app->isAlias(TranslatorContract::class)); + $this->assertSame('translator', $app->getAlias(TranslatorContract::class)); + $this->assertTrue($app->isAlias(PasswordBrokerFactory::class)); + $this->assertSame('auth.password', $app->getAlias(PasswordBrokerFactory::class)); + $this->assertTrue($app->isAlias(PasswordBroker::class)); + $this->assertSame('auth.password.broker', $app->getAlias(PasswordBroker::class)); } public function testAddAbsoluteCachePathPrefixReturnsSelf() diff --git a/tests/Foundation/Support/Providers/EventServiceProviderTest.php b/tests/Foundation/Support/Providers/EventServiceProviderTest.php index ae5a79c78..d4406db8f 100644 --- a/tests/Foundation/Support/Providers/EventServiceProviderTest.php +++ b/tests/Foundation/Support/Providers/EventServiceProviderTest.php @@ -46,7 +46,7 @@ class_alias(ListenerInterface::class, 'Tests\Integration\Foundation\Fixtures\Eve $this->assertContains('App\Listeners\CustomListener', $events['App\Events\CustomEvent']); } - public function testGetEventsReadsFromCacheWhenCached() + public function testGetEventsReadsFromCacheWhenCached(): void { $cachePath = $this->app->getCachedEventsPath(); $cacheDir = dirname($cachePath); @@ -64,6 +64,8 @@ public function testGetEventsReadsFromCacheWhenCached() file_put_contents($cachePath, 'app->instance('events.cached', true); + $provider = new EventServiceProvider($this->app); $events = $provider->getEvents(); @@ -76,7 +78,7 @@ public function testGetEventsReadsFromCacheWhenCached() } } - public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache() + public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache(): void { $cachePath = $this->app->getCachedEventsPath(); $cacheDir = dirname($cachePath); @@ -88,6 +90,8 @@ public function testGetEventsReturnsEmptyWhenCachedButProviderNotInCache() file_put_contents($cachePath, 'app->instance('events.cached', true); + $provider = new EventServiceProvider($this->app); $events = $provider->getEvents(); From 4a1bf9b0dc523423e87991e3fdf55735f94ac194 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:48:44 +0000 Subject: [PATCH 06/29] test(cache): complete Redis lock compression coverage Complete the current Laravel lock-test surface for framework PR #57710. Restore Zstd maximum compression, Lz4 levels 3 and 12, and the assertions that force release and release remove the lock key. Preserve all Hypervel refresh and reacquisition coverage. Use independent PHPUnit datasets so each compression level gets a fresh application and pool. Reuse the existing topology-aware connection fixture helper. Keep native phpredis capability guards: separate PECL compression extensions are not required. Whole Cache and Queue workflow coverage was already incorporated in 494aeff6a. Upstream: https://github.com/laravel/framework/pull/57710 Merge: ae006118ae027c86e3b34a47287114ceebb5eb03 Current 13.x source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Validation: edited file 12 tests / 99 assertions on standalone Redis and three-node Cluster, with one unavailable-msgpack skip; focused standalone ParaTest 57 / 216; existing Cluster lock tests 14 / 40. Formatting and diff checks pass. Independently reviewed and signed off by claude-laravel-parity. --- .../Cache/Redis/PhpRedisCacheLockTest.php | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php index 056d15b56..cabc4a02a 100644 --- a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php @@ -7,20 +7,13 @@ use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; use Hypervel\Support\Facades\Cache; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use Redis; /** - * Tests that Redis locks work correctly under various phpredis serializer - * and compression configurations. - * - * Validates the pack() + withConnection() fix on RedisLock::release() and - * refresh() — Lua ARGV values must be pre-packed when a serializer is - * configured, because phpredis does NOT auto-serialize eval() ARGV. - * - * Unlike Laravel (which sets serializer options on a live client instance), - * Hypervel uses connection pooling — serializer/compression options must be - * configured at the connection config level so the pool creates connections - * with the correct settings. + * Configure serialization and compression on the pool so every connection + * uses the same options. Lock release and refresh must pack the owner for + * Lua with those options, since phpredis does not serialize eval() ARGV. */ class PhpRedisCacheLockTest extends TestCase { @@ -93,7 +86,9 @@ public function testRedisLockCanBeAcquiredAndReleasedWithLzfCompression(): void $this->assertLockCanBeAcquiredAndReleased(); } - public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(): void + #[TestWith(['COMPRESSION_ZSTD_DEFAULT'])] + #[TestWith(['COMPRESSION_ZSTD_MAX'])] + public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(string $compressionLevel): void { if (! defined('Redis::COMPRESSION_ZSTD')) { $this->markTestSkipped('Redis extension is not configured to support the zstd compression.'); @@ -102,13 +97,16 @@ public function testRedisLockCanBeAcquiredAndReleasedWithZstdCompression(): void $this->configureLockConnection([ 'serializer' => Redis::SERIALIZER_NONE, 'compression' => Redis::COMPRESSION_ZSTD, - 'compression_level' => Redis::COMPRESSION_ZSTD_DEFAULT, + 'compression_level' => constant(Redis::class . '::' . $compressionLevel), ]); $this->assertLockCanBeAcquiredAndReleased(); } - public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(): void + #[TestWith([1])] + #[TestWith([3])] + #[TestWith([12])] + public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(int $compressionLevel): void { if (! defined('Redis::COMPRESSION_LZ4')) { $this->markTestSkipped('Redis extension is not configured to support the lz4 compression.'); @@ -117,7 +115,7 @@ public function testRedisLockCanBeAcquiredAndReleasedWithLz4Compression(): void $this->configureLockConnection([ 'serializer' => Redis::SERIALIZER_NONE, 'compression' => Redis::COMPRESSION_LZ4, - 'compression_level' => 1, + 'compression_level' => $compressionLevel, ]); $this->assertLockCanBeAcquiredAndReleased(); @@ -140,21 +138,13 @@ public function testRedisLockCanBeAcquiredAndReleasedWithSerializationAndCompres /** * Configure a dedicated Redis connection for lock testing with the given options. * - * Creates a 'lock-test' Redis connection with the specified serializer/compression - * options, points the cache store's lock_connection to it, and purges the cache - * store so it picks up the new configuration. + * @param array $options */ protected function configureLockConnection(array $options): void { $config = $this->app->make('config'); - $baseConfig = $config->array('database.redis.default'); - - $config->set('database.redis.lock-test', array_merge($baseConfig, [ - 'options' => $options, - ])); - $config->set('cache.stores.redis.connection', 'default'); - $config->set('cache.stores.redis.lock_connection', 'lock-test'); + $config->set('cache.stores.redis.lock_connection', $this->createRedisConnectionWithOptions('lock-test', $options)); Cache::forgetDriver('redis'); } @@ -168,6 +158,8 @@ protected function assertLockCanBeAcquiredAndReleased(): void $store = Cache::store('redis'); $store->lock('foo')->forceRelease(); + $this->assertNull($store->lockConnection()->get($store->getPrefix() . 'foo')); + $lock = $store->lock('foo', 3); $this->assertTrue($lock->get()); $this->assertFalse($store->lock('foo', 3)->get()); @@ -184,8 +176,8 @@ protected function assertLockCanBeAcquiredAndReleased(): void $this->assertGreaterThan($decayedLifetime, $refreshedLifetime); $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix() . 'foo')); - // After release, lock should be acquirable again $lock = $store->lock('foo', 10); $this->assertTrue($lock->get()); $lock->forceRelease(); From f128809a7f38a432ba6b6c20d44f52146b68f83c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:15:01 +0000 Subject: [PATCH 07/29] fix(cache): apply native compression to Lua cache writes Use the existing held-connection pack helper for Lua cache values when compression is enabled without a native serializer. PhpRedis _serialize does not compress, so putMany and the three Any-tag writers stored uncompressed payloads despite configured compression. Raw-byte fallback during reads concealed the mismatch. Preserve numeric types when packing so pack_ignore_numbers retains incrementable counters. Keep native-serializer handling, the uncompressed fast path, pooling and command counts unchanged. Correct the two misleading mock expectations, add real standalone/Cluster regressions for all four writers and numeric packing, and clarify both documented counter configuration alternatives. Discovered while reconciling https://github.com/laravel/framework/pull/53940. The current Laravel RedisStore::pack implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 supplies the same packing boundary; this fixes the Hypervel Lua adaptation. Full reconciliation of related PR #54372 remains separately tracked. Validation: changed unit file 17 tests/35 assertions; new integration file 5/15 on standalone and Cluster; cache operation ParaTest 479/2312; focused integration ParaTest 41/133; RedisStore integration 22/45 with four existing skips. Full source/types PHPStan, repository formatting and diff checks passed. Self-reviewed and signed off by claude-laravel-parity. --- src/cache/src/Redis/Support/Serialization.php | 13 +-- src/docs/cache.md | 2 +- src/docs/redis.md | 2 +- .../Cache/Redis/Support/SerializationTest.php | 15 ++-- .../Redis/SerializationIntegrationTest.php | 84 +++++++++++++++++++ 5 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 tests/Integration/Cache/Redis/SerializationIntegrationTest.php diff --git a/src/cache/src/Redis/Support/Serialization.php b/src/cache/src/Redis/Support/Serialization.php index 8ee2aefc9..d45b097d8 100644 --- a/src/cache/src/Redis/Support/Serialization.php +++ b/src/cache/src/Redis/Support/Serialization.php @@ -59,7 +59,7 @@ public function serialize(RedisConnection $connection, mixed $value): mixed * serialize Lua ARGV parameters. * * This method handles three scenarios: - * 1. Serializer configured (igbinary/json/php): Use pack() which calls _serialize() + * 1. Serializer configured (igbinary/json/php): Pack with native serialization and compression * 2. No serializer, but compression enabled: PHP serialize, then compress * 3. No serializer, no compression: Just PHP serialize * @@ -69,24 +69,17 @@ public function serialize(RedisConnection $connection, mixed $value): mixed */ public function serializeForLua(RedisConnection $connection, mixed $value): string { - // Case 1: Serializer configured (e.g. igbinary/json) - // pack() calls _serialize() which handles serialization and compression if ($connection->serialized()) { return $connection->pack([$value])[0]; } - // No serializer - must PHP-serialize first $serialized = $this->phpSerialize($value); - // Case 2: Check if compression is enabled (even without serializer) if ($connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE) { - // _serialize() applies compression even with SERIALIZER_NONE - // Cast to string in case serialize() returned a numeric value - return $connection->_serialize(is_numeric($serialized) ? (string) $serialized : $serialized); + // Lua arguments need native packing for compression. Preserve numeric types for pack_ignore_numbers. + return $connection->pack([$serialized])[0]; } - // Case 3: No serializer, no compression - // Cast to string in case serialize() returned a numeric value return is_numeric($serialized) ? (string) $serialized : $serialized; } diff --git a/src/docs/cache.md b/src/docs/cache.md index 51ead9fac..09c4a89c3 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -384,7 +384,7 @@ Cache::decrement('key'); Cache::decrement('key', $amount); ``` -When using a Redis cache store with PhpRedis serialization, atomic counters require either `Redis::SERIALIZER_NONE` or the PhpRedis 6.2+ `pack_ignore_numbers` option. See the [PhpRedis serialization documentation](/docs/{{version}}/redis#phpredis-serialization) for configuration details. +When using a Redis cache store, atomic counters require disabling both PhpRedis serialization and compression or enabling the PhpRedis 6.2+ `pack_ignore_numbers` option. See the [PhpRedis serialization documentation](/docs/{{version}}/redis#phpredis-serialization) for configuration details. #### Retrieve and Store diff --git a/src/docs/redis.md b/src/docs/redis.md index 06961e831..4deb4b70d 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -168,7 +168,7 @@ The PhpRedis extension may also be configured to use a variety of serializers an Currently supported serializers include: `Redis::SERIALIZER_NONE` (default), `Redis::SERIALIZER_PHP`, `Redis::SERIALIZER_JSON`, `Redis::SERIALIZER_IGBINARY`, and `Redis::SERIALIZER_MSGPACK`. -Redis atomic counters must remain unencoded. Connections used for atomic increments or decrements should either use `Redis::SERIALIZER_NONE` or enable `pack_ignore_numbers` with PhpRedis 6.2 or later. This applies to standalone and Cluster connections. +Redis atomic counters must remain unencoded. Connections used to store atomic counters must disable both serialization and compression, or enable `pack_ignore_numbers` with PhpRedis 6.2 or later. This applies to standalone and Cluster connections. When relying on Cache's [serializable class allowlist](/docs/{{version}}/cache#serializable-cached-objects), configure the connection used by the Redis cache store with `Redis::SERIALIZER_NONE`. Options in the shared `options` array also apply to that connection. diff --git a/tests/Cache/Redis/Support/SerializationTest.php b/tests/Cache/Redis/Support/SerializationTest.php index 1372af106..7f7ea8342 100644 --- a/tests/Cache/Redis/Support/SerializationTest.php +++ b/tests/Cache/Redis/Support/SerializationTest.php @@ -183,9 +183,9 @@ public function testSerializeForLuaAppliesCompressionWhenEnabled(): void $connection->shouldReceive('getOption') ->with(Redis::OPT_COMPRESSION) ->andReturn(Redis::COMPRESSION_LZF); - $connection->shouldReceive('_serialize') - ->with(serialize('test-value')) - ->andReturn('compressed-value'); + $connection->shouldReceive('pack') + ->with([serialize('test-value')]) + ->andReturn(['compressed-value']); $this->assertSame('compressed-value', $this->serialization->serializeForLua($connection, 'test-value')); } @@ -216,7 +216,7 @@ public function testSerializeForLuaCastsNumericValuesToString(): void $this->assertSame('45.67', $this->serialization->serializeForLua($connection, 45.67)); } - public function testSerializeForLuaCastsNumericToStringWithCompression(): void + public function testSerializeForLuaPreservesNumericTypesForPackingWithCompression(): void { if (! defined('Redis::COMPRESSION_LZF')) { $this->markTestSkipped('Redis::COMPRESSION_LZF not available (phpredis compiled without LZF support)'); @@ -228,10 +228,9 @@ public function testSerializeForLuaCastsNumericToStringWithCompression(): void $connection->shouldReceive('getOption') ->with(Redis::OPT_COMPRESSION) ->andReturn(Redis::COMPRESSION_LZF); - // When compression is enabled, numeric strings get passed through _serialize - $connection->shouldReceive('_serialize') - ->with('123') - ->andReturn('compressed-123'); + $connection->shouldReceive('pack') + ->with([123]) + ->andReturn(['compressed-123']); $this->assertSame('compressed-123', $this->serialization->serializeForLua($connection, 123)); } diff --git a/tests/Integration/Cache/Redis/SerializationIntegrationTest.php b/tests/Integration/Cache/Redis/SerializationIntegrationTest.php new file mode 100644 index 000000000..0196d2b2c --- /dev/null +++ b/tests/Integration/Cache/Redis/SerializationIntegrationTest.php @@ -0,0 +1,84 @@ +configureCompression(); + + if ($operation !== 'putMany') { + $this->setTagMode(TagMode::Any); + } + + $cache = $operation === 'putMany' ? $this->cache() : $this->cache()->tags(['compressed']); + $value = str_repeat('cache-value', 100); + + $this->assertTrue(match ($operation) { + 'putMany' => $cache->putMany(['compressed' => $value], 60), + 'put' => $cache->put('compressed', $value, 60), + 'add' => $cache->add('compressed', $value, 60), + 'forever' => $cache->forever('compressed', $value), + }); + + $redis = $this->redis(); + $key = $this->getCachePrefix() . 'compressed'; + $expected = $redis->withConnection( + static fn (RedisConnection $connection): string => $connection->pack([serialize($value)])[0], + ); + + // Round trips also succeed for uncompressed bytes, so inspect the stored representation. + $this->assertSame($expected, $redis->withoutSerializationOrCompression(static fn (): mixed => $redis->get($key))); + $this->assertSame($value, $this->cache()->get('compressed')); + } + + public function testPutManyPreservesNumbersWhenPackingIgnoresThem(): void + { + if (! defined('Redis::OPT_PACK_IGNORE_NUMBERS')) { + $this->markTestSkipped('PhpRedis does not support OPT_PACK_IGNORE_NUMBERS.'); + } + + $this->configureCompression(ignoreNumbers: true); + + $this->assertTrue($this->cache()->putMany(['counter' => 5], 60)); + + $redis = $this->redis(); + $key = $this->getCachePrefix() . 'counter'; + + $this->assertSame('5', $redis->withoutSerializationOrCompression(static fn (): mixed => $redis->get($key))); + $this->assertSame(6, $this->cache()->increment('counter')); + } + + /** + * Configure compression on a fresh connection before resolving the cache store. + */ + protected function configureCompression(bool $ignoreNumbers = false): void + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis extension is not configured to support the lzf compression.'); + } + + $options = [ + 'serializer' => Redis::SERIALIZER_NONE, + 'compression' => Redis::COMPRESSION_LZF, + ]; + + if ($ignoreNumbers) { + $options['pack_ignore_numbers'] = true; + } + + config(['cache.stores.redis.connection' => $this->createRedisConnectionWithOptions('cache_compressed', $options)]); + } +} From 814167ebddf9a5e134dcc5f956ad6efdaaf97790 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 18:36:04 +0000 Subject: [PATCH 08/29] test(cache): complete upstream Redis counter regression coverage Port the applicable current Laravel tests from PRs #54372 and #60821. Hypervel already normalizes tagged enum keys and uses a dedicated atomic rate limiter, so no production change is required. Preserve the limiter admission assertions on one stored counter by varying policy cost instead of identity-defining capacity. Point the omitted cache-backed fixture to its native limiter test. Restore serialized increment coverage through a fresh pooled connection with the supported pack_ignore_numbers option, and port the enum decrement/read/tag-flush regression with its file-local fixture. Upstream: https://github.com/laravel/framework/pull/54372 (2d10f2aeddc94e57529a2fb33a50bed8810e3923) and https://github.com/laravel/framework/pull/60821 (31a10a802f925098284d56e1f2bfddddfdd95811). Reconciled with 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validation: immediate per-file tests and focused three-worker ParaTest passed (57 tests, 263 assertions, three existing separate-lock-store skips). All three files passed on Redis Cluster with identical counts. Formatting and diff checks passed; peer independently verified both topologies and signed off. --- .../Cache/Redis/RedisCacheIntegrationTest.php | 3 ++ .../Cache/Redis/RedisStoreTest.php | 39 ++++++++++++++++++- .../RateLimiter/Redis/RedisStoreTest.php | 5 +++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php index e3ac06f1c..c574be746 100644 --- a/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php +++ b/tests/Integration/Cache/Redis/RedisCacheIntegrationTest.php @@ -19,6 +19,9 @@ public function testRedisCacheAddTwice() $this->assertGreaterThan(3500, $this->store()->connection()->ttl($this->store()->getPrefix() . 'k')); } + // REMOVED: the cache-backed testRedisCacheRateLimiter fixture. Its admission assertions + // run in RateLimiter/Redis/RedisStoreTest::testSerializerAndCompressionOptionsDoNotAffectLimiterState(). + /** * Breaking change. */ diff --git a/tests/Integration/Cache/Redis/RedisStoreTest.php b/tests/Integration/Cache/Redis/RedisStoreTest.php index a21d23a0e..284073873 100644 --- a/tests/Integration/Cache/Redis/RedisStoreTest.php +++ b/tests/Integration/Cache/Redis/RedisStoreTest.php @@ -130,6 +130,20 @@ public function testTagEntriesCanBeIncremented() $this->assertEquals(0, Cache::store('redis')->tags(['votes'])->get('person-1')); } + public function testTagEntriesCanBeDecrementedUsingEnumKeys(): void + { + Cache::store('redis')->clear(); + + Cache::store('redis')->tags(['votes'])->put(RedisTaggedCacheTestKey::Person1, 2, 5); + Cache::store('redis')->tags(['votes'])->decrement(RedisTaggedCacheTestKey::Person1); + + $this->assertEquals(1, Cache::store('redis')->tags(['votes'])->get(RedisTaggedCacheTestKey::Person1)); + + Cache::store('redis')->tags(['votes'])->flush(); + + $this->assertNull(Cache::store('redis')->tags(['votes'])->get(RedisTaggedCacheTestKey::Person1)); + } + public function testIncrementedTagEntriesProperlyTurnStale() { Cache::store('redis')->clear(); @@ -228,9 +242,25 @@ public function testMultipleItemsCanBeSetAndRetrieved() // PutMany operation class which has its own cluster fallback. This behavior is tested in // tests/Cache/Redis/Operations/PutManyTest.php (cluster mode tests). - public function testIncrementWithSerializationEnabled() + public function testIncrementWithSerializationEnabled(): void { - $this->markTestSkipped('Test makes no sense anymore. Application must explicitly wrap such code in runClean() when used with serialization/compression enabled.'); + if (! defined('Redis::OPT_PACK_IGNORE_NUMBERS')) { + $this->markTestSkipped('PhpRedis does not support OPT_PACK_IGNORE_NUMBERS.'); + } + + $connection = $this->createRedisConnectionWithOptions('cache_serialized', [ + 'serializer' => Redis::SERIALIZER_PHP, + 'pack_ignore_numbers' => true, + ]); + config(['cache.stores.redis.connection' => $connection]); + + $store = Cache::store('redis'); + $store->flush(); + $store->add('foo', 1, 10); + $this->assertSame(1, $store->get('foo')); + + $store->increment('foo'); + $this->assertSame(2, $store->get('foo')); } public function testTagsCanBeFlushedWithLargeNumberOfKeys() @@ -340,3 +370,8 @@ public function testStoreFlushLocksThrowsExceptionWhenLockConnectionIsSame() $store->flushLocks(); } } + +enum RedisTaggedCacheTestKey: string +{ + case Person1 = 'person-1'; +} diff --git a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php index 5913e9dbb..017029b51 100644 --- a/tests/Integration/RateLimiter/Redis/RedisStoreTest.php +++ b/tests/Integration/RateLimiter/Redis/RedisStoreTest.php @@ -424,7 +424,12 @@ public function testSerializerAndCompressionOptionsDoNotAffectLimiterState(): vo resetAfter: 5, )->by('encoded-backoff'); + // Vary cost rather than the limit so both admission checks inspect the same stored counter. + $this->assertFalse($limiter->inspect($fixed->cost(2))->denied()); $this->assertSame(1, $limiter->consume($fixed)->remaining()); + $this->assertTrue($limiter->inspect($fixed->cost(2))->denied()); + $this->assertFalse($limiter->inspect($fixed)->denied()); + $this->assertSame(1, $limiter->consume($sliding)->remaining()); $this->assertTrue($limiter->consume($leaky)->allowed()); $this->assertTrue($limiter->consume($leaky)->denied()); From 084fe95bca1f66129dc05131906e2f44bcca9f67 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 19:03:27 +0000 Subject: [PATCH 09/29] fix(validation): complete upstream placeholder replacement delegation Route related validation message replacers through their shared upstream owners so all ten affected rules preserve original mixed-case values and expand uppercase and capitalized placeholders. Complete the related comparison and present-unless delegations and restore the two protected extension-point visibilities from current Laravel 13.x. Port the complete remaining source changes from Laravel PRs: https://github.com/laravel/framework/pull/56789 https://github.com/laravel/framework/pull/56790 https://github.com/laravel/framework/pull/56792 https://github.com/laravel/framework/pull/56794 https://github.com/laravel/framework/pull/56795 https://github.com/laravel/framework/pull/56813 https://github.com/laravel/framework/pull/56970 Discovered while reconciling the already-present casing implementation and tests from: https://github.com/laravel/framework/pull/57556 https://github.com/laravel/framework/pull/57564 https://github.com/laravel/framework/pull/57733 Source reference: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Keep Hypervel native types, useful array annotations and the strict string boundary in the shared comparison replacer. Both compiled and normal validation retain their existing failure-formatting path. Add ten real-validator regression cases without duplicating the existing upstream casing suite, and document supported placeholder capitalization using adapted Laravel localization prose. Validation: ten regressions reproduced before the source fix; ValidationValidatorTest passes 795 tests / 2628 assertions; Validation ParaTest passes 1692 tests / 5832 assertions. Full source and type-fixture PHPStan, formatting and diff checks pass. Independently reviewed and signed off by claude-laravel-parity. --- src/docs/validation.md | 9 ++ .../src/Concerns/ReplacesAttributes.php | 90 ++++--------------- tests/Validation/ValidationValidatorTest.php | 66 ++++++++++++++ 3 files changed, 93 insertions(+), 72 deletions(-) diff --git a/src/docs/validation.md b/src/docs/validation.md index 9addbe387..0a3809517 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1050,6 +1050,15 @@ $messages = [ ]; ``` +You may capitalize `:attribute` and supported rule placeholders to control the casing of their replacements: + +```php +$messages = [ + 'same' => 'The :Attribute and :Other must match.', + 'in' => 'The :attribute must be one of the following: :VALUES', +]; +``` + #### Specifying a Custom Message for a Given Attribute diff --git a/src/validation/src/Concerns/ReplacesAttributes.php b/src/validation/src/Concerns/ReplacesAttributes.php index aa12d8bf4..01bbf3b98 100644 --- a/src/validation/src/Concerns/ReplacesAttributes.php +++ b/src/validation/src/Concerns/ReplacesAttributes.php @@ -25,14 +25,12 @@ protected function replaceAcceptedIf(string $message, string $attribute, string /** * Replace all place-holders for the declined_if rule. + * + * @param array $parameters */ protected function replaceDeclinedIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -168,11 +166,7 @@ protected function replaceMaxDigits(string $message, string $attribute, string $ */ protected function replaceMissingIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -287,11 +281,7 @@ protected function replaceInArrayKeys(string $message, string $attribute, string */ protected function replaceRequiredArrayKeys(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -321,10 +311,7 @@ protected function replaceMimes(string $message, string $attribute, string $rule */ protected function replacePresentIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -334,10 +321,7 @@ protected function replacePresentIf(string $message, string $attribute, string $ */ protected function replacePresentUnless(string $message, string $attribute, string $rule, array $parameters): string { - return str_replace([':other', ':value'], [ - $this->getDisplayableAttribute($parameters[0]), - $this->getDisplayableValue($parameters[0], $parameters[1]), - ], $message); + return $this->replaceMissingUnless($message, $attribute, $rule, $parameters); } /** @@ -447,11 +431,7 @@ protected function replaceGt(string $message, string $attribute, string $rule, a */ protected function replaceLt(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -461,11 +441,7 @@ protected function replaceLt(string $message, string $attribute, string $rule, a */ protected function replaceGte(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -475,11 +451,7 @@ protected function replaceGte(string $message, string $attribute, string $rule, */ protected function replaceLte(string $message, string $attribute, string $rule, array $parameters): string { - if (is_null($value = $this->getValue($parameters[0]))) { - return str_replace(':value', $this->getDisplayableAttribute($parameters[0]), $message); - } - - return str_replace(':value', (string) $this->getSize($attribute, $value), $message); + return $this->replaceGt($message, $attribute, $rule, $parameters); } /** @@ -489,11 +461,7 @@ protected function replaceLte(string $message, string $attribute, string $rule, */ protected function replaceRequiredIf(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[1] = $this->getDisplayableValue($parameters[0], Arr::get($this->data, $parameters[0])); - - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other', ':value'], $parameters, $message); + return $this->replaceAcceptedIf($message, $attribute, $rule, $parameters); } /** @@ -513,11 +481,9 @@ protected function replaceRequiredIfAccepted(string $message, string $attribute, * * @param array $parameters */ - public function replaceRequiredIfDeclined(string $message, string $attribute, string $rule, array $parameters): string + protected function replaceRequiredIfDeclined(string $message, string $attribute, string $rule, array $parameters): string { - $parameters[0] = $this->getDisplayableAttribute($parameters[0]); - - return str_replace([':other'], $parameters, $message); + return $this->replaceRequiredIfAccepted($message, $attribute, $rule, $parameters); } /** @@ -574,7 +540,7 @@ protected function replaceProhibitedIfAccepted(string $message, string $attribut * * @param array $parameters */ - public function replaceProhibitedIfDeclined(string $message, string $attribute, string $rule, array $parameters): string + protected function replaceProhibitedIfDeclined(string $message, string $attribute, string $rule, array $parameters): string { return $this->replaceRequiredIfAccepted($message, $attribute, $rule, $parameters); } @@ -586,15 +552,7 @@ public function replaceProhibitedIfDeclined(string $message, string $attribute, */ protected function replaceProhibitedUnless(string $message, string $attribute, string $rule, array $parameters): string { - $other = $this->getDisplayableAttribute($parameters[0]); - - $values = []; - - foreach (array_slice($parameters, 1) as $value) { - $values[] = $this->getDisplayableValue($parameters[0], $value); - } - - return str_replace([':other', ':values'], [$other, implode(', ', $values)], $message); + return $this->replaceRequiredUnless($message, $attribute, $rule, $parameters); } /** @@ -706,11 +664,7 @@ protected function replaceDimensions(string $message, string $attribute, string */ protected function replaceEndsWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -720,11 +674,7 @@ protected function replaceEndsWith(string $message, string $attribute, string $r */ protected function replaceDoesntEndWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** @@ -744,11 +694,7 @@ protected function replaceStartsWith(string $message, string $attribute, string */ protected function replaceDoesntStartWith(string $message, string $attribute, string $rule, array $parameters): string { - foreach ($parameters as &$parameter) { - $parameter = $this->getDisplayableValue($attribute, $parameter); - } - - return str_replace(':values', implode(', ', $parameters), $message); + return $this->replaceIn($message, $attribute, $rule, $parameters); } /** diff --git a/tests/Validation/ValidationValidatorTest.php b/tests/Validation/ValidationValidatorTest.php index 4ca8538a5..aa4b7cf96 100755 --- a/tests/Validation/ValidationValidatorTest.php +++ b/tests/Validation/ValidationValidatorTest.php @@ -943,6 +943,72 @@ public function testCapitalizedDisplayableValuesAreReplaced() $this->assertSame('The url must start with one of the following values hTtp, hTtps', $v->messages()->first('url')); } + #[TestWith(['declined_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['missing_if', ['foo' => 'yes', 'bar' => 'aAa']])] + #[TestWith(['present_if', ['bar' => 'aAa']])] + #[TestWith(['required_if', ['bar' => 'aAa']])] + public function testConditionalRulePlaceholdersPreserveCasingVariants(string $rule, array $data): void + { + $validator = new Validator( + $this->getArrayTranslator(), + $data, + ['foo' => $rule . ':bar,aAa'], + [$rule => ':other|:OTHER|:Other|:value|:VALUE|:Value'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('otherField|OTHERFIELD|OtherField|aAa|AAA|AAa', $validator->errors()->first('foo')); + } + + public function testRequiredIfDeclinedPlaceholdersPreserveCasingVariants(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['bar' => 'no'], + ['foo' => 'required_if_declined:bar'], + ['required_if_declined' => ':other|:OTHER|:Other'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('otherField|OTHERFIELD|OtherField', $validator->errors()->first('foo')); + } + + public function testProhibitedUnlessPlaceholdersPreserveCasingVariants(): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => 'yes', 'bar' => 'aAa'], + ['foo' => 'prohibited_unless:bar,tAylor,sVen'], + ['prohibited_unless' => ':other|:OTHER|:Other|:values|:VALUES|:Values'], + ['bar' => 'otherField'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame( + 'otherField|OTHERFIELD|OtherField|tAylor, sVen|TAYLOR, SVEN|TAylor, SVen', + $validator->errors()->first('foo'), + ); + } + + #[TestWith(['required_array_keys', []])] + #[TestWith(['ends_with', 'other'])] + #[TestWith(['doesnt_end_with', 'tAylor'])] + #[TestWith(['doesnt_start_with', 'sVen'])] + public function testValueListRulePlaceholdersPreserveCasingVariants(string $rule, array|string $value): void + { + $validator = new Validator( + $this->getArrayTranslator(), + ['foo' => $value], + ['foo' => $rule . ':tAylor,sVen'], + [$rule => ':values|:VALUES|:Values'], + ); + + $this->assertFalse($validator->passes()); + $this->assertSame('tAylor, sVen|TAYLOR, SVEN|TAylor, SVen', $validator->errors()->first('foo')); + } + public function testDisplayableAttributesAreReplacedInCustomReplacers() { $trans = $this->getArrayTranslator(); From 3b6c49abf3eb06b96779ade3cbf440a8ac32beca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:07:13 +0000 Subject: [PATCH 10/29] feat(validation): port array_keys and preserve literal field identity Port Laravel framework PR #60918 and documentation PR #11310 using current 13.x source and tests: https://github.com/laravel/framework/pull/60918 https://github.com/laravel/docs/pull/11310 Source baseline: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 Add the array_keys rule, fluent builder, unexpected-key replacements, English translation and upstream documentation. Preserve every upstream test assertion with Hypervel types, imports and existing enum fixtures. Correct literal dot/asterisk key comparisons in all four array-key rules. Accept encoded validator keys and literal direct-call keys, retaining short-circuit lookups and inline bulk key encoding. Normalize empty parsed parameters in the new fallback consistently with Arr::exists. Keep encoded field identity throughout validation message formatting. Premature decoding made supported literal names resolve as nested fields, select incorrect numeric messages, lose wildcard labels and misreport positions. Decode only for display, message keys, failed-rule output and registered custom callbacks. Preserve custom rules' redirected messages. No temporary state or compatibility machinery is introduced. The owner approved the internal subclass-extension argument difference. Document that contract in source; omit rare internal override guidance from user documentation per the owner's explicit direction. Validation: Validation ParaTest 1722 tests / 5903 assertions; integration 263 tests / 271 assertions with 167 service-dependent skips. Full source and type PHPStan passed, formatting and diff checks passed. Independently reviewed and signed off by claude-laravel-parity. --- src/docs/validation.md | 16 ++ src/translation/lang/en/validation.php | 1 + .../src/Concerns/FormatsMessages.php | 36 ++-- .../src/Concerns/ReplacesAttributes.php | 23 +++ .../src/Concerns/ValidatesAttributes.php | 40 +++- src/validation/src/Rule.php | 9 + src/validation/src/Rules/ArrayKeys.php | 44 +++++ src/validation/src/ValidationData.php | 15 ++ src/validation/src/Validator.php | 18 +- .../ValidationArrayKeysRuleTest.php | 184 +++++++++++++++++ tests/Validation/ValidationValidatorTest.php | 185 ++++++++++++++++++ 11 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 src/validation/src/Rules/ArrayKeys.php create mode 100644 tests/Validation/ValidationArrayKeysRuleTest.php diff --git a/src/docs/validation.md b/src/docs/validation.md index 0a3809517..ba0d38634 100644 --- a/src/docs/validation.md +++ b/src/docs/validation.md @@ -1409,6 +1409,7 @@ Below is a list of all available validation rules and their function:
[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');