From 4e6de87818c5d0f1b63a2032121919f947250e4a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:31:20 +0000 Subject: [PATCH 01/32] Port command discovery regression coverage from Laravel Restore the current upstream regression for discovering real commands whose class names end in Test while excluding PHPUnit classes in the same directory. Exercise the real kernel through addCommandPaths and normal bootstrap. Keep all three upstream fixture roles and assertions, use the disposable Testbench application and an owned autoloader, and clean up only the created fixture directory. This avoids upstream test-helper machinery and shared autoloader mutation while retaining Hypervel command discovery and execution behavior. Upstream: https://github.com/laravel/framework/pull/58017 and https://github.com/laravel/framework/pull/58147. Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Validated the complete kernel test file, focused Foundation Console and Console suites, and formatting. Peer verification also covered generator interaction in both execution orders. No production behavior changes. --- tests/Foundation/Console/KernelTest.php | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index 597e34e52..1bebdf9b5 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Foundation\Console; +use Composer\Autoload\ClassLoader; use Hypervel\Console\Application as ConsoleApplication; use Hypervel\Console\Command; use Hypervel\Console\Scheduling\CacheEventMutex; @@ -13,6 +14,7 @@ use Hypervel\Contracts\Debug\ExceptionHandler as ExceptionHandlerContract; use Hypervel\Contracts\Foundation\Application as ApplicationContract; use Hypervel\Events\Dispatcher; +use Hypervel\Filesystem\Filesystem; use Hypervel\Foundation\Application; use Hypervel\Foundation\Bootstrap\BootProviders; use Hypervel\Foundation\Console\Kernel; @@ -24,6 +26,7 @@ use ReflectionProperty; use RuntimeException; use Swoole\Coroutine\CanceledException; +use Symfony\Component\Console\Command\Command as SymfonyCommand; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\StringInput; use Symfony\Component\Console\Output\BufferedOutput; @@ -184,6 +187,81 @@ public function testConfiguredScheduleCacheUsesTheSelectedStoreForBothMutexes(): $this->assertSame('scheduling', $this->app->make(CacheSchedulingMutex::class)->store); } + public function testLoadIgnoresTestFiles(): void + { + $files = new Filesystem; + $directory = $this->app->path('Console/Commands/Discovery'); + $loader = new ClassLoader; + $loader->addPsr4('App\Console\Commands\Discovery\\', $directory); + + try { + $files->ensureDirectoryExists($directory); + $files->put($directory . '/ExampleCommand.php', <<<'PHP' +put($directory . '/ExampleCommandTest.php', <<<'PHP' +put($directory . '/ExampleCommandUnitTest.php', <<<'PHP' +assertTrue(true); + } +} +PHP); + $loader->register(); + + $kernel = new Kernel($this->app, $this->app->make('events')); + $kernel->addCommandPaths([$directory]); + + $commands = collect($kernel->getArtisan()->all()) + ->map(static fn (SymfonyCommand $command): string => $command::class)->all(); + + $this->assertContains('App\Console\Commands\Discovery\ExampleCommand', $commands); + $this->assertContains('App\Console\Commands\Discovery\ExampleCommandTest', $commands); + $this->assertNotContains('App\Console\Commands\Discovery\ExampleCommandUnitTest', $commands); + } finally { + $loader->unregister(); + $files->deleteDirectory($directory); + } + } + public function testSetArtisanSynchronizesTheKernelAndContainerBeforeReboundCallbacks(): void { $kernel = $this->app->make(KernelContract::class); From 1b3fb874cd02fb64fc3dea1382fb9fab0aebe754 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:31:35 +0000 Subject: [PATCH 02/32] Clarify database-specific schema column limits Correct the descriptions inherited from Laravel PR #58019: text capacities count bytes, not characters, and the listed integer widths, unsigned ranges and text limits belong to MySQL and MariaDB. PostgreSQL and SQLite compile several of these column types differently. Preserve all upstream numeric values while qualifying the descriptions across the 22 affected methods, including incrementing columns and foreignId. The SQL generation, types and public APIs remain unchanged; no runtime checks or compatibility branches are added. Upstream: https://github.com/laravel/framework/pull/58019. Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Verified against the supported schema grammars and MySQL string type documentation. Validation: formatting and diff checks pass; peer facade-docblock verification passes. Changes are prose only and do not alter analysis annotations or executable code. --- src/database/src/Schema/Blueprint.php | 54 +++++++++++++-------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/src/database/src/Schema/Blueprint.php b/src/database/src/Schema/Blueprint.php index 42feec03b..fdc6ddf93 100755 --- a/src/database/src/Schema/Blueprint.php +++ b/src/database/src/Schema/Blueprint.php @@ -706,7 +706,7 @@ public function foreign(array|string $columns, ?string $name = null): ForeignKey } /** - * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). */ public function id(string $column = 'id'): ColumnDefinition { @@ -714,7 +714,7 @@ public function id(string $column = 'id'): ColumnDefinition } /** - * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). + * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). */ public function increments(string $column): ColumnDefinition { @@ -722,7 +722,7 @@ public function increments(string $column): ColumnDefinition } /** - * Create a new auto-incrementing integer column on the table (4-byte, 0 to 4,294,967,295). + * Create a new auto-incrementing integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). */ public function integerIncrements(string $column): ColumnDefinition { @@ -730,7 +730,7 @@ public function integerIncrements(string $column): ColumnDefinition } /** - * Create a new auto-incrementing tiny integer column on the table (1-byte, 0 to 255). + * Create a new auto-incrementing tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255). */ public function tinyIncrements(string $column): ColumnDefinition { @@ -738,7 +738,7 @@ public function tinyIncrements(string $column): ColumnDefinition } /** - * Create a new auto-incrementing small integer column on the table (2-byte, 0 to 65,535). + * Create a new auto-incrementing small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535). */ public function smallIncrements(string $column): ColumnDefinition { @@ -746,7 +746,7 @@ public function smallIncrements(string $column): ColumnDefinition } /** - * Create a new auto-incrementing medium integer column on the table (3-byte, 0 to 16,777,215). + * Create a new auto-incrementing medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215). */ public function mediumIncrements(string $column): ColumnDefinition { @@ -754,7 +754,7 @@ public function mediumIncrements(string $column): ColumnDefinition } /** - * Create a new auto-incrementing big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + * Create a new auto-incrementing big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). */ public function bigIncrements(string $column): ColumnDefinition { @@ -782,7 +782,7 @@ public function string(string $column, ?int $length = null): ColumnDefinition } /** - * Create a new tiny text column on the table (up to 255 characters). + * Create a new tiny text column on the table (up to 255 bytes on MySQL/MariaDB). */ public function tinyText(string $column): ColumnDefinition { @@ -790,7 +790,7 @@ public function tinyText(string $column): ColumnDefinition } /** - * Create a new text column on the table (up to 65,535 characters / ~64 KB). + * Create a new text column on the table (up to 65,535 bytes on MySQL/MariaDB). */ public function text(string $column): ColumnDefinition { @@ -798,7 +798,7 @@ public function text(string $column): ColumnDefinition } /** - * Create a new medium text column on the table (up to 16,777,215 characters / ~16 MB). + * Create a new medium text column on the table (up to 16,777,215 bytes on MySQL/MariaDB). */ public function mediumText(string $column): ColumnDefinition { @@ -806,7 +806,7 @@ public function mediumText(string $column): ColumnDefinition } /** - * Create a new long text column on the table (up to 4,294,967,295 characters / ~4 GB). + * Create a new long text column on the table (up to 4,294,967,295 bytes on MySQL/MariaDB). */ public function longText(string $column): ColumnDefinition { @@ -814,8 +814,8 @@ public function longText(string $column): ColumnDefinition } /** - * Create a new integer (4-byte) column on the table. - * Range: -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned). + * Create a new integer column on the table. + * MySQL/MariaDB (4-byte): -2,147,483,648 to 2,147,483,647 (signed) or 0 to 4,294,967,295 (unsigned). */ public function integer(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -823,8 +823,8 @@ public function integer(string $column, bool $autoIncrement = false, bool $unsig } /** - * Create a new tiny integer (1-byte) column on the table. - * Range: -128 to 127 (signed) or 0 to 255 (unsigned). + * Create a new tiny integer column on the table. + * MySQL/MariaDB (1-byte): -128 to 127 (signed) or 0 to 255 (unsigned). */ public function tinyInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -832,8 +832,8 @@ public function tinyInteger(string $column, bool $autoIncrement = false, bool $u } /** - * Create a new small integer (2-byte) column on the table. - * Range: -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned). + * Create a new small integer column on the table. + * MySQL/MariaDB (2-byte): -32,768 to 32,767 (signed) or 0 to 65,535 (unsigned). */ public function smallInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -841,8 +841,8 @@ public function smallInteger(string $column, bool $autoIncrement = false, bool $ } /** - * Create a new medium integer (3-byte) column on the table. - * Range: -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned). + * Create a new medium integer column on the table. + * MySQL/MariaDB (3-byte): -8,388,608 to 8,388,607 (signed) or 0 to 16,777,215 (unsigned). */ public function mediumInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -850,8 +850,8 @@ public function mediumInteger(string $column, bool $autoIncrement = false, bool } /** - * Create a new big integer (8-byte) column on the table. - * Range: -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned). + * Create a new big integer column on the table. + * MySQL/MariaDB (8-byte): -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807 (signed) or 0 to 18,446,744,073,709,551,615 (unsigned). */ public function bigInteger(string $column, bool $autoIncrement = false, bool $unsigned = false): ColumnDefinition { @@ -859,7 +859,7 @@ public function bigInteger(string $column, bool $autoIncrement = false, bool $un } /** - * Create a new unsigned integer column on the table (4-byte, 0 to 4,294,967,295). + * Create a new unsigned integer column on the table (MySQL/MariaDB: 4-byte, 0 to 4,294,967,295). */ public function unsignedInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -867,7 +867,7 @@ public function unsignedInteger(string $column, bool $autoIncrement = false): Co } /** - * Create a new unsigned tiny integer column on the table (1-byte, 0 to 255). + * Create a new unsigned tiny integer column on the table (MySQL/MariaDB: 1-byte, 0 to 255). */ public function unsignedTinyInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -875,7 +875,7 @@ public function unsignedTinyInteger(string $column, bool $autoIncrement = false) } /** - * Create a new unsigned small integer column on the table (2-byte, 0 to 65,535). + * Create a new unsigned small integer column on the table (MySQL/MariaDB: 2-byte, 0 to 65,535). */ public function unsignedSmallInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -883,7 +883,7 @@ public function unsignedSmallInteger(string $column, bool $autoIncrement = false } /** - * Create a new unsigned medium integer column on the table (3-byte, 0 to 16,777,215). + * Create a new unsigned medium integer column on the table (MySQL/MariaDB: 3-byte, 0 to 16,777,215). */ public function unsignedMediumInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -891,7 +891,7 @@ public function unsignedMediumInteger(string $column, bool $autoIncrement = fals } /** - * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + * Create a new unsigned big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). */ public function unsignedBigInteger(string $column, bool $autoIncrement = false): ColumnDefinition { @@ -899,7 +899,7 @@ public function unsignedBigInteger(string $column, bool $autoIncrement = false): } /** - * Create a new unsigned big integer column on the table (8-byte, 0 to 18,446,744,073,709,551,615). + * Create a new unsigned big integer column on the table (MySQL/MariaDB: 8-byte, 0 to 18,446,744,073,709,551,615). */ public function foreignId(string $column): ForeignIdColumnDefinition { From fb011c1550ef43b58a91cd02c4d4a391cb7c057b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:58:48 +0000 Subject: [PATCH 03/32] Fix fractional values in numeric collection sorting Multi-attribute SORT_NUMERIC comparisons truncated both values to integers, so different fractional prices or scores became ties and were ordered by secondary fields or input order. Compare floats to match PHP's native numeric sort and Collection's single-attribute path. Keep the existing comparison loop, sort directions, key preservation and LazyCollection delegation. Add one shared regression for both collection classes covering positive and negative fractions, numeric strings, secondary-key ties and both primary sort directions. Found while reconciling Laravel's casting update; the defect originated in its multi-attribute sort-option implementation and remains in source pin 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Upstream: https://github.com/laravel/framework/pull/50269 https://github.com/laravel/framework/pull/58037 Validation: the new regression failed with the integer comparator and passes with the float comparator. Affected collection, support, request and file validation tests pass, as do formatting and both PHPStan configurations. --- src/collections/src/Collection.php | 2 +- tests/Support/SupportCollectionTest.php | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 1b9297a38..762303059 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -1572,7 +1572,7 @@ protected function sortByMany(array $comparisons = [], int $options = SORT_REGUL } } else { $result = match ($options) { - SORT_NUMERIC => (int) $values[0] <=> (int) $values[1], + SORT_NUMERIC => (float) $values[0] <=> (float) $values[1], SORT_STRING => strcmp((string) $values[0], (string) $values[1]), SORT_NATURAL => strnatcmp((string) $values[0], (string) $values[1]), SORT_LOCALE_STRING => strcoll((string) $values[0], (string) $values[1]), diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 848aded67..0f9de1153 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -2313,6 +2313,28 @@ public function testSortByMany($collection): void setlocale(LC_ALL, $defaultLocale); } + #[DataProvider('collectionClassProvider')] + public function testSortByManyWithNumericFractions(string $collection): void + { + $data = new $collection([ + ['score' => 1.9, 'rank' => 5], + ['score' => '1.1', 'rank' => 2], + ['score' => 1.1, 'rank' => 1], + ['score' => -1.1, 'rank' => 0], + ['score' => '-1.9', 'rank' => 0], + ]); + + $this->assertSame([4, 3, 2, 1, 0], $data->sortBy([ + ['score', 'asc'], + ['rank', 'asc'], + ], SORT_NUMERIC)->keys()->all()); + + $this->assertSame([0, 2, 1, 3, 4], $data->sortBy([ + ['score', 'desc'], + ['rank', 'asc'], + ], SORT_NUMERIC)->keys()->all()); + } + #[DataProvider('collectionClassProvider')] public function testNaturalSortByManyWithNull($collection): void { From fa7957e335d751c23b384d468abacc8840f380ea Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 19:59:04 +0000 Subject: [PATCH 04/32] Complete Laravel's native casting update Replace the remaining intval and floatval calls in InteractsWithData's numeric getters and the file-size rule helper with native PHP casts. All other supported source and test changes from the upstream PR are already present; SQL Server remains intentionally unsupported. Give File::toKilobytes its exact native int|float return type while keeping the current upstream conditional return and exception annotations. Data retrieval, size rounding, invalid-suffix errors and typed size properties retain their existing behavior. Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. https://github.com/laravel/framework/pull/58037 Validation: existing request conversion and file-size boundary tests pass, along with the affected support tests, formatting and both PHPStan configurations. No new tests duplicate the existing conversion coverage. --- src/support/src/Traits/InteractsWithData.php | 4 ++-- src/validation/src/Rules/File.php | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index e703b10b2..c2ad30ce6 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -247,7 +247,7 @@ public function boolean(?string $key = null, bool $default = false): bool */ public function integer(string $key, int $default = 0): int { - return intval($this->data($key, $default)); + return (int) $this->data($key, $default); } /** @@ -255,7 +255,7 @@ public function integer(string $key, int $default = 0): int */ public function float(string $key, float $default = 0.0): float { - return floatval($this->data($key, $default)); + return (float) $this->data($key, $default); } /** diff --git a/src/validation/src/Rules/File.php b/src/validation/src/Rules/File.php index 1678d08a7..4f3cd3a8a 100644 --- a/src/validation/src/Rules/File.php +++ b/src/validation/src/Rules/File.php @@ -206,8 +206,12 @@ public function encoding(string $encoding): static /** * Convert a potentially human-friendly file size to kilobytes. + * + * @return ($size is int ? int : float|int) + * + * @throws InvalidArgumentException */ - protected function toKilobytes(int|string $size): mixed + protected function toKilobytes(int|string $size): int|float { if (! is_string($size)) { return $size; @@ -215,7 +219,7 @@ protected function toKilobytes(int|string $size): mixed $size = strtolower(trim($size)); - $value = floatval($size); + $value = (float) $size; return round(match (true) { Str::endsWith($size, 'kb') => $value * 1, From 1933da74cd6666c42ef1977b2204a154d01eb11c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:43:35 +0000 Subject: [PATCH 05/32] Fix mailable assertions for Htmlable text views Mailable delivery accepts Htmlable view values through Mailer::renderView(), but renderForAssertions() cast the same text value directly to string. Implementations with toHtml() and no __toString() sent successfully and then failed both HTML and text assertions. Use the existing Htmlable conversion when producing assertion text and describe the actual buildView() array values. Preserve the rendering cache and locale lifecycle. Add one regression using real delivery through ArrayTransport followed by HTML and text assertions. Discovered while porting https://github.com/laravel/framework/pull/60466 from Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2; current upstream shares the assertion defect. Verified the regression fails before the fix. Full source/type analysis, affected mail/locale/facade tests, and formatting pass. --- src/mail/src/Mailable.php | 7 +++++-- tests/Mail/MailMailableTest.php | 34 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index 5ac77245a..d67c72ac1 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -283,6 +283,8 @@ public function render(): string /** * Build the view for the message. * + * @return array|string + * * @throws ReflectionException */ protected function buildView(): array|string @@ -1423,7 +1425,7 @@ protected function renderForAssertions(): array return $this->assertionableRenderStrings; } - return $this->assertionableRenderStrings = $this->withLocale($this->locale, function () { + return $this->assertionableRenderStrings = $this->withLocale($this->locale, function (): array { $this->prepareMailableForDelivery(); /** @var \Hypervel\Mail\Mailer $mailer */ @@ -1448,7 +1450,8 @@ protected function renderForAssertions(): array ); } - return [(string) $html, (string) $text]; + // Match the mailer's conversion for Htmlable views, which need not be stringable. + return [(string) $html, $text instanceof Htmlable ? $text->toHtml() : (string) $text]; }); } diff --git a/tests/Mail/MailMailableTest.php b/tests/Mail/MailMailableTest.php index 0af516928..d1f5edd79 100644 --- a/tests/Mail/MailMailableTest.php +++ b/tests/Mail/MailMailableTest.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory; use Hypervel\Contracts\Mail\Attachable; use Hypervel\Contracts\Mail\Mailer as MailerContract; +use Hypervel\Contracts\Support\Htmlable; use Hypervel\Contracts\View\Factory as ViewFactory; use Hypervel\Contracts\View\View as ViewContract; use Hypervel\Filesystem\FilesystemAdapter; @@ -20,6 +21,7 @@ use Hypervel\Mail\Message; use Hypervel\Mail\Transport\ArrayTransport; use Hypervel\Support\ClassInvoker; +use Hypervel\Support\HtmlString; use Hypervel\Testbench\TestCase; use Mockery as m; use PHPUnit\Framework\AssertionFailedError; @@ -573,6 +575,38 @@ public function testMailableBuildsViewData(): void $this->assertSame($expected, $mailable->buildViewData()); } + public function testMailableAssertionsRenderHtmlableText(): void + { + $mailable = new class extends Mailable { + /** + * Build the view for the message. + */ + protected function buildView(): array + { + return [ + 'html' => new HtmlString('

HTML content

'), + 'text' => new class implements Htmlable { + /** + * Get content as a string of HTML. + */ + public function toHtml(): string + { + return 'Plain content'; + } + }, + ]; + } + }; + + $mailable->from('sender@example.com')->to('recipient@example.com'); + $mailer = new Mailer('array', $this->app->make(ViewFactory::class), new ArrayTransport); + $sentMessage = $mailer->send($mailable); + + $this->assertSame('Plain content', $sentMessage->getOriginalMessage()->getTextBody()); + + $mailable->assertSeeInHtml('HTML content')->assertSeeInText('Plain content'); + } + public function testMailerMayBeSet(): void { $mailable = new WelcomeMailableStub; From fc8f4f70addee09f6f9fb0945eae15905c11d914 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:43:56 +0000 Subject: [PATCH 06/32] Preserve callback return types for shared input and locale helpers Port the current generic return annotations for whenHas(), whenFilled(), whenMissing(), whenEnum(), and withLocale(), together with the complete upstream type fixture. Restore the missing ValidatedInput whenEnum regression and regenerate the Request facade annotations. Keep existing native signatures, precise enum conversion, conditional callback behavior, and coroutine-local locale restoration unchanged. Add native typing to the ported runtime test callbacks while preserving literal return inference in the type fixture. Laravel PRs: https://github.com/laravel/framework/pull/60466 https://github.com/laravel/framework/pull/60486 https://github.com/laravel/framework/pull/60507 https://github.com/laravel/framework/pull/60536 Port source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The new type fixture exposed the missing inference before these annotations. Full source/type analysis, affected Support/Http/mail/notification tests, facade consistency, and formatting pass. --- src/support/src/Facades/Request.php | 8 +-- src/support/src/Traits/InteractsWithData.php | 28 ++++++-- src/support/src/Traits/Localizable.php | 5 ++ tests/Support/ValidatedInputTest.php | 31 ++++++++ types/Support/Traits.php | 74 ++++++++++++++++++++ 5 files changed, 138 insertions(+), 8 deletions(-) create mode 100644 types/Support/Traits.php diff --git a/src/support/src/Facades/Request.php b/src/support/src/Facades/Request.php index 7812caea5..32c824474 100644 --- a/src/support/src/Facades/Request.php +++ b/src/support/src/Facades/Request.php @@ -195,10 +195,10 @@ * @method static bool wantsJson() * @method static bool wantsMarkdown() * @method static mixed when(mixed $value = null, null|callable $callback = null, null|callable $default = null) - * @method static mixed whenEnum(string $key, string $enumClass, callable $callback, callable|null $default = null) - * @method static mixed whenFilled(string $key, callable $callback, callable|null $default = null) - * @method static mixed whenHas(string $key, callable $callback, callable|null $default = null) - * @method static mixed whenMissing(string $key, callable $callback, callable|null $default = null) + * @method static mixed whenEnum(string $key, string $enumClass, callable $callback, null|callable $default = null) + * @method static mixed whenFilled(string $key, callable $callback, null|callable $default = null) + * @method static mixed whenHas(string $key, callable $callback, null|callable $default = null) + * @method static mixed whenMissing(string $key, callable $callback, null|callable $default = null) * * @see \Hypervel\Http\Request */ diff --git a/src/support/src/Traits/InteractsWithData.php b/src/support/src/Traits/InteractsWithData.php index c2ad30ce6..16bf85150 100644 --- a/src/support/src/Traits/InteractsWithData.php +++ b/src/support/src/Traits/InteractsWithData.php @@ -75,7 +75,12 @@ public function hasAny(array|string $keys): bool /** * Apply the callback if the instance contains the given key. * - * @return $this|mixed + * @template TReturn + * @template TReturnDefault = never + * + * @param callable(mixed): TReturn $callback + * @param null|(callable(): TReturnDefault) $default + * @return $this|TReturn|TReturnDefault */ public function whenHas(string $key, callable $callback, ?callable $default = null): mixed { @@ -141,7 +146,12 @@ public function anyFilled(array|string $keys): bool /** * Apply the callback if the instance contains a non-empty value for the given key. * - * @return $this|mixed + * @template TReturn + * @template TReturnDefault = never + * + * @param callable(mixed): TReturn $callback + * @param null|(callable(): TReturnDefault) $default + * @return $this|TReturn|TReturnDefault */ public function whenFilled(string $key, callable $callback, ?callable $default = null): mixed { @@ -160,10 +170,13 @@ public function whenFilled(string $key, callable $callback, ?callable $default = * Apply the callback if the instance contains a valid enum value for the given key. * * @template TEnum of \BackedEnum + * @template TReturn + * @template TReturnDefault = never * * @param class-string $enumClass - * @param callable(TEnum): mixed $callback - * @return $this|mixed + * @param callable(TEnum): TReturn $callback + * @param null|(callable(): TReturnDefault) $default + * @return $this|TReturn|TReturnDefault */ public function whenEnum(string $key, string $enumClass, callable $callback, ?callable $default = null): mixed { @@ -190,6 +203,13 @@ public function missing(array|string $key): bool /** * Apply the callback if the instance is missing the given key. + * + * @template TReturn + * @template TReturnDefault = never + * + * @param callable(mixed): TReturn $callback + * @param null|(callable(): TReturnDefault) $default + * @return $this|TReturn|TReturnDefault */ public function whenMissing(string $key, callable $callback, ?callable $default = null): mixed { diff --git a/src/support/src/Traits/Localizable.php b/src/support/src/Traits/Localizable.php index 30ccedb12..6503d631d 100644 --- a/src/support/src/Traits/Localizable.php +++ b/src/support/src/Traits/Localizable.php @@ -11,6 +11,11 @@ trait Localizable { /** * Run the callback with the given locale. + * + * @template TReturn + * + * @param Closure(): TReturn $callback + * @return TReturn */ public function withLocale(?string $locale, Closure $callback): mixed { diff --git a/tests/Support/ValidatedInputTest.php b/tests/Support/ValidatedInputTest.php index 4d1d4106f..cf0b9bc35 100644 --- a/tests/Support/ValidatedInputTest.php +++ b/tests/Support/ValidatedInputTest.php @@ -233,6 +233,37 @@ public function testWhenFilledMethod() $this->assertFalse($bar); } + public function testWhenEnumMethod(): void + { + $input = new ValidatedInput(['status' => 'Hello world', 'invalid' => 'invalid', 'age' => '']); + + $status = $invalid = $age = $missing = $default = false; + + $input->whenEnum('status', StringBackedEnum::class, function (StringBackedEnum $value) use (&$status): void { + $status = $value; + }); + + $input->whenEnum('invalid', StringBackedEnum::class, function (StringBackedEnum $value) use (&$invalid): void { + $invalid = $value; + }); + + $input->whenEnum('age', StringBackedEnum::class, function (StringBackedEnum $value) use (&$age): void { + $age = $value; + }); + + $input->whenEnum('missing', StringBackedEnum::class, function (StringBackedEnum $value) use (&$missing): void { + $missing = $value; + }, function () use (&$default): void { + $default = true; + }); + + $this->assertSame(StringBackedEnum::HelloWorld, $status); + $this->assertFalse($invalid); + $this->assertFalse($age); + $this->assertFalse($missing); + $this->assertTrue($default); + } + public function testMissingMethod() { $input = new ValidatedInput(['name' => 'Fatih', 'surname' => 'AYDIN', 'foo' => ['bar' => null, 'baz' => '']]); diff --git a/types/Support/Traits.php b/types/Support/Traits.php new file mode 100644 index 000000000..f26ad78ed --- /dev/null +++ b/types/Support/Traits.php @@ -0,0 +1,74 @@ +withLocale('en', fn () => 'foo')); + } +}; + +$interactsWithData = function (UriQueryString $query): void { + assertType('1|2|Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, function ($enum) { + assertType('TestIntEnum', $enum); + + return 1; + }, function () { + return 2; + })); + + assertType('3|Hypervel\Support\UriQueryString', $query->whenEnum('foo', TestIntEnum::class, function ($enum) { + return 3; + })); + + assertType('1|2|Hypervel\Support\UriQueryString', $query->whenHas('foo', function ($value) { + assertType('mixed', $value); + + return 1; + }, function () { + return 2; + })); + + assertType('3|Hypervel\Support\UriQueryString', $query->whenHas('foo', function ($value) { + return 3; + })); + + assertType('1|2|Hypervel\Support\UriQueryString', $query->whenFilled('foo', function ($value) { + assertType('mixed', $value); + + return 1; + }, function () { + return 2; + })); + + assertType('3|Hypervel\Support\UriQueryString', $query->whenFilled('foo', function ($value) { + return 3; + })); + + assertType('1|2|Hypervel\Support\UriQueryString', $query->whenMissing('foo', function ($value) { + assertType('mixed', $value); + + return 1; + }, function () { + return 2; + })); + + assertType('3|Hypervel\Support\UriQueryString', $query->whenMissing('foo', function ($value) { + return 3; + })); +}; + +enum TestIntEnum: int +{ +} From 07b8bf2414eaf5a1c2e511dfc4b764a7eaaf583b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:44:11 +0000 Subject: [PATCH 07/32] Complete Fluent input documentation and enum-array coverage The shared InteractsWithData implementation and Fluent retrieval tests from Laravel PR #53665 are already present. Document that Fluent input values provide the same typed retrieval methods as requests. Correct the singular fixture-key lookups in the Fluent and Request enum-array tests. They were reading absent keys and passing without checking invalid populated values or the non-backed enum case. Preserve the existing cases and add the required void return type to the touched Fluent test. Laravel PR: https://github.com/laravel/framework/pull/53665. Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Checked Laravel docs at 2914ba0b06c6be40c2f1f992555853f6266707d6; no matching Fluent explanation exists there. Both affected test files, the wider Support/Http checks, and formatting pass. --- src/docs/requests.md | 2 ++ tests/Http/HttpRequestTest.php | 4 ++-- tests/Support/SupportFluentTest.php | 6 +++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/docs/requests.md b/src/docs/requests.md index d3280a7ff..85281043d 100644 --- a/src/docs/requests.md +++ b/src/docs/requests.md @@ -471,6 +471,8 @@ You may also pass an array of keys to build the instance from only those input v $user = $request->fluent(['name', 'role']); ``` +Fluent instances also provide the input retrieval methods described on this page, such as `integer`, `boolean`, `date`, and `enum`. + #### Retrieving Date Input Values diff --git a/tests/Http/HttpRequestTest.php b/tests/Http/HttpRequestTest.php index 503e8fa7b..128febed1 100644 --- a/tests/Http/HttpRequestTest.php +++ b/tests/Http/HttpRequestTest.php @@ -1123,9 +1123,9 @@ public function testEnumsMethod(): void $this->assertEquals([TestEnumBacked::test, TestEnumBacked::test], $request->enums('valid_enum_values', TestEnumBacked::class)); - $this->assertEmpty($request->enums('invalid_enum_value', TestEnumBacked::class)); + $this->assertEmpty($request->enums('invalid_enum_values', TestEnumBacked::class)); $this->assertEmpty($request->enums('empty_value_request', TestEnumBacked::class)); - $this->assertEmpty($request->enums('valid_enum_value', TestEnum::class)); + $this->assertEmpty($request->enums('valid_enum_values', TestEnum::class)); $this->assertEquals([TestIntegerEnumBacked::minus_1, TestIntegerEnumBacked::zero], $request->enums('string.minus_1', TestIntegerEnumBacked::class)); $this->assertEquals([TestIntegerEnumBacked::zero], $request->enums('string.0', TestIntegerEnumBacked::class)); diff --git a/tests/Support/SupportFluentTest.php b/tests/Support/SupportFluentTest.php index ed85cd795..454eeffe2 100644 --- a/tests/Support/SupportFluentTest.php +++ b/tests/Support/SupportFluentTest.php @@ -422,7 +422,7 @@ public function testEnumMethod() $this->assertNull($fluent->enum('int.doesnt_exist', TestBackedEnum::class)); } - public function testEnumsMethod() + public function testEnumsMethod(): void { $fluent = new Fluent([ 'valid_enum_values' => ['A', 'B'], @@ -444,9 +444,9 @@ public function testEnumsMethod() $this->assertEquals([TestStringBackedEnum::A, TestStringBackedEnum::B], $fluent->enums('valid_enum_values', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('invalid_enum_value', TestStringBackedEnum::class)); + $this->assertEmpty($fluent->enums('invalid_enum_values', TestStringBackedEnum::class)); $this->assertEmpty($fluent->enums('empty_value_request', TestStringBackedEnum::class)); - $this->assertEmpty($fluent->enums('valid_enum_value', TestEnum::class)); + $this->assertEmpty($fluent->enums('valid_enum_values', TestEnum::class)); $this->assertEquals([TestBackedEnum::A, TestBackedEnum::B], $fluent->enums('string.a', TestBackedEnum::class)); $this->assertEquals([TestBackedEnum::B], $fluent->enums('string.b', TestBackedEnum::class)); From 7de9299ad8ed4f9251199adf5831e22aff5fa144 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:13:14 +0000 Subject: [PATCH 08/32] Complete renderer query coverage and preserve displayed binding values Port the complete current listener tests from Laravel PRs #58040 and #59309, preserving Hypervel coroutine-local query storage and the existing multibyte regression. Merge the query-cap case into its upstream test location, including exact call counts and first/last retained queries. Correct the upstream truncation fixture: 500 placeholders never reach the 2000-byte limit. Use 1000 and assert the actual truncated length as well as the retained binding count. Fix the adjacent applicationQueries display bug with Str::replaceArray(): repeated regex substitutions consumed backreference-like value text and replaced question marks inside earlier bindings. Preserve number/null/string formatting without adding a parser, connection access or retained state. Correct nullable connection-name/time annotations and add one real-renderer regression. Laravel PRs: https://github.com/laravel/framework/pull/58040 https://github.com/laravel/framework/pull/59309. Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Current upstream shares both the ineffective fixture and display corruption. Regression fails before the fix; affected test files, complete renderer suite, full source/type analysis and formatting pass. --- .../src/Exceptions/Renderer/Exception.php | 24 ++- .../src/Exceptions/Renderer/Listener.php | 2 +- .../Exceptions/Renderer/ExceptionTest.php | 46 ++++++ .../Renderer/ListenerContextIsolationTest.php | 17 --- .../Exceptions/Renderer/ListenerTest.php | 143 +++++++++++++++++- 5 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 tests/Foundation/Exceptions/Renderer/ExceptionTest.php diff --git a/src/foundation/src/Exceptions/Renderer/Exception.php b/src/foundation/src/Exceptions/Renderer/Exception.php index 1209eb521..0d76a7a67 100644 --- a/src/foundation/src/Exceptions/Renderer/Exception.php +++ b/src/foundation/src/Exceptions/Renderer/Exception.php @@ -10,6 +10,7 @@ use Hypervel\Foundation\Bootstrap\HandleExceptions; use Hypervel\Http\Request; use Hypervel\Support\Collection; +use Hypervel\Support\Str; use Symfony\Component\ErrorHandler\Exception\FlattenException; class Exception @@ -226,25 +227,22 @@ public function applicationRouteParametersContext(): ?string /** * Get the application's SQL queries. * - * @return array + * @return array */ public function applicationQueries(): array { - return array_map(function (array $query) { - $sql = $query['sql']; - - foreach ($query['bindings'] as $binding) { - $sql = match (gettype($binding)) { - 'integer', 'double' => preg_replace('/\?/', (string) $binding, $sql, 1), - 'NULL' => preg_replace('/\?/', 'NULL', $sql, 1), - default => preg_replace('/\?/', "'{$binding}'", $sql, 1), - }; - } - + return array_map(function (array $query): array { + $bindings = array_map(static fn (mixed $binding): string => match (gettype($binding)) { + 'integer', 'double' => (string) $binding, + 'NULL' => 'NULL', + default => "'{$binding}'", + }, $query['bindings']); + + // Fill original placeholders so question marks inside values are not replaced again. return [ 'connectionName' => $query['connectionName'], 'time' => $query['time'], - 'sql' => $sql, + 'sql' => Str::replaceArray('?', $bindings, $query['sql']), ]; }, $this->listener->queries()); } diff --git a/src/foundation/src/Exceptions/Renderer/Listener.php b/src/foundation/src/Exceptions/Renderer/Listener.php index bb8ba99f8..6f717f2e0 100644 --- a/src/foundation/src/Exceptions/Renderer/Listener.php +++ b/src/foundation/src/Exceptions/Renderer/Listener.php @@ -31,7 +31,7 @@ public function registerListeners(Dispatcher $events): void /** * Return the queries that have been executed. * - * @return array + * @return array */ public function queries(): array { diff --git a/tests/Foundation/Exceptions/Renderer/ExceptionTest.php b/tests/Foundation/Exceptions/Renderer/ExceptionTest.php new file mode 100644 index 000000000..c65374bbe --- /dev/null +++ b/tests/Foundation/Exceptions/Renderer/ExceptionTest.php @@ -0,0 +1,46 @@ +shouldReceive('getName')->once()->andReturn(null); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + $listener = new Listener; + $listener->onQueryExecuted(new QueryExecuted( + 'select * from t where a = ? and b = ? and c = ? and d = ? and e = ?', + ['$1 off?', 'next \1', 7, 1.5, null], + null, + $connection, + )); + + $exception = new Exception( + FlattenException::createFromThrowable(new RuntimeException('Example exception.')), + Request::create('/'), + $listener, + __DIR__, + ); + + $this->assertSame([[ + 'connectionName' => null, + 'time' => null, + 'sql' => "select * from t where a = '$1 off?' and b = 'next \\1' and c = 7 and d = 1.5 and e = NULL", + ]], $exception->applicationQueries()); + } +} diff --git a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php index 634aeacc5..346b84fcf 100644 --- a/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php +++ b/tests/Foundation/Exceptions/Renderer/ListenerContextIsolationTest.php @@ -14,23 +14,6 @@ class ListenerContextIsolationTest extends TestCase { - public function testQueryCapStopsAtMaxQueries(): void - { - $listener = new Listener; - - $connection = m::mock(Connection::class); - $connection->shouldReceive('getName')->andReturn('testing'); - $connection->shouldReceive('prepareBindings')->andReturn([]); - - for ($i = 0; $i < 110; ++$i) { - $listener->onQueryExecuted( - new QueryExecuted("SELECT {$i}", [], 1.0, $connection) - ); - } - - $this->assertCount(100, $listener->queries()); - } - public function testQueriesAreIsolatedBetweenCoroutines(): void { $results = parallel([ diff --git a/tests/Foundation/Exceptions/Renderer/ListenerTest.php b/tests/Foundation/Exceptions/Renderer/ListenerTest.php index 330507bfa..0de4e32ad 100644 --- a/tests/Foundation/Exceptions/Renderer/ListenerTest.php +++ b/tests/Foundation/Exceptions/Renderer/ListenerTest.php @@ -12,12 +12,12 @@ class ListenerTest extends TestCase { - public function testQueriesReturnsExpectedShapeAfterQueryExecuted() + public function testQueriesReturnsExpectedShapeAfterQueryExecuted(): void { $connection = m::mock(Connection::class); - $connection->shouldReceive('getName')->andReturn('testing'); - $connection->shouldReceive('prepareBindings')->with(['foo'])->andReturn(['foo']); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->with(['foo'])->andReturn(['foo']); $event = new QueryExecuted('select * from users where id = ?', ['foo'], 5.2, $connection); @@ -37,12 +37,143 @@ public function testQueriesReturnsExpectedShapeAfterQueryExecuted() $this->assertArrayHasKey('sql', $query); $this->assertArrayHasKey('bindings', $query); - $this->assertEquals('testing', $query['connectionName']); - $this->assertEquals(5.2, $query['time']); - $this->assertEquals('select * from users where id = ?', $query['sql']); + $this->assertSame('testing', $query['connectionName']); + $this->assertSame(5.2, $query['time']); + $this->assertSame('select * from users where id = ?', $query['sql']); $this->assertEquals(['foo'], $query['bindings']); } + public function testListenerCapsAt100Queries(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->times(150)->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->times(100)->andReturnUsing(fn (array $bindings): array => $bindings); + + for ($index = 0; $index < 150; ++$index) { + $listener->onQueryExecuted( + new QueryExecuted("select {$index}", [], 1.0, $connection) + ); + } + + $this->assertCount(100, $listener->queries()); + $this->assertSame('select 0', $listener->queries()[0]['sql']); + $this->assertSame('select 99', $listener->queries()[99]['sql']); + } + + public function testLargeSqlIsTruncated(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + $largeSql = str_repeat('x', 5000); + $listener->onQueryExecuted( + new QueryExecuted($largeSql, [], 1.0, $connection) + ); + + $this->assertLessThanOrEqual(2000, strlen($listener->queries()[0]['sql'])); + } + + public function testBindingsMatchPlaceholderCountInTruncatedSql(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + // Build SQL with 1000 placeholders so truncation to 2000 bytes removes + // some placeholders and their corresponding bindings. + $placeholders = implode(', ', array_fill(0, 1000, '?')); + $sql = "INSERT INTO t (a) VALUES ({$placeholders})"; + $bindings = array_fill(0, 1000, 'value'); + + $listener->onQueryExecuted( + new QueryExecuted($sql, $bindings, 1.0, $connection) + ); + + $storedQuery = $listener->queries()[0]; + $storedPlaceholders = substr_count($storedQuery['sql'], '?'); + + $this->assertSame(2000, strlen($storedQuery['sql'])); + $this->assertCount($storedPlaceholders, $storedQuery['bindings']); + } + + public function testExcessBindingsAreTrimmedToMatchPlaceholders(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + // 1 placeholder but 1000 bindings — only 1 binding should be kept + $listener->onQueryExecuted( + new QueryExecuted('select ?', array_fill(0, 1000, 'v'), 1.0, $connection) + ); + + $this->assertCount(1, $listener->queries()[0]['bindings']); + } + + public function testShortSqlAndBindingsAreNotModified(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + $sql = 'select * from users where name = ?'; + $listener->onQueryExecuted( + new QueryExecuted($sql, ['John'], 1.0, $connection) + ); + + $this->assertEquals($sql, $listener->queries()[0]['sql']); + $this->assertEquals(['John'], $listener->queries()[0]['bindings']); + } + + public function testQueryWithNoBindingsIsUnchanged(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + $listener->onQueryExecuted( + new QueryExecuted('select count(*) from users', [], 1.0, $connection) + ); + + $this->assertSame('select count(*) from users', $listener->queries()[0]['sql']); + $this->assertEmpty($listener->queries()[0]['bindings']); + } + + public function testNormalQuerySkipsTruncation(): void + { + $listener = new Listener; + + $connection = m::mock(Connection::class); + $connection->shouldReceive('getName')->once()->andReturn('testing'); + $connection->shouldReceive('prepareBindings')->once()->andReturnUsing(fn (array $bindings): array => $bindings); + + $sql = 'select * from users where id = ? and name = ? and email = ?'; + $bindings = [1, 'John', 'john@example.com']; + + $listener->onQueryExecuted( + new QueryExecuted($sql, $bindings, 1.0, $connection) + ); + + $storedQuery = $listener->queries()[0]; + + // Nothing should be modified — SQL is short and bindings match placeholders + $this->assertEquals($sql, $storedQuery['sql']); + $this->assertEquals($bindings, $storedQuery['bindings']); + } + public function testLongQueriesAndBindingsAreBounded(): void { $connection = m::mock(Connection::class); From e94df799924ebef28afeef14d3f519071492afbe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:13:31 +0000 Subject: [PATCH 09/32] Document enum cases in translation replacements Complete the public documentation for Laravel PR #58048. Explain backed values, pure case names and the precedence of custom formatting handlers, while distinguishing other objects that use __toString(). The translator implementation, three enum fixtures and all upstream replacement assertions were already present and verified. Pinned Laravel localization documentation has no matching enum explanation, so add concise guidance beside the existing replacement examples. Laravel PR: https://github.com/laravel/framework/pull/58048. Framework source: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2; docs source checked: 2914ba0b06c6be40c2f1f992555853f6266707d6. Existing translation tests pass; prose verified against formatter precedence and reviewed. --- src/docs/localization.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/docs/localization.md b/src/docs/localization.md index f61c3270d..d5a0740c0 100644 --- a/src/docs/localization.md +++ b/src/docs/localization.md @@ -233,10 +233,12 @@ If your placeholder contains all capital letters, or only has its first letter c 'goodbye' => 'Goodbye, :Name', // Goodbye, Dayle ``` +You may also pass enum cases as replacements. Backed enums use their value and pure enums use their case name, unless you register a custom formatting handler for the enum as described below. + #### Object Replacement Formatting -If you attempt to provide an object as a translation placeholder, the object's `__toString` method will be invoked. The [__toString](https://www.php.net/manual/en/language.oop5.magic.php#object.tostring) method is one of PHP's built-in "magic methods". However, sometimes you may not have control over the `__toString` method of a given class, such as when the class that you are interacting with belongs to a third-party library. +If you attempt to provide any other object as a translation placeholder, the object's `__toString` method will be invoked. The [__toString](https://www.php.net/manual/en/language.oop5.magic.php#object.tostring) method is one of PHP's built-in "magic methods". However, sometimes you may not have control over the `__toString` method of a given class, such as when the class that you are interacting with belongs to a third-party library. In these cases, Hypervel allows you to register a custom formatting handler for that particular type of object. To accomplish this, you should invoke the translator's `stringable` method. The `stringable` method accepts a closure, which should type-hint the type of object that it is responsible for formatting. Typically, the `stringable` method should be invoked within the `boot` method of your application's `AppServiceProvider` class: From 0237c87290d9f7b5b2cdd8dd6ee2728aff1a0605 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:38:40 +0000 Subject: [PATCH 10/32] Document HTTP request attributes for fake responses Complete the public documentation for Laravel PR #58054. Explain how withAttributes distinguishes requests to one URL, and show fake response selection through Request::attributes(). Attributes remain local to the client and are also available to request assertions. The existing source and current upstream integration cases already cover this behavior. Checked the example against those paths; the peer independently executed the documented example and verified assertion visibility. The HTTP integration tests pass. No runtime changes or additional tests. Upstream: https://github.com/laravel/framework/pull/58054 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The pinned Laravel documentation contains no corresponding HTTP attributes section. --- src/docs/http-client.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index df115a61e..e6ad28aac 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -1080,6 +1080,24 @@ Http::fake(function (Request $request) { }); ``` + +#### Request Attributes + +To distinguish requests sent to the same URL, you may attach attributes using the `withAttributes` method. These attributes are available to fake callbacks and request assertions through the request's `attributes` method and are not sent to the remote server: + +```php +use Hypervel\Http\Client\Request; +use Hypervel\Support\Facades\Http; + +Http::fake(fn (Request $request) => match ($request->attributes()['name'] ?? null) { + 'products' => Http::response(['products' => []]), + default => Http::response(), +}); + +$response = Http::withAttributes(['name' => 'products']) + ->get('https://example.com/graphql'); +``` + ### Inspecting Requests From 43b5054e0a13592aae18424ff36a8a54204f10e6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:38:40 +0000 Subject: [PATCH 11/32] Complete split-package upstream references and DI license Add minimal READMEs for reflection, conditionable and macroable so future ports can identify their Laravel source. The reflection implementation, Composer wiring and license already cover the package split from Laravel PRs #58052 and #58055. Add the missing DI README linking to its AOP documentation and copy the existing sibling license with Hyperf and Hypervel attribution. The DI package history confirms its Hyperf origin; its README follows the current rule for independently maintained packages without an ongoing upstream reference. Reviewed all package paths, documentation links and README conventions. Verified the DI license is identical to the pool package license. Documentation and license files only; no runtime behavior or test changes. Upstream: https://github.com/laravel/framework/pull/58052 https://github.com/laravel/framework/pull/58055 Source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. --- src/conditionable/README.md | 6 ++++++ src/di/LICENSE.md | 23 +++++++++++++++++++++++ src/di/README.md | 6 ++++++ src/macroable/README.md | 6 ++++++ src/reflection/README.md | 6 ++++++ 5 files changed, 47 insertions(+) create mode 100644 src/conditionable/README.md create mode 100644 src/di/LICENSE.md create mode 100644 src/di/README.md create mode 100644 src/macroable/README.md create mode 100644 src/reflection/README.md diff --git a/src/conditionable/README.md b/src/conditionable/README.md new file mode 100644 index 000000000..2104ef529 --- /dev/null +++ b/src/conditionable/README.md @@ -0,0 +1,6 @@ +Conditionable for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/conditionable) + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Conditionable diff --git a/src/di/LICENSE.md b/src/di/LICENSE.md new file mode 100644 index 000000000..63e1b7f54 --- /dev/null +++ b/src/di/LICENSE.md @@ -0,0 +1,23 @@ +The MIT License (MIT) + +Copyright (c) Hyperf + +Copyright (c) Hypervel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/src/di/README.md b/src/di/README.md new file mode 100644 index 000000000..43deb2319 --- /dev/null +++ b/src/di/README.md @@ -0,0 +1,6 @@ +DI for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/di) + +Documentation: https://hypervel.org/docs/aop diff --git a/src/macroable/README.md b/src/macroable/README.md new file mode 100644 index 000000000..60b8bca7b --- /dev/null +++ b/src/macroable/README.md @@ -0,0 +1,6 @@ +Macroable for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/macroable) + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Macroable diff --git a/src/reflection/README.md b/src/reflection/README.md new file mode 100644 index 000000000..9d0e89993 --- /dev/null +++ b/src/reflection/README.md @@ -0,0 +1,6 @@ +Reflection for Hypervel +=== + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/reflection) + +Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Reflection From 0396460965b294101002901b96e01ae4bfd59ab3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:43:25 +0000 Subject: [PATCH 12/32] Port HTTP client sync and async return-type inference Track PendingRequest's async state in PHPDoc so requests created by the factory infer Response, async requests infer PromiseInterface, and the fluent setter updates both its result and a separately held receiver. Port the current upstream type fixture, including QUERY coverage, and regenerate the Http facade. Remove Saloon's now-redundant response narrowing. Use a conservative bool template default instead of false: unannotated subclasses and unknown-state references must retain an honest union. Record fresh synchronous construction explicitly on Factory's two creation methods. Keep native static return types and static annotations to preserve subclass identity. Bare facade fluent entry points retain their existing union precision; no generator machinery or runtime transport changes are needed. PHPStan can carry self-out state between identical factory-call expressions even when they create different objects. Preserve the correct contracts and explain the independent fixture scope; the limitation and reproduction are recorded for upstream reporting. Dropping self-out would instead mis-type held requests after async() calls. Upstream: https://github.com/laravel/framework/pull/58090 https://github.com/laravel/framework/pull/58232 https://github.com/laravel/framework/pull/58684 https://github.com/laravel/framework/pull/60663 Source: Laravel 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. QUERY source, runtime tests and public documentation were already present; this completes its conditional return annotation and type-test coverage. Validated with full source/type analysis, HTTP and Saloon tests, facade generation/tag checks, formatting and diff checks. --- src/http/src/Client/Factory.php | 9 ++++- src/http/src/Client/PendingRequest.php | 29 ++++++++++++++++ src/saloon/src/Http/Sender.php | 2 -- src/support/src/Facades/Http.php | 20 +++++------ types/Http/Client/PendingRequest.php | 48 ++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 13 deletions(-) create mode 100644 types/Http/Client/PendingRequest.php diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 776f3de05..5a786540c 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -543,6 +543,8 @@ public function recorded(?callable $callback = null): Collection /** * Create a new pending request instance for this factory. + * + * @return PendingRequest */ public function createPendingRequest(): PendingRequest { @@ -556,6 +558,8 @@ public function createPendingRequest(): PendingRequest /** * Instantiate a new pending request instance for this factory. + * + * @return PendingRequest */ protected function newPendingRequest(): PendingRequest { @@ -565,7 +569,10 @@ protected function newPendingRequest(): PendingRequest throw new InvalidArgumentException('The global HTTP client options callback must return an array.'); } - return new PendingRequest($this, $this->globalMiddleware, $options); + /** @var PendingRequest $request */ + $request = new PendingRequest($this, $this->globalMiddleware, $options); + + return $request; } /** diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 6ee896ad0..d36ed7df0 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -41,6 +41,9 @@ use Throwable; use UnitEnum; +/** + * @template TAsync of bool = bool + */ class PendingRequest implements Transient { use Conditionable; @@ -180,6 +183,8 @@ class PendingRequest implements Transient /** * Whether the requests should be asynchronous. + * + * @var TAsync */ protected bool $async = false; @@ -772,6 +777,8 @@ public function dd(): static /** * Issue a GET request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -789,6 +796,8 @@ public function get(string $url, Arrayable|array|JsonSerializable|string|null $q /** * Issue a HEAD request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -806,6 +815,8 @@ public function head(string $url, Arrayable|array|JsonSerializable|string|null $ /** * Issue a QUERY request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -819,6 +830,8 @@ public function query(string $url, Arrayable|array|JsonSerializable $data = []): /** * Issue a POST request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -832,6 +845,8 @@ public function post(string $url, Arrayable|array|JsonSerializable $data = []): /** * Issue a PATCH request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -845,6 +860,8 @@ public function patch(string $url, Arrayable|array|JsonSerializable $data = []): /** * Issue a PUT request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -858,6 +875,8 @@ public function put(string $url, Arrayable|array|JsonSerializable $data = []): P /** * Issue a DELETE request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws ConnectionException * @throws InvalidArgumentException */ @@ -880,6 +899,8 @@ public function delete(string $url, Arrayable|array|JsonSerializable $data = []) /** * Send the request to the given URL. * + * @phpstan-return (TAsync is false ? Response : PromiseInterface) + * * @throws Exception * @throws ConnectionException|Throwable * @throws InvalidArgumentException @@ -1918,11 +1939,19 @@ public function isAllowedRequestUrl(string $url): bool /** * Toggle asynchronicity in requests. + * + * @template T of bool = true + * + * @param T $async + * @return static + * + * @phpstan-self-out static */ public function async(bool $async = true): static { $this->async = $async; + // @phpstan-ignore return.type (The fluent setter returns the same receiver with its new generic state.) return $this; } diff --git a/src/saloon/src/Http/Sender.php b/src/saloon/src/Http/Sender.php index fdbf4cd0d..8fd295da5 100644 --- a/src/saloon/src/Http/Sender.php +++ b/src/saloon/src/Http/Sender.php @@ -7,7 +7,6 @@ use Hypervel\Contracts\Config\Repository as ConfigRepository; use Hypervel\Contracts\Telescope\TelescopeTag; use Hypervel\Http\Client\Factory; -use Hypervel\Http\Client\Response as HttpResponse; use Psr\Http\Message\RequestInterface; class Sender @@ -74,7 +73,6 @@ public function send(PendingRequest $pendingRequest, array $transport): Response return $request; }); - /** @var HttpResponse $httpResponse */ $httpResponse = $httpRequest->send( $pendingRequest->method()->value, (string) $pendingRequest->uri(), diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php index c6875b5d6..dab43262d 100644 --- a/src/support/src/Facades/Http.php +++ b/src/support/src/Facades/Http.php @@ -18,7 +18,7 @@ * @method static void assertSentInOrder(array $callbacks) * @method static void assertSequencesAreEmpty() * @method static \GuzzleHttp\ClientInterface createClient(\GuzzleHttp\HandlerStack $handlerStack, \GuzzleHttp\Cookie\CookieJar $cookies) - * @method static \Hypervel\Http\Client\PendingRequest createPendingRequest() + * @method static \Hypervel\Http\Client\PendingRequest createPendingRequest() * @method static \Closure failedConnection(string|null $message = null) * @method static \Hypervel\Http\Client\RequestException failedRequest(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static void flushMacros() @@ -54,7 +54,7 @@ * @method static \Hypervel\Http\Client\PendingRequest asForm() * @method static \Hypervel\Http\Client\PendingRequest asJson() * @method static \Hypervel\Http\Client\PendingRequest asMultipart() - * @method static \Hypervel\Http\Client\PendingRequest async(bool $async = true) + * @method static \Hypervel\Http\Client\PendingRequest async(bool $async = true) * @method static \Hypervel\Http\Client\PendingRequest attach(array|string $name, resource|string $contents = '', string|null $filename = null, array $headers = []) * @method static array attributes() * @method static \Hypervel\Http\Client\PendingRequest baseUrl(string $url) @@ -69,27 +69,27 @@ * @method static \Hypervel\Http\Client\PendingRequest connectTimeout(int|float $seconds) * @method static \Hypervel\Http\Client\PendingRequest contentType(string $contentType) * @method static \Hypervel\Http\Client\PendingRequest dd() - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response delete(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface delete(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) * @method static \Hypervel\Http\Client\PendingRequest dontTruncateExceptions() * @method static \Hypervel\Http\Client\PendingRequest dump() - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response get(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface get(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null) * @method static string|null getConnection() * @method static array getOptions() * @method static \GuzzleHttp\Promise\PromiseInterface|null getPromise() - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response head(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface head(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array|string|null $query = null) * @method static bool isAllowedRequestUrl(string $url) * @method static \Hypervel\Http\Client\PendingRequest maxRedirects(int $max) * @method static array mergeOptions(mixed ...$options) - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response patch(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response post(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface patch(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface post(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) * @method static \Hypervel\Http\Client\PendingRequest prependMiddleware(callable $middleware) * @method static \GuzzleHttp\HandlerStack pushHandlers(\GuzzleHttp\HandlerStack $handlerStack) - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response put(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response query(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface put(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface query(string $url, \Hypervel\Contracts\Support\Arrayable|\JsonSerializable|array $data = []) * @method static \Hypervel\Http\Client\PendingRequest replaceHeaders(array $headers) * @method static \Hypervel\Http\Client\PendingRequest retry(array|int $times, \Closure|int $sleepMilliseconds = 0, null|callable $when = null, bool $throw = true) * @method static \Psr\Http\Message\RequestInterface runBeforeSendingCallbacks(\Psr\Http\Message\RequestInterface $request, array $options) - * @method static \GuzzleHttp\Promise\PromiseInterface|\Hypervel\Http\Client\Response send(string $method, string $url, array $options = []) + * @method static \Hypervel\Http\Client\Response|\GuzzleHttp\Promise\PromiseInterface send(string $method, string $url, array $options = []) * @method static \Hypervel\Http\Client\PendingRequest setClient(\GuzzleHttp\ClientInterface $client) * @method static \Hypervel\Http\Client\PendingRequest setHandler(callable $handler) * @method static \Hypervel\Http\Client\PendingRequest sink(resource|\Psr\Http\Message\StreamInterface|string $to) diff --git a/types/Http/Client/PendingRequest.php b/types/Http/Client/PendingRequest.php new file mode 100644 index 000000000..702543f2c --- /dev/null +++ b/types/Http/Client/PendingRequest.php @@ -0,0 +1,48 @@ +{$method}('/foo')); + assertType('GuzzleHttp\Promise\PromiseInterface', Http::createPendingRequest()->async()->{$method}('/foo')); +} + +// PHPStan carries async()'s self-out type onto repeated Http::createPendingRequest() +// expressions in the same scope, although each call creates a fresh request. +// Keep these state checks separate from the loop's inferred async state. +function (bool $async): void { + assertType('Hypervel\Http\Client\Response', Http::createPendingRequest()->withHeaders([])->get('/foo')); + assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::async()->get('/foo')); + + $request = Http::createPendingRequest(); + assertType('Hypervel\Http\Client\Response', $request->send('GET', '/foo')); + $request->async(); + assertType('GuzzleHttp\Promise\PromiseInterface', $request->send('GET', '/foo')); + $request->async(false); + assertType('Hypervel\Http\Client\Response', $request->get('/foo')); + + assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::createPendingRequest()->async($async)->get('/foo')); +}; + +class PlainHttpPendingRequest extends PendingRequest +{ +} + +assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', (new PlainHttpPendingRequest)->async()->get('/foo')); + +/** + * @template TAsync of bool = bool + * @extends PendingRequest + */ +class GenericHttpPendingRequest extends PendingRequest +{ +} + +$genericRequest = (new GenericHttpPendingRequest)->async(); +assertType('GenericHttpPendingRequest', $genericRequest); +assertType('GuzzleHttp\Promise\PromiseInterface', $genericRequest->get('/foo')); From 4b070c83e06d5c7f06d34c77e61c0a4c6d81ebfb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:43:39 +0000 Subject: [PATCH 13/32] Correct the JSON:API compound resource type example BlogPostResource resolves to blog_posts through snake() and pluralStudly(), not blog-posts. Correct the example inherited from Laravel's resource docs so it matches both frameworks' resource type derivation. Found while completing the usage-improvements port: https://github.com/laravel/framework/pull/57960 Checked Laravel docs at 2914ba0b06c6be40c2f1f992555853f6266707d6 and the current Hypervel resolveResourceType() path. Documentation-only correction; resource behavior is unchanged. --- src/docs/eloquent-resources.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md index fa13b3eda..4ab568f76 100644 --- a/src/docs/eloquent-resources.md +++ b/src/docs/eloquent-resources.md @@ -1199,7 +1199,7 @@ JsonApiResource::maxRelationshipDepth(3); ### Resource Type and ID -By default, the resource's `type` is derived from the resource class name. For example, `PostResource` produces the type `posts` and `BlogPostResource` produces `blog-posts`. The resource's `id` is resolved from the model's primary key. +By default, the resource's `type` is derived from the resource class name. For example, `PostResource` produces the type `posts` and `BlogPostResource` produces `blog_posts`. The resource's `id` is resolved from the model's primary key. If you need to customize these values, you may override the `toType` and `toId` methods on your resource: From 3550c9d267a680d6c1766d81f049f514c316f8fb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:24:25 +0000 Subject: [PATCH 14/32] Fix string attributes in collection visibility merges Complete Laravel PR #58110 using the current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Collection mergeHidden() and mergeVisible() advertise string inputs but forwarded them unchanged to model methods requiring arrays. Normalize once at the collection boundary, preserving the model contracts and existing collection behavior. Extend both upstream tests with string cases while retaining their array assertions. The new cases reproduce the TypeErrors before the fix and pass afterwards. Restore the required parent setup call. Validated the complete collection test class, formatting and full source/type analysis. Upstream: https://github.com/laravel/framework/pull/58110 --- src/database/src/Eloquent/Collection.php | 4 ++-- .../DatabaseEloquentCollectionTest.php | 24 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index bd20cb174..285f2aede 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -653,7 +653,7 @@ public function makeHidden(array|string $attributes): static public function mergeHidden(array|string $attributes): static { // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) - return $this->each->mergeHidden($attributes); + return $this->each->mergeHidden((array) $attributes); } /** @@ -686,7 +686,7 @@ public function makeVisible(array|string $attributes): static public function mergeVisible(array|string $attributes): static { // @phpstan-ignore return.type (HigherOrderProxy returns $this, not TModel) - return $this->each->mergeVisible($attributes); + return $this->each->mergeVisible((array) $attributes); } /** diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index dbac3cf7e..745ad12b1 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -15,6 +15,7 @@ use Hypervel\Tests\TestCase; use LogicException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; class DatabaseEloquentCollectionTest extends TestCase @@ -24,6 +25,8 @@ class DatabaseEloquentCollectionTest extends TestCase */ protected function setUp(): void { + parent::setUp(); + $db = new DB; $db->addConnection([ @@ -731,22 +734,35 @@ public function testMakeVisibleRemovesHiddenFromEntireCollection() $this->assertEquals([], $c[0]->getHidden()); } - public function testMergeHiddenAddsHiddenOnEntireCollection() + #[DataProvider('mergeAttributesProvider')] + public function testMergeHiddenAddsHiddenOnEntireCollection(array|string $attributes): void { $c = new Collection([new CollectionModel]); - $c = $c->mergeHidden(['merged']); + $c = $c->mergeHidden($attributes); $this->assertEquals(['hidden', 'merged'], $c[0]->getHidden()); } - public function testMergeVisibleRemovesHiddenFromEntireCollection() + #[DataProvider('mergeAttributesProvider')] + public function testMergeVisibleRemovesHiddenFromEntireCollection(array|string $attributes): void { $c = new Collection([new CollectionModel]); - $c = $c->mergeVisible(['merged']); + $c = $c->mergeVisible($attributes); $this->assertEquals(['visible', 'merged'], $c[0]->getVisible()); } + /** + * Provide attributes to merge across the collection. + */ + public static function mergeAttributesProvider(): array + { + return [ + 'array' => [['merged']], + 'string' => ['merged'], + ]; + } + public function testSetVisibleReplacesVisibleOnEntireCollection() { $c = new Collection([new CollectionModel]); From 117cf03b0ad2338209366205cec6d39c7ce4f6ca Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:24:35 +0000 Subject: [PATCH 15/32] Complete HTTP response tap and macro coverage Complete Laravel PR #58115 from current 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Restore the upstream tap regression through a faked factory request and its captured response value. Preserve the existing Hypervel chaining test, and restore the upstream macro result type assertion. Add one discoverability sentence linking the HTTP response API to the existing tap documentation. The pinned Laravel docs have no HTTP-specific tap coverage; reuse the canonical helper explanation instead of duplicating it. Validated the full HTTP client test class, formatting and full source/type analysis. Upstream: https://github.com/laravel/framework/pull/58115 --- src/docs/http-client.md | 2 ++ tests/Http/HttpClientTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index e6ad28aac..8991740c9 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -66,6 +66,8 @@ $response->handlerStats() : array; $response->toPsrResponse() : Psr\Http\Message\ResponseInterface; ``` +You may also use the response's [tap method](/docs/{{version}}/helpers#method-tap) to inspect it without interrupting a method chain. + The `Hypervel\Http\Client\Response` object also implements the PHP `ArrayAccess` interface, allowing you to access JSON response data directly on the response: ```php diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index b15c0cae4..5b81fb82a 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -590,6 +590,21 @@ public function testDecodeUsingTakesPrecedenceOverJsonFlags(): void } public function testResponseObjectIsTappable(): void + { + $bar = null; + $this->factory->fake([ + '*' => ['result' => ['foo' => 'bar']], + ]); + + $this->factory->get('http://foo.com/api') + ->tap(function (Response $response) use (&$bar) { + $bar = $response['result']['foo']; + }); + + $this->assertSame('bar', $bar); + } + + public function testResponseTapKeepsResponseAvailableForChaining(): void { $response = new Response($this->factory::psr7Response(['foo' => 'bar'])); @@ -618,6 +633,7 @@ public function testResponseObjectIsMacroable(): void $response = $this->factory->get('http://www.omdbapi.com/?apikey=test_api_key&i=test_imdb_id'); + $this->assertIsArray($response->movieFields()); $this->assertSame([ 'title' => 'The Godfather', 'year' => 1972, From 7e0169227fd6174c19f527af8312ee4bdad2843f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:24:49 +0000 Subject: [PATCH 16/32] Update the release action and correct 0.4 version preparation Port Laravel PR #58118 with the current action revision from #60672, using 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Pin git-auto-commit-action v7.2.0 to the exact reviewed commit. The other upstream auto-commit workflows and Laravel Cloud comment change have no corresponding Hypervel integration. Match the typed Application VERSION constant so version replacement actually takes effect. Pass the version through the environment instead of interpolating it into shell source, and verify the resulting literal so an unmatched replacement aborts before tagging. Repeating an already-applied version remains valid. Set the existing release-branch guard to 0.4. Component repositories must be split to current 0.4 branches before the first release; no release or split operation is performed by this change. Validated action defaults and clean-tree/push behavior against its pinned source, workflow YAML and shell syntax, and the version step on scratch copies for replacement, repeat invocation and a missing-pattern failure. Upstream: https://github.com/laravel/framework/pull/58118 Upstream: https://github.com/laravel/framework/pull/60672 --- .github/workflows/release.yml | 8 ++++++-- bin/release.sh | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9098acb69..fc3f7d916 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,10 +58,14 @@ jobs: core.setFailed('Workflow failed. Release version does not match with selected target branch. Did you select the correct branch?') - name: Update Application.php version - run: sed -i "s/const VERSION = '.*';/const VERSION = '${{ steps.version.outputs.version }}';/g" src/foundation/src/Application.php + run: | + sed -i "s/const string VERSION = '.*';/const string VERSION = '${VERSION}';/g" src/foundation/src/Application.php + grep -Fq -- "const string VERSION = '${VERSION}';" src/foundation/src/Application.php + env: + VERSION: ${{ steps.version.outputs.version }} - name: Commit version change - uses: stefanzweifel/git-auto-commit-action@v5 + uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0 with: commit_message: "Update version to v${{ steps.version.outputs.version }}" diff --git a/bin/release.sh b/bin/release.sh index 9d03efdb9..10a1902cd 100755 --- a/bin/release.sh +++ b/bin/release.sh @@ -18,7 +18,7 @@ fi # Initialize variables NOW=$(date +%s) -RELEASE_BRANCH="0.3" +RELEASE_BRANCH="0.4" CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) BASEPATH=$(cd `dirname $0`; cd ../src/; pwd) VERSION=$1 @@ -106,4 +106,4 @@ done TIME=$(echo "$(date +%s) - $NOW" | bc) -printf "Execution time: %f seconds" $TIME \ No newline at end of file +printf "Execution time: %f seconds" $TIME From 13e953eec9228f2f8b8343925d420641098d05bd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:24:59 +0000 Subject: [PATCH 17/32] Type the queue failover event job payload Port the meaningful QueueFailedOver constructor descriptions from Laravel PR #58123 at current 13.x revision 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Use the native object|string union for command, matching the supported queue job inputs and the sole event construction path. Raw operations carry no job and do not dispatch this event. Preserve the command parameter name and mutable public properties. CacheFailedOver already carries the applicable types and explanations, so it needs no change. Omit redundant exception annotations under the porting policy. Validated the complete failover queue test class, formatting and full source/type analysis. Upstream: https://github.com/laravel/framework/pull/58123 --- src/queue/src/Events/QueueFailedOver.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/queue/src/Events/QueueFailedOver.php b/src/queue/src/Events/QueueFailedOver.php index 83197ed84..cb4eae26c 100644 --- a/src/queue/src/Events/QueueFailedOver.php +++ b/src/queue/src/Events/QueueFailedOver.php @@ -10,10 +10,13 @@ class QueueFailedOver { /** * Create a new event instance. + * + * @param null|string $connectionName the queue connection that failed + * @param object|string $command the job instance */ public function __construct( public ?string $connectionName, - public mixed $command, + public object|string $command, public Throwable $exception, ) { } From 8a7cad6eb32fcb449a8bb85b188c52f4b8ba5a42 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:48:45 +0000 Subject: [PATCH 18/32] Allow opting out of database lock pruning with null MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port Laravel PR #58152 from the current 13.x implementation at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58152 Accept a nullable pruning lottery and skip automatic cleanup when it is null. Retain the existing public pruner and resolver-based connection access so locks do not retain coroutine-owned pooled connections. Include the existing empty-array opt-out in the odds metadata, beyond upstream’s two-integer tuple or null. Document the empty array for store configuration, where null still selects the default odds. Restore the upstream acquisition exception annotation. The new null regression fails against the previous constructor; both opt-out cases verify acquisition without pruning. Existing forced-pruning and exception handling coverage remains. Focused cache and validation suites, formatting, and full source and type-fixture analysis pass. --- src/cache/src/DatabaseLock.php | 12 +++++++++--- src/docs/cache.md | 2 ++ tests/Cache/CacheDatabaseLockTest.php | 14 +++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 5725801ab..50b4d5f19 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -33,8 +33,10 @@ class DatabaseLock extends Lock implements RefreshableLock /** * The prune probability odds. + * + * @var null|array{int, int}|array{} */ - protected array $lottery; + protected ?array $lottery; /** * The default number of seconds that a lock should be held. @@ -43,6 +45,8 @@ class DatabaseLock extends Lock implements RefreshableLock /** * Create a new lock instance. + * + * @param null|array{int, int}|array{} $lottery the prune probability odds, or null to disable automatic pruning */ public function __construct( ConnectionResolverInterface $resolver, @@ -51,7 +55,7 @@ public function __construct( string $table, int $seconds, ?string $owner = null, - array $lottery = [2, 100], + ?array $lottery = [2, 100], int $defaultTimeoutInSeconds = 86400 ) { parent::__construct($name, $seconds, $owner); @@ -73,6 +77,8 @@ protected function connection(): ConnectionInterface /** * Attempt to acquire the lock. + * + * @throws Throwable */ public function acquire(): bool { @@ -99,7 +105,7 @@ public function acquire(): bool $acquired = $updated >= 1; } - if (count($this->lottery) === 2 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) { + if (count($this->lottery ?? []) === 2 && random_int(1, $this->lottery[1]) <= $this->lottery[0]) { $this->pruneExpiredLocks(); } diff --git a/src/docs/cache.md b/src/docs/cache.md index 7d8722633..e96946d25 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -839,6 +839,8 @@ Cache::lock('foo', 10) }); ``` +When using database locks, you may disable automatic pruning by setting your cache store's `lock_lottery` option to an empty array. You may then call `pruneExpiredLocks` on a database lock to remove expired locks explicitly. + ### Managing Locks Across Processes diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index 6231f5b29..b93ae4f62 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -15,6 +15,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\TestWith; class CacheDatabaseLockTest extends TestCase { @@ -121,6 +122,17 @@ public function testExpiredLocksAreDeletedDuringAcquisition(): void $this->assertTrue($lock->acquire()); } + #[TestWith([null])] + #[TestWith([[]])] + public function testLockCanBeAcquiredWithoutAutomaticPruning(?array $lottery): void + { + [$lock, $table] = $this->getLock(lockLottery: $lottery); + + $table->shouldReceive('insert')->once()->andReturn(true); + + $this->assertTrue($lock->acquire()); + } + public function testLockCanBeReleased(): void { [$lock, $table] = $this->getLock(); @@ -367,7 +379,7 @@ public function testGetConnectionNameCanReturnNull(): void /** * Get a DatabaseLock instance with mocked dependencies. */ - protected function getLock(int $seconds = 10, array $lockLottery = [0, 1], ?string $connectionName = 'default'): array + protected function getLock(int $seconds = 10, ?array $lockLottery = [0, 1], ?string $connectionName = 'default'): array { $resolver = m::mock(ConnectionResolverInterface::class); $connection = m::mock(ConnectionInterface::class); From 08039dda8d57c6c4a8fc4e521f250eab31bbe69e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:48:45 +0000 Subject: [PATCH 19/32] Complete current-time date rule test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Laravel PR #58059 using the current 13.x tests at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58059 The dateTime builder and past, future, nowOrPast and nowOrFuture methods are already implemented and documented. Restore both missing upstream test methods and all four strict assertions in upstream order. Adapt expected parameters to Hypervel’s standard CSV quoting while preserving all existing date-format and literal-separator coverage. The changed test file and focused suites pass, along with formatting and full static analysis. --- tests/Validation/ValidationDateRuleTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Validation/ValidationDateRuleTest.php b/tests/Validation/ValidationDateRuleTest.php index f0a39d4b4..db9da6c90 100644 --- a/tests/Validation/ValidationDateRuleTest.php +++ b/tests/Validation/ValidationDateRuleTest.php @@ -48,6 +48,24 @@ public function testBeforeTodayRule(): void $this->assertEquals('date|before_or_equal:"today"', (string) $rule); } + public function testPastRule(): void + { + $rule = Rule::date()->past(); + $this->assertSame('date|before:"now"', (string) $rule); + + $rule = Rule::date()->nowOrPast(); + $this->assertSame('date|before_or_equal:"now"', (string) $rule); + } + + public function testFutureRule(): void + { + $rule = Rule::date()->future(); + $this->assertSame('date|after:"now"', (string) $rule); + + $rule = Rule::date()->nowOrFuture(); + $this->assertSame('date|after_or_equal:"now"', (string) $rule); + } + public function testAfterSpecificDateRule(): void { $rule = Rule::date()->after(CarbonImmutable::parse('2024-01-01')); From 42fedf400fdab892b241576e41dca708863bd934 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:33:12 +0000 Subject: [PATCH 20/32] Complete collection callback contracts and grouping type coverage Port Laravel framework PR #58176 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2: https://github.com/laravel/framework/pull/58176 Reconcile its already implemented enum/countBy dependencies: https://github.com/laravel/framework/pull/56856 https://github.com/laravel/framework/pull/56830 Put enum-aware keyBy and countBy callback metadata on Enumerable so interface-typed calls share the concrete implementations' contract. Include supported boolean counting/grouping, null grouping and Stringable keys. Preserve the Collection return alternative for countBy implementations such as Eloquent, without changing native signatures or runtime behavior. Correct the upstream grouping contract: LazyCollection produces ordinary Collection groups, while eager collections preserve their concrete class. Keep the shared contract on Enumerable and refine eager groups locally. Order conditional key types by array-key first so PHPStan can prove their bounds, removing obsolete suppressions instead of adding runtime machinery. Stringable and enum branches cannot overlap because PHP forbids enum __toString implementations. Alias the native Stringable import to retain the existing Hypervel Support Stringable references in the same namespace. Port the current eager and lazy grouping, keyBy and countBy type assertions into the existing fixture, retaining input cases and callback assertions. Correct lazy-group expectations to match runtime and preserve more precise literal keys. Add focused interface and normalized-key regression cases. Validated with composer analyse, composer lint:fix, affected eager/lazy and Eloquent collection suites, and final focused grouping/key/count tests. --- src/collections/src/Collection.php | 24 ++-- src/collections/src/Enumerable.php | 17 +-- src/collections/src/LazyCollection.php | 9 +- types/Collections/Collection.php | 146 ++++++++++++++++++++++++- 4 files changed, 172 insertions(+), 24 deletions(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 762303059..d5ef7b19b 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -14,8 +14,10 @@ use Hypervel\Support\Traits\Macroable; use Hypervel\Support\Traits\TransformsToResourceCollection; use InvalidArgumentException; +use Override; use SortDirection; use stdClass; +use Stringable as BaseStringable; use Traversable; use UnitEnum; @@ -505,7 +507,18 @@ public function getOrPut(mixed $key, mixed $value): mixed /** * Group an associative array by a field or using a callback. + * + * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable + * + * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy + * @return static< + * ($groupBy is (array|string) + * ? array-key + * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), + * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> + * > */ + #[Override] public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static { if (! $this->useAsCallable($groupBy) && is_array($groupBy)) { @@ -529,7 +542,7 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal $groupKey = match (true) { is_bool($groupKey) => (int) $groupKey, $groupKey instanceof UnitEnum => enum_value($groupKey), - $groupKey instanceof \Stringable => (string) $groupKey, + $groupKey instanceof BaseStringable => (string) $groupKey, is_null($groupKey) => (string) $groupKey, default => $groupKey, }; @@ -554,13 +567,8 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. - * - * @template TNewKey of array-key|\UnitEnum - * - * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy - * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue> - * @phpstan-ignore method.childReturnType (complex conditional types PHPStan can't match) */ + #[Override] public function keyBy(callable|array|string $keyBy): static { $keyBy = $this->valueRetriever($keyBy); @@ -1816,9 +1824,9 @@ public function count(): int /** * Count the number of items in the collection by a field or using a callback. * - * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy * @return static */ + #[Override] public function countBy(callable|string|null $countBy = null): Collection { return $this->newInstance($this->lazy()->countBy($countBy)->all()); diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index 65d53a68b..3fa328bc9 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -15,8 +15,10 @@ use JsonException; use JsonSerializable; use SortDirection; +use Stringable as BaseStringable; use Traversable; use UnexpectedValueException; +use UnitEnum; /** * Some transformations may return a base collection when an implementation @@ -435,26 +437,25 @@ public function get(mixed $key, mixed $default = null): mixed; /** * Group an associative array by a field or using a callback. * - * @template TGroupKey of array-key|\UnitEnum|\Stringable + * @template TGroupKey of array-key|bool|null|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): (array|TGroupKey))|string $groupBy * @return static< * ($groupBy is (array|string) * ? array-key - * : (TGroupKey is \UnitEnum ? array-key : (TGroupKey is \Stringable ? string : TGroupKey))), - * static<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> + * : (TGroupKey is array-key ? TGroupKey : (TGroupKey is bool ? int : (TGroupKey is (BaseStringable|null) ? string : array-key)))), + * Collection<($preserveKeys is true ? TKey : int), ($groupBy is array ? mixed : TValue)> * > - * @phpstan-ignore generics.notSubtype (PHPStan cannot prove normalized conditional group keys satisfy array-key) */ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = false): static; /** * Key an associative array by a field or using a callback. * - * @template TNewKey of array-key + * @template TNewKey of array-key|UnitEnum|BaseStringable * * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy - * @return static<($keyBy is string ? array-key : ($keyBy is array ? array-key : TNewKey)), TValue> + * @return static<($keyBy is (array|string) ? array-key : (TNewKey is array-key ? TNewKey : (TNewKey is BaseStringable ? string : array-key))), TValue> */ public function keyBy(callable|array|string $keyBy): static; @@ -1085,8 +1086,8 @@ public function count(): int; /** * Count the number of items in the collection by a field or using a callback. * - * @param null|(callable(TValue, TKey): array-key)|string $countBy - * @return static + * @param null|(callable(TValue, TKey): (array-key|bool|UnitEnum))|string $countBy + * @return Collection|static */ public function countBy(callable|string|null $countBy = null): Collection|static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index d6418fcea..5ea4f4371 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -354,9 +354,9 @@ public function crossJoin(Arrayable|iterable ...$arrays): static /** * Count the number of items in the collection by a field or using a callback. * - * @param null|(callable(TValue, TKey): (array-key|UnitEnum))|string $countBy * @return static */ + #[Override] public function countBy(callable|string|null $countBy = null): static { $countBy = is_null($countBy) @@ -557,13 +557,8 @@ public function groupBy(callable|array|string $groupBy, bool $preserveKeys = fal /** * Key an associative array by a field or using a callback. - * - * @template TNewKey of array-key|\UnitEnum - * - * @param array|(callable(TValue, TKey): TNewKey)|string $keyBy - * @return static<($keyBy is (array|string) ? array-key : (TNewKey is UnitEnum ? array-key : TNewKey)), TValue> - * @phpstan-ignore method.childReturnType (complex conditional return type PHPStan can't verify) */ + #[Override] public function keyBy(callable|array|string $keyBy): static { return $this->newInstance(function () use ($keyBy) { diff --git a/types/Collections/Collection.php b/types/Collections/Collection.php index fb535bc12..3b7ed47f9 100644 --- a/types/Collections/Collection.php +++ b/types/Collections/Collection.php @@ -28,7 +28,7 @@ assertType('Hypervel\Support\Collection', $collection->flatten()); assertType('Hypervel\Support\LazyCollection', $lazy->flatten()); assertType( - 'Hypervel\Support\Collection>', + "Hypervel\\Support\\Collection<'even'|'odd', Hypervel\\Support\\Collection>", $collection->groupBy(static fn (int $value): array => [$value % 2 === 0 ? 'even' : 'odd']) ); @@ -58,6 +58,8 @@ assertType('Hypervel\Support\LazyCollection', LazyCollection::make($lazySource)); /** + * Check shared enumerable return and callback types. + * * @param Enumerable $enumerable */ function assertEnumerableTypes(Enumerable $enumerable): void @@ -67,6 +69,148 @@ function assertEnumerableTypes(Enumerable $enumerable): void assertType('Hypervel\Support\Enumerable', $enumerable->random(2, true)); assertType('float|int', $enumerable->sum(static fn (int $value): int => $value)); assertType('mixed', $enumerable->sum('amount')); + + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->keyBy(static fn () => Digit::One)); + assertType('Hypervel\Support\Enumerable', $enumerable->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn () => Digit::One)); + assertType('Hypervel\Support\Enumerable<(int|string), int>', $enumerable->countBy(static fn (int $value): bool => $value > 1)); + assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn (int $value): bool => $value > 1)); + assertType('Hypervel\Support\Enumerable>', $enumerable->groupBy(static fn () => null, preserveKeys: true)); } assertEnumerableTypes($collection); + +/** + * Check eager collection grouping and key inference. + * + * @param Collection $collection + */ +function assertCollectionGroupingTypes(Collection $collection): void +{ + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email'])); + assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; + })); + assertType('Hypervel\Support\Collection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\Collection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); + + assertType("Hypervel\\Support\\Collection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { + return 'foo'; + }, preserveKeys: true)); + + assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy('name')); + assertType("Hypervel\\Support\\Collection<'foo', User>", $collection->keyBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; + })); + assertType('Hypervel\Support\Collection<0, User>', $collection->keyBy(static fn ($user): int => 0)); + assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\Collection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); + + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([1])->countBy()); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy('email')); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return $string; + })); + + assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn (): bool => true)); + assertType('Hypervel\Support\Collection>', $collection->groupBy(static fn () => null)); + assertType('Hypervel\Support\Collection', $collection->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\Collection<(int|string), int>', $collection->countBy(static fn (): bool => true)); +} + +/** + * Check lazy collection grouping and key inference. + * + * @param LazyCollection $collection + */ +function assertLazyCollectionGroupingTypes(LazyCollection $collection): void +{ + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name')); + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy('name', true)); + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(['name', 'email'])); + assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection>", $collection->groupBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; + })); + assertType('Hypervel\Support\LazyCollection<0, Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => 0)); + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), Hypervel\Support\Collection>', $collection->groupBy(static fn ($user) => NumberedDigit::One)); + + assertType("Hypervel\\Support\\LazyCollection<'foo', Hypervel\\Support\\Collection<'bar', User>>", $collection->keyBy(fn ($user) => 'bar')->groupBy(function ($user) { + return 'foo'; + }, preserveKeys: true)); + + assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy('name')); + assertType("Hypervel\\Support\\LazyCollection<'foo', User>", $collection->keyBy(function ($user, $int) { + assertType('User', $user); + assertType('int', $int); + + return 'foo'; + })); + assertType('Hypervel\Support\LazyCollection<0, User>', $collection->keyBy(static fn ($user): int => 0)); + assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), User>', $collection->keyBy(static fn ($user) => NumberedDigit::One)); + + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([1])->countBy()); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string' => 'string'])->countBy('string')); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy('email')); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 'email')); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => 0)); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => Digit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make([new User])->countBy(static fn ($user) => NamedDigit::One)); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection::make(['string'])->countBy(function ($string, $int) { + assertType('string', $string); + assertType('int', $int); + + return $string; + })); + + assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn (): bool => true)); + assertType('Hypervel\Support\LazyCollection>', $collection->groupBy(static fn () => null)); + assertType('Hypervel\Support\LazyCollection', $collection->keyBy(static fn () => new Collection(['key']))); + assertType('Hypervel\Support\LazyCollection<(int|string), int>', $collection->countBy(static fn (): bool => true)); +} + +enum Digit +{ + case One; + case Two; + case Three; +} + +enum NamedDigit: string +{ + case One = 'one'; + case Two = 'two'; + case Three = 'three'; +} + +enum NumberedDigit: int +{ + case One = 1; + case Two = 2; + case Three = 3; +} From cb84337083e04ec7d1dc6fdd7ea535eb1dea8e4a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:51:38 +0000 Subject: [PATCH 21/32] Correct HTTP after-response callback return contracts Complete the current callback metadata from Laravel framework PR #58088: https://github.com/laravel/framework/pull/58088 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The existing callback loop intentionally ignores non-Response returns, including the string and void callbacks in the upstream regression tests. The inherited Response|null annotation incorrectly rejected these supported callbacks. Describe their result as mixed and type the callback collection consistently, preserving nullable Request for caller-supplied Guzzle clients that bypass middleware capture. Extend the existing type fixture to check inferred callback arguments, string/void returns, response replacement and the synchronous fluent result. Explain that both fixture blocks need isolation from PHPStan's retained async self-out state. Runtime behavior, callback ownership and native signatures are unchanged; existing runtime tests already cover the cases. Verified full source/type analysis, formatting, immediate type-fixture checks and affected HTTP, mail, queue and relationship tests. Peer review independently confirmed the contract and fixture isolation. --- src/http/src/Client/PendingRequest.php | 4 +++- types/Http/Client/PendingRequest.php | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index d36ed7df0..2381a6014 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -158,6 +158,8 @@ class PendingRequest implements Transient /** * The callbacks that should execute after the response is built. + * + * @var Collection */ protected Collection $afterResponseCallbacks; @@ -695,7 +697,7 @@ public function beforeSending(callable $callback): static /** * Add a new callback to execute after the response is built. * - * @param callable(Response, null|Request): (null|Response) $callback + * @param callable(Response, null|Request): mixed $callback */ public function afterResponse(callable $callback): static { diff --git a/types/Http/Client/PendingRequest.php b/types/Http/Client/PendingRequest.php index 702543f2c..beaab4948 100644 --- a/types/Http/Client/PendingRequest.php +++ b/types/Http/Client/PendingRequest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Hypervel\Http\Client\PendingRequest; +use Hypervel\Http\Client\Response; use Hypervel\Support\Facades\Http; use function PHPStan\Testing\assertType; @@ -14,7 +15,7 @@ // PHPStan carries async()'s self-out type onto repeated Http::createPendingRequest() // expressions in the same scope, although each call creates a fresh request. -// Keep these state checks separate from the loop's inferred async state. +// Keep the state and callback checks below in separate scopes from that loop. function (bool $async): void { assertType('Hypervel\Http\Client\Response', Http::createPendingRequest()->withHeaders([])->get('/foo')); assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::async()->get('/foo')); @@ -29,6 +30,22 @@ function (bool $async): void { assertType('GuzzleHttp\Promise\PromiseInterface|Hypervel\Http\Client\Response', Http::createPendingRequest()->async($async)->get('/foo')); }; +function (): void { + $request = Http::createPendingRequest() + ->afterResponse(function ($response, $request): string { + assertType('Hypervel\Http\Client\Response', $response); + assertType('Hypervel\Http\Client\Request|null', $request); + + return 'ignored'; + }) + ->afterResponse(static function (Response $response): void { + }) + ->afterResponse(static fn (Response $response): Response => new Response($response->toPsrResponse())); + + assertType('Hypervel\Http\Client\PendingRequest', $request); + assertType('Hypervel\Http\Client\Response', $request->get('/foo')); +}; + class PlainHttpPendingRequest extends PendingRequest { } From 3489ff514d73cd20feb1fcdbaee4e93d288af666 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:52:08 +0000 Subject: [PATCH 22/32] Document optional and renamed pivot timestamp columns Complete the public documentation for Laravel framework PR #58164: https://github.com/laravel/framework/pull/58164 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Docs checked at 2914ba0b06c6be40c2f1f992555853f6266707d6. The implementation and attachment regression already support false for either withTimestamps argument, but both frameworks' documentation still requires both timestamp columns unconditionally. Replace that warning with the default requirement, column-name arguments and a disabled-column example. Explain the custom-pivot requirement to override timestamp-column methods for renamed or disabled columns: AsPivot delegates to the parent model, so setting the custom pivot's constants alone does not reliably configure these values. Keep the guidance focused on public usage. Verified the current upstream source and fixture, the existing SQLite attachment test, and the relationship/mailable paths during peer review. No source behavior or new application compatibility difference. --- src/docs/eloquent-relationships.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 76724bb73..24833e26d 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -880,8 +880,13 @@ If you would like your intermediate table to have `created_at` and `updated_at` return $this->belongsToMany(Role::class)->withTimestamps(); ``` -> [!WARNING] -> Intermediate tables that utilize Eloquent's automatically maintained timestamps are required to have both `created_at` and `updated_at` timestamp columns. +By default, the intermediate table must contain both timestamp columns. To use different column names, pass them to the `createdAt` and `updatedAt` arguments. You may pass `false` to either argument to disable that timestamp: + +```php +return $this->belongsToMany(Role::class)->withTimestamps(updatedAt: false); +``` + +For custom pivot models, also override `getCreatedAtColumn` or `getUpdatedAtColumn` to return the renamed column or `null` for a disabled timestamp. #### Customizing the `pivot` Attribute Name From 3220a895a3338086209c69c2607694a0953d591b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:52:08 +0000 Subject: [PATCH 23/32] Remove unused mail-render and job-failure tuple assignments Complete Laravel framework PR #58187 from current 13.x: https://github.com/laravel/framework/pull/58187 Source revision: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Mailer::render only consumes the HTML/plain entries of parseView's tuple, and Job::failed invokes the conventional failed hook regardless of the parsed execution method. Remove those two unused assignments. Preserve Mailer::send's raw entry and Job::fire's method because both are used. The other three changed upstream files already contain the cleanup. No behavioral changes or additional tests: existing mail rendering and queue failure coverage passes, with formatting and full source/type analysis. The complete five-file upstream diff was reconciled. --- src/mail/src/Mailer.php | 2 +- src/queue/src/Jobs/Job.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mail/src/Mailer.php b/src/mail/src/Mailer.php index 546403c4b..1f58d2426 100644 --- a/src/mail/src/Mailer.php +++ b/src/mail/src/Mailer.php @@ -190,7 +190,7 @@ public function render(array|Closure|string $view, array $data = []): string // First we need to parse the view, which could either be a string or an array // containing both an HTML and plain text versions of the view which should // be used when sending an e-mail. We will extract both of them out here. - [$view, $plain, $raw] = $this->parseView($view); + [$view, $plain] = $this->parseView($view); $data['message'] = $this->createMessage(); diff --git a/src/queue/src/Jobs/Job.php b/src/queue/src/Jobs/Job.php index ab543d44e..e0fbcd940 100644 --- a/src/queue/src/Jobs/Job.php +++ b/src/queue/src/Jobs/Job.php @@ -332,7 +332,7 @@ protected function failed(?Throwable $e): void { $payload = $this->payload(); - [$class, $method] = JobName::parse($payload['job']); + [$class] = JobName::parse($payload['job']); if (method_exists($this->instance = $this->resolve($class), 'failed')) { $this->instance->failed($payload['data'], $e, $payload['uuid'] ?? '', $this); From 860dbe0076fc88e6db66d5648bb50569e86ccafe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 00:52:08 +0000 Subject: [PATCH 24/32] Retain upstream invocation checks in null-key relationship tests Complete the current test expectations for Laravel framework PR #58191: https://github.com/laravel/framework/pull/58191 Source: 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. All three relation matchers already reject null parent keys, and the has-one/has-many regression assertions are present. Port current upstream single-invocation expectations for relation construction, constraints and related collection creation. Remove unused timestamp-column mocks and the unused newCollection stub from Hypervel's null-foreign-key test. Keep every behavioral assertion, the extra null-foreign-key case and the string-key model fixture. Add native callback types and helper descriptions without changing the supported relationship behavior. Verified the changed file immediately, affected parallel suites, formatting and full source/type analysis. Peer review confirmed every required call and that removed mocks are not used on these paths. --- .../EloquentHasOneOrManyDeprecationTest.php | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/Database/EloquentHasOneOrManyDeprecationTest.php b/tests/Database/EloquentHasOneOrManyDeprecationTest.php index 06443a7f2..f06bf3f66 100644 --- a/tests/Database/EloquentHasOneOrManyDeprecationTest.php +++ b/tests/Database/EloquentHasOneOrManyDeprecationTest.php @@ -30,7 +30,7 @@ public function testHasManyMatchWithNullLocalKey(): void $model2 = new HasOneOrManyDeprecationModelStub; $model2->id = null; - $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) { + $relation->getRelated()->expects('newCollection')->andReturnUsing(function (array $array): Collection { return new Collection($array); }); @@ -68,43 +68,41 @@ public function testHasManyMatchWithNullForeignKey(): void $model = new HasOneOrManyDeprecationModelStub; $model->id = ''; - $relation->getRelated()->shouldReceive('newCollection')->andReturnUsing(function ($array) { - return new Collection($array); - }); - $models = $relation->match([$model], new Collection([$result]), 'foo'); $this->assertNull($models[0]->foo); } + /** + * Create a has-many relation with mocked query constraints. + */ protected function getHasManyRelation(): HasMany { $queryBuilder = m::mock(QueryBuilder::class); $builder = m::mock(Builder::class, [$queryBuilder]); - $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); - $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + $builder->expects('whereNotNull')->with('table.foreign_key'); + $builder->expects('where')->with('table.foreign_key', '=', 1); $related = m::mock(Model::class); - $builder->shouldReceive('getModel')->andReturn($related); + $builder->expects('getModel')->andReturn($related); $parent = m::mock(Model::class); - $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); - $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); - $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $parent->expects('getAttribute')->with('id')->andReturn(1); return new HasMany($builder, $parent, 'table.foreign_key', 'id'); } + /** + * Create a has-one relation with mocked query constraints. + */ protected function getHasOneRelation(): HasOne { $queryBuilder = m::mock(QueryBuilder::class); $builder = m::mock(Builder::class, [$queryBuilder]); - $builder->shouldReceive('whereNotNull')->with('table.foreign_key'); - $builder->shouldReceive('where')->with('table.foreign_key', '=', 1); + $builder->expects('whereNotNull')->with('table.foreign_key'); + $builder->expects('where')->with('table.foreign_key', '=', 1); $related = m::mock(Model::class); - $builder->shouldReceive('getModel')->andReturn($related); + $builder->expects('getModel')->andReturn($related); $parent = m::mock(Model::class); - $parent->shouldReceive('getAttribute')->with('id')->andReturn(1); - $parent->shouldReceive('getCreatedAtColumn')->andReturn('created_at'); - $parent->shouldReceive('getUpdatedAtColumn')->andReturn('updated_at'); + $parent->expects('getAttribute')->with('id')->andReturn(1); return new HasOne($builder, $parent, 'table.foreign_key', 'id'); } From 91fca009f00f9eea532ac88d7576dbe020320583 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:21:32 +0000 Subject: [PATCH 25/32] docs(auth): document the guest middleware static constructor Complete the public documentation for Laravel PR #58204. Show the multi-guard using() constructor beside guest redirection and its equivalent guest:admin,web alias. The source implementation and all three upstream guard-count assertions are already present, including Hypervel request-local guard selection. Upstream: https://github.com/laravel/framework/pull/58204 Source reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). The pinned Laravel authentication documentation has no constructor example, so this adds a concise example at the existing public surface. Validation: existing middleware source and upstream assertions reconciled; full static analysis and formatting pass. Independently reviewed with the filesystem URL batch. --- src/docs/authentication.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/docs/authentication.md b/src/docs/authentication.md index 52281fcbe..2e13c394c 100644 --- a/src/docs/authentication.md +++ b/src/docs/authentication.md @@ -498,6 +498,16 @@ public function boot(): void When the `guest` middleware names a guard and the request continues, that guard becomes the current default guard for the request. If multiple guards are listed, the first guard is selected. +You may use the `RedirectIfAuthenticated` middleware's `using` method as an alternative to a middleware alias. For example, the following is equivalent to `guest:admin,web`: + +```php +use Hypervel\Auth\Middleware\RedirectIfAuthenticated; +use Hypervel\Support\Facades\Route; + +Route::get('/admin/login', fn () => view('auth.login')) + ->middleware(RedirectIfAuthenticated::using('admin', 'web')); +``` + #### Specifying a Guard From 0a7ec56ee86c1c7243873677744ee377c277bd84 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 01:21:48 +0000 Subject: [PATCH 26/32] fix(filesystem): normalize the application URL for public files Port Laravel PR #58210 from current 13.x. Strip trailing slashes from APP_URL before appending /storage so a configured application subpath does not produce URLs containing //storage. Normalize the shipped configuration rather than changing explicit filesystem URL handling or adding work to each generated URL. Cast the environment value to string before rtrim so an absent APP_URL still produces /storage under strict types. Update the matching documentation example, which remains stale in the pinned Laravel docs. Exercise the real public disk adapter using shipped config for an ordinary host, a trailing-slash subpath, and an absent environment value. Upstream: https://github.com/laravel/framework/pull/58210 Source reference: 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 (13.x). Validation: the subpath regression fails before the fix and passes afterwards. FoundationConfigTest, the filesystem suite, full static analysis and formatting pass. Self-reviewed and independently reviewed. --- src/docs/filesystem.md | 2 +- src/foundation/config/filesystems.php | 2 +- tests/Foundation/FoundationConfigTest.php | 26 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md index 16eab6b61..6baa4646e 100644 --- a/src/docs/filesystem.md +++ b/src/docs/filesystem.md @@ -509,7 +509,7 @@ If you would like to modify the host for URLs generated using the `Storage` faca 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL').'/storage', + 'url' => rtrim((string) env('APP_URL'), '/').'/storage', 'visibility' => 'public', 'throw' => false, ], diff --git a/src/foundation/config/filesystems.php b/src/foundation/config/filesystems.php index 37fc24236..d561def03 100644 --- a/src/foundation/config/filesystems.php +++ b/src/foundation/config/filesystems.php @@ -45,7 +45,7 @@ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => env('APP_URL') . '/storage', + 'url' => rtrim((string) env('APP_URL'), '/') . '/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, diff --git a/tests/Foundation/FoundationConfigTest.php b/tests/Foundation/FoundationConfigTest.php index a313d9977..c955a65b6 100644 --- a/tests/Foundation/FoundationConfigTest.php +++ b/tests/Foundation/FoundationConfigTest.php @@ -10,6 +10,7 @@ use Hypervel\Pool\PoolOption; use Hypervel\Redis\RedisConfig; use Hypervel\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; use Swoole\Constant; class FoundationConfigTest extends TestCase @@ -253,6 +254,31 @@ public function testShippedFilesystemDisksDeclareVisibilityAndFailurePolicy(): v } } + #[DataProvider('publicDiskUrlProvider')] + public function testPublicDiskUrlsNormalizeTheApplicationUrl(?string $appUrl, string $expectedUrl): void + { + $config = $this->withEnvironmentValue( + 'APP_URL', + $appUrl, + fn (): array => $this->filesystemConfig(), + ); + $disk = $this->app->make('filesystem')->build($config['disks']['public']); + + $this->assertSame($expectedUrl, $disk->url('avatar.png')); + } + + /** + * Provide application URLs and their public file URLs. + */ + public static function publicDiskUrlProvider(): array + { + return [ + 'without trailing slash' => ['https://example.test', 'https://example.test/storage/avatar.png'], + 'subpath with trailing slash' => ['https://example.test/app/', 'https://example.test/app/storage/avatar.png'], + 'absent application URL' => [null, '/storage/avatar.png'], + ]; + } + public function testS3RootReadsTheAwsRootEnvironmentVariable(): void { $config = $this->withEnvironmentValue( From 812acdcaf069502fe29c23111fbc17768f70cbc2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:10:04 +0000 Subject: [PATCH 27/32] Complete session authentication HMAC contract and Auth facade metadata Complete the remaining Laravel #58107 surface against framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Regenerate the Auth facade from AuthManager and SessionGuard so hashPasswordForCookie, logoutOtherDevices and the remaining session guard methods are discoverable. Preserve guard-contract impurity through mixins, including HTTP basic authentication, with type fixtures guarding against incorrect narrowing. Retain the owner-approved HMAC-only requirement for custom guards used with auth.session. Document the required method and SessionGuard extension path, and record the deliberate omission of the raw-hash and missing-method fallbacks introduced by #58385 and #58389. No runtime fallback or compatibility branch is added. Restore the protected middleware guard return contract to AuthFactory|Guard so concrete guard overrides remain possible. Port current upstream session middleware assertions and precise remember-cookie mock counts while preserving Hypervel-specific rejection coverage. Add a regression for the concrete guard override. Validated with full formatting and source/type analysis, Auth facade generation checks, changed test classes, and the Auth, Session, Sanctum and FacadeDocumenter suites. Upstream: https://github.com/laravel/framework/pull/58107 Related exclusions: https://github.com/laravel/framework/pull/58385 https://github.com/laravel/framework/pull/58389 --- src/docs/authentication.md | 2 + src/docs/porting-from-laravel.md | 2 + src/session/README.md | 2 +- .../src/Middleware/AuthenticateSession.php | 8 +- src/support/src/Facades/Auth.php | 38 ++++- tests/Auth/AuthGuardTest.php | 16 +-- .../Middleware/AuthenticateSessionTest.php | 133 ++++++++++++------ types/Support/Auth.php | 26 ++++ 8 files changed, 170 insertions(+), 57 deletions(-) create mode 100644 types/Support/Auth.php diff --git a/src/docs/authentication.md b/src/docs/authentication.md index 2e13c394c..0d16b355e 100644 --- a/src/docs/authentication.md +++ b/src/docs/authentication.md @@ -830,6 +830,8 @@ Route::middleware(['auth', 'auth.session'])->group(function () { }); ``` +Custom guards used with `auth.session` must provide a `hashPasswordForCookie` method that returns an HMAC of the password hash and use the same value when creating remember cookies. Extending `Hypervel\Auth\SessionGuard` provides this behavior. + Then, you may use the `logoutOtherDevices` method provided by the `Auth` facade. This method requires the user to confirm their current password, which your application should accept through an input form: ```php diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 2dc439787..5868315a1 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -603,6 +603,8 @@ Custom cache tag sets must declare `TagSet::reset(): bool` and `TagSet::flush(): ### Sessions +Custom guards used with `auth.session` must provide `hashPasswordForCookie()`; Hypervel does not fall back to raw password hashes when the method is missing. Guards extending `SessionGuard` already support it. See [session authentication](/docs/{{version}}/authentication#invalidating-sessions-on-other-devices). + Hypervel's persistent application session drivers are `file`, `cookie`, `database`, and `redis`. The non-persistent `array` and `null` drivers are available for testing. Redis sessions are stored directly in Redis and may select a named Redis connection using `SESSION_CONNECTION`. Laravel's Memcached, APC / APCu, DynamoDB, and generic cache-backed session configurations do not port. Hypervel does not provide Laravel's cache session handler or `SESSION_STORE` setting. Select one of Hypervel's session drivers and review its requirements in the [session documentation](/docs/{{version}}/session). diff --git a/src/session/README.md b/src/session/README.md index 62b859c3e..1163d6cf0 100644 --- a/src/session/README.md +++ b/src/session/README.md @@ -8,7 +8,7 @@ Documentation: https://hypervel.org/docs/session ## Differences From Laravel - `Store::passwordConfirmed(?string $guard = null)` stamps a guard-scoped key (`auth.password_confirmed_at_{guard}`) instead of Laravel's single shared key, resolving the current guard when none is given. -- Password-hash session artifacts are HMAC-only. Laravel's raw-hash fallback for legacy sessions is intentionally omitted because Hypervel 0.4 has no released legacy sessions. +- Password-hash session artifacts are HMAC-only. Laravel's raw-hash fallbacks for legacy sessions and guards without `hashPasswordForCookie()` are intentionally omitted. Custom guards used with `auth.session` must provide that method; extending `SessionGuard` supplies it. - Hypervel's Redis session driver persists directly through Redis instead of Laravel's shared cache-backed handler. Laravel's APC, Memcached, DynamoDB, and shared cache-wrapper session drivers are not provided. - Hypervel's generated sessions table uses a nullable indexed string for `user_id`, supporting integer, UUID, ULID, and application-defined identifiers, together with a nullable `auth_provider` for provider-qualified ownership. Its `ip_address` uses the semantic IP column type, including PostgreSQL's native `inet` type. - `DatabaseSessionHandler::getDefaultPayload()` receives the session ID before the serialized data. Laravel's scalar `addUserInformation()` and `userId()` hooks are not provided because Hypervel stores the authentication provider and user ID as one ownership value. diff --git a/src/session/src/Middleware/AuthenticateSession.php b/src/session/src/Middleware/AuthenticateSession.php index 007de0507..29fd6b072 100644 --- a/src/session/src/Middleware/AuthenticateSession.php +++ b/src/session/src/Middleware/AuthenticateSession.php @@ -7,6 +7,7 @@ use Closure; use Hypervel\Auth\AuthenticationException; use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Guard; use Hypervel\Contracts\Session\Middleware\AuthenticatesSessions; use Hypervel\Http\Request; @@ -83,8 +84,9 @@ protected function storePasswordHashInSession(Request $request): void /** * Validate the password hash against the stored value. * - * Only HMAC artifacts are valid; Hypervel has no released raw-hash - * session artifacts to accept. + * Only HMAC artifacts are valid. Custom guards must provide + * hashPasswordForCookie(); the raw-hash and missing-method fallbacks + * are intentionally omitted. */ protected function validatePasswordHash(string $passwordHash, mixed $storedValue): bool { @@ -113,7 +115,7 @@ protected function logout(Request $request): void /** * Get the guard instance that should be used by the middleware. */ - protected function guard(): AuthFactory + protected function guard(): AuthFactory|Guard { return $this->auth; } diff --git a/src/support/src/Facades/Auth.php b/src/support/src/Facades/Auth.php index b44caa7f4..86ed4bf08 100644 --- a/src/support/src/Facades/Auth.php +++ b/src/support/src/Facades/Auth.php @@ -5,6 +5,7 @@ namespace Hypervel\Support\Facades; use Hypervel\Contracts\Auth\StatefulGuard; +use Hypervel\Contracts\Auth\SupportsBasicAuth; /** * @method static void clearUserCache(mixed $identifier, \UnitEnum|string|null $guard = null) @@ -30,19 +31,45 @@ * @method static void shouldUse(\UnitEnum|string|null $name) * @method static \Closure userResolver() * @method static \Hypervel\Auth\AuthManager viaRequest(string $driver, callable $callback) + * @method static void attempting(callable $callback) + * @method static bool attemptWhen(array $credentials = [], callable|array|null $callbacks = null, bool $remember = false) + * @method static \Hypervel\Contracts\Auth\Authenticatable authenticate() + * @method static void flushMacros() + * @method static void flushState() + * @method static \Hypervel\Auth\SessionGuard forgetUser() + * @method static \Hypervel\Contracts\Cookie\QueueingFactory getCookieJar() + * @method static \Hypervel\Contracts\Events\Dispatcher|null getDispatcher() + * @method static \Hypervel\Contracts\Auth\Authenticatable|null getLastAttempted() + * @method static string getName() + * @method static \Hypervel\Contracts\Auth\UserProvider|null getProvider() + * @method static string getRecallerName() + * @method static \Symfony\Component\HttpFoundation\Request getRequest() + * @method static \Hypervel\Contracts\Session\Session getSession() + * @method static \Hypervel\Support\Timebox getTimebox() + * @method static \Hypervel\Contracts\Auth\Authenticatable|null getUser() + * @method static string hashPasswordForCookie(string|null $passwordHash) + * @method static bool hasMacro(string $name) + * @method static void logoutCurrentDevice() + * @method static \Hypervel\Contracts\Auth\Authenticatable|null logoutOtherDevices(string $password) + * @method static void macro(string $name, callable|object $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static void setCookieJar(\Hypervel\Contracts\Cookie\QueueingFactory $cookie) + * @method static void setDispatcher(\Hypervel\Contracts\Events\Dispatcher $events) + * @method static void setProvider(\Hypervel\Contracts\Auth\UserProvider $provider) + * @method static \Hypervel\Auth\SessionGuard setRememberDuration(int $minutes) * * @see \Hypervel\Auth\AuthManager - * @see \Hypervel\Contracts\Auth\Guard - * @see \Hypervel\Contracts\Auth\StatefulGuard + * @see \Hypervel\Auth\SessionGuard * * @mixin \Hypervel\Contracts\Auth\StatefulGuard + * @mixin \Hypervel\Contracts\Auth\SupportsBasicAuth */ class Auth extends Facade { /** * Get methods that should be excluded from the generated facade docblock. * - * The guard surface comes from the mixin because @method tags cannot carry + * The guard contracts come from mixins because @method tags cannot carry * the contracts' @phpstan-impure metadata. * * The documenter excludes by name, so review this hook if AuthManager gains @@ -52,7 +79,10 @@ class Auth extends Facade */ protected static function ignoredFacadeDocumenterMethods(): array { - return get_class_methods(StatefulGuard::class); + return [ + ...get_class_methods(StatefulGuard::class), + ...get_class_methods(SupportsBasicAuth::class), + ]; } /** diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index c4d396750..0c67f12f6 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -491,7 +491,7 @@ public function testLogoutCurrentDeviceFiresLogoutEvent() $mock->logoutCurrentDevice(); } - public function testLoginMethodQueuesCookieWhenRemembering() + public function testLoginMethodQueuesCookieWhenRemembering(): void { [$session, $provider, $request, $cookie, $timebox, $app] = $this->getMocks(); $guard = new SessionGuard('default', $provider, $session, $app); @@ -503,9 +503,9 @@ public function testLoginMethodQueuesCookieWhenRemembering() $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('regenerate')->once(); $user = m::mock(Authenticatable::class); - $user->shouldReceive('getAuthIdentifier')->andReturn('foo'); - $user->shouldReceive('getAuthPassword')->andReturn('bar'); - $user->shouldReceive('getRememberToken')->andReturn('recaller'); + $user->shouldReceive('getAuthIdentifier')->times(2)->andReturn('foo'); + $user->shouldReceive('getAuthPassword')->once()->andReturn('bar'); + $user->shouldReceive('getRememberToken')->times(2)->andReturn('recaller'); $user->shouldReceive('setRememberToken')->never(); $provider->shouldReceive('updateRememberToken')->never(); $guard->login($user, true); @@ -531,7 +531,7 @@ public function testLoginMethodQueuesCookieWhenRememberingPasswordlessUser() $guard->login($user, true); } - public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride() + public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride(): void { [$session, $provider, $request, $cookie, $timebox, $app] = $this->getMocks(); $guard = new SessionGuard('default', $provider, $session, $app); @@ -544,9 +544,9 @@ public function testLoginMethodQueuesCookieWhenRememberingAndAllowsOverride() $guard->getSession()->shouldReceive('put')->once()->with($guard->getName(), 'foo'); $session->shouldReceive('regenerate')->once(); $user = m::mock(Authenticatable::class); - $user->shouldReceive('getAuthIdentifier')->andReturn('foo'); - $user->shouldReceive('getAuthPassword')->andReturn('bar'); - $user->shouldReceive('getRememberToken')->andReturn('recaller'); + $user->shouldReceive('getAuthIdentifier')->times(2)->andReturn('foo'); + $user->shouldReceive('getAuthPassword')->once()->andReturn('bar'); + $user->shouldReceive('getRememberToken')->times(2)->andReturn('recaller'); $user->shouldReceive('setRememberToken')->never(); $provider->shouldReceive('updateRememberToken')->never(); $guard->login($user, true); diff --git a/tests/Session/Middleware/AuthenticateSessionTest.php b/tests/Session/Middleware/AuthenticateSessionTest.php index 6115daffa..ed8379568 100644 --- a/tests/Session/Middleware/AuthenticateSessionTest.php +++ b/tests/Session/Middleware/AuthenticateSessionTest.php @@ -7,7 +7,10 @@ use Hypervel\Auth\AuthenticationException; use Hypervel\Auth\AuthManager; use Hypervel\Container\Container; +use Hypervel\Contracts\Auth\Authenticatable; use Hypervel\Contracts\Auth\Factory as AuthFactory; +use Hypervel\Contracts\Auth\Guard; +use Hypervel\Contracts\Auth\StatefulGuard; use Hypervel\Http\Request; use Hypervel\Session\ArraySessionHandler; use Hypervel\Session\Middleware\AuthenticateSession; @@ -27,7 +30,7 @@ public function testHandleWithoutSession(): void $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, $next); - $this->assertEquals('next-1', $response); + $this->assertSame('next-1', $response); } public function testHandleWithSessionWithoutRequestUser(): void @@ -43,13 +46,16 @@ public function testHandleWithSessionWithoutRequestUser(): void $next = fn () => 'next-2'; $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, $next); - $this->assertEquals('next-2', $response); + $this->assertSame('next-2', $response); } public function testHandleWithSessionWithoutAuthPassword(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): ?string { return null; } @@ -69,13 +75,16 @@ public function getAuthPassword() $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, $next); - $this->assertEquals('next-3', $response); + $this->assertSame('next-3', $response); } public function testHandleWithSessionWithUserAuthPasswordOnRequestViaRememberFalse(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): string { return 'my-pass-(*&^%$#!@'; } @@ -88,23 +97,26 @@ public function getAuthPassword() $request->setHypervelSession($session); $authFactory = m::mock(AuthFactory::class); - $authFactory->shouldReceive('viaRemember')->andReturn(false); - $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); - $authFactory->shouldReceive('user')->andReturn(null); + $authFactory->shouldReceive('viaRemember')->once()->andReturn(false); + $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web'); + $authFactory->shouldReceive('user')->once()->andReturn(null); // expected MAC for current password when storing in session: - $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, fn () => 'next-4'); - $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); - $this->assertEquals('next-4', $response); + $this->assertSame('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); + $this->assertSame('next-4', $response); } public function testHandleWithInvalidPasswordHash(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): string { return 'my-pass-(*&^%$#!@'; } @@ -120,13 +132,12 @@ public function getAuthPassword() $request->setHypervelSession($session); $authFactory = m::mock(AuthFactory::class); - $authFactory->shouldReceive('viaRemember')->andReturn(true); + $authFactory->shouldReceive('viaRemember')->once()->andReturn(true); $authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name'); $authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null); - $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); - $authFactory->shouldReceive('user')->andReturn(null); + $authFactory->shouldReceive('getDefaultDriver')->once()->andReturn('web'); // expected MAC for current password (won't match cookie): - $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + $authFactory->shouldReceive('hashPasswordForCookie')->once()->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $this->assertNotNull($session->get('a')); $this->assertNotNull($session->get('b')); @@ -140,9 +151,9 @@ public function getAuthPassword() $middleware->handle($request, fn () => 'next-7'); } catch (AuthenticationException $e) { $message = $e->getMessage(); - $this->assertEquals('i-wanna-go-home', $e->redirectTo($request)); + $this->assertSame('i-wanna-go-home', $e->redirectTo($request)); } - $this->assertEquals('Unauthenticated.', $message); + $this->assertSame('Unauthenticated.', $message); // ensure session is flushed: $this->assertNull($session->get('a')); @@ -192,7 +203,10 @@ public function getAuthPassword(): string public function testHandleWithInvalidIncookiePasswordHashViaRememberTrue(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): string { return 'my-pass-(*&^%$#!@'; } @@ -208,13 +222,12 @@ public function getAuthPassword() $request->setHypervelSession($session); $authFactory = m::mock(AuthFactory::class); - $authFactory->shouldReceive('viaRemember')->andReturn(true); + $authFactory->shouldReceive('viaRemember')->once()->andReturn(true); $authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name'); $authFactory->shouldReceive('logoutCurrentDevice')->once(); - $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); - $authFactory->shouldReceive('user')->andReturn(null); + $authFactory->shouldReceive('getDefaultDriver')->once()->andReturn('web'); // expected MAC for current password (won't match cookie): - $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + $authFactory->shouldReceive('hashPasswordForCookie')->once()->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); $middleware = new AuthenticateSession($authFactory); // act: @@ -224,7 +237,7 @@ public function getAuthPassword() } catch (AuthenticationException $e) { $message = $e->getMessage(); } - $this->assertEquals('Unauthenticated.', $message); + $this->assertSame('Unauthenticated.', $message); // ensure session is flushed $this->assertNull($session->get('password_hash_web')); @@ -235,7 +248,10 @@ public function getAuthPassword() public function testHandleWithValidIncookieInvalidInsessionHashViaRememberTrue(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): string { return 'my-pass-(*&^%$#!@'; } @@ -252,13 +268,12 @@ public function getAuthPassword() $request->setHypervelSession($session); $authFactory = m::mock(AuthFactory::class); - $authFactory->shouldReceive('viaRemember')->andReturn(true); + $authFactory->shouldReceive('viaRemember')->once()->andReturn(true); $authFactory->shouldReceive('getRecallerName')->once()->andReturn('recaller-name'); $authFactory->shouldReceive('logoutCurrentDevice')->once()->andReturn(null); - $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); - $authFactory->shouldReceive('user')->andReturn(null); + $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web'); // expected MAC for current password (matches cookie but not session): - $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); // act: $middleware = new AuthenticateSession($authFactory); @@ -268,7 +283,7 @@ public function getAuthPassword() } catch (AuthenticationException $e) { $message = $e->getMessage(); } - $this->assertEquals('Unauthenticated.', $message); + $this->assertSame('Unauthenticated.', $message); // ensure session is flushed: $this->assertNull($session->get('password_hash_web')); @@ -279,7 +294,10 @@ public function getAuthPassword() public function testHandleWithValidPasswordInSessionCookieIsEmptyGuardHasUser(): void { $user = new class { - public function getAuthPassword() + /** + * Get the user's authentication password. + */ + public function getAuthPassword(): string { return 'my-pass-(*&^%$#!@'; } @@ -296,27 +314,60 @@ public function getAuthPassword() $request->setHypervelSession($session); $authFactory = m::mock(AuthFactory::class); - $authFactory->shouldReceive('viaRemember')->andReturn(false); + $authFactory->shouldReceive('viaRemember')->once()->andReturn(false); $authFactory->shouldReceive('getRecallerName')->never(); $authFactory->shouldReceive('logoutCurrentDevice')->never(); - $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); - $authFactory->shouldReceive('user')->andReturn($user); + $authFactory->shouldReceive('getDefaultDriver')->times(3)->andReturn('web'); + $authFactory->shouldReceive('user')->once()->andReturn($user); // expected MAC for current password: - $authFactory->shouldReceive('hashPasswordForCookie')->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); + $authFactory->shouldReceive('hashPasswordForCookie')->times(2)->with('my-pass-(*&^%$#!@')->andReturn('mac:my-pass-(*&^%$#!@'); // act: $middleware = new AuthenticateSession($authFactory); $response = $middleware->handle($request, fn () => 'next-8'); - $this->assertEquals('next-8', $response); + $this->assertSame('next-8', $response); // ensure session is not flushed: - $this->assertEquals('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); - $this->assertEquals('1', $session->get('a')); - $this->assertEquals('2', $session->get('b')); + $this->assertSame('mac:my-pass-(*&^%$#!@', $session->get('password_hash_web')); + $this->assertSame('1', $session->get('a')); + $this->assertSame('2', $session->get('b')); + } + + public function testGuardOverrideCanReturnAConcreteGuard(): void + { + $user = m::mock(Authenticatable::class); + $user->shouldReceive('getAuthPassword')->andReturn('password-hash'); + + $request = new Request; + $request->setUserResolver(fn () => $user); + $session = new Store('name', new ArraySessionHandler(1)); + $request->setHypervelSession($session); + + $guard = m::mock(StatefulGuard::class); + $guard->shouldReceive('viaRemember')->once()->andReturn(false); + $guard->shouldReceive('hashPasswordForCookie')->twice()->with('password-hash')->andReturn('password-mac'); + $guard->shouldReceive('user')->once()->andReturn(null); + + $authFactory = m::mock(AuthFactory::class); + $authFactory->shouldReceive('guard')->andReturn($guard); + $authFactory->shouldReceive('getDefaultDriver')->andReturn('web'); + + $middleware = new class($authFactory) extends AuthenticateSession { + /** + * Get the guard instance that should be used by the middleware. + */ + protected function guard(): Guard + { + return $this->auth->guard(); + } + }; + + $this->assertSame('next', $middleware->handle($request, fn () => 'next')); + $this->assertSame('password-mac', $session->get('password_hash_web')); } - // REMOVED: Laravel's OldFormatCookie* backward-compatibility tests; - // Hypervel 0.4 is greenfield and only accepts HMAC artifacts. + // REMOVED: Laravel's OldFormatCookie* backward-compatibility tests, + // including guards without hashPasswordForCookie(); only HMAC artifacts are supported. public function testHandleWithRawRememberCookiePasswordHashLogsOut(): void { $user = new class { diff --git a/types/Support/Auth.php b/types/Support/Auth.php new file mode 100644 index 000000000..6717b76e0 --- /dev/null +++ b/types/Support/Auth.php @@ -0,0 +1,26 @@ + Date: Wed, 9 Sep 2026 02:25:14 +0000 Subject: [PATCH 28/32] Remove legacy remember-cookie deserialization Reconcile Laravel #19843, #25167, #25301 and #42316 against framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The remaining Recaller decoding branch supports cookies from the Laravel 5.5/5.6 serialization transition. Current cookie middleware handles encryption serialization symmetrically before Recaller receives the plain value, and Hypervel has no legacy cookies to migrate. With owner approval, assign the constructor string directly and remove the serialized-input compatibility test. Preserve the upstream protected recaller property, all current method signatures and Hypervel cached segment parsing. Extend the existing segment test to cover valid custom fourth segments; retain third-segment-only hash behavior and all other existing assertions. Record the omitted compatibility behavior at its source insertion point. No new format checks, fallback branches or public documentation are needed for this internal legacy path. Normal authentication, custom segments and coroutine-local guard state are unchanged. Validated with the changed Recaller tests, Auth/Cookie/Session suites, full source and type analysis, formatting and final diff checks. Upstream history: https://github.com/laravel/framework/pull/19843 https://github.com/laravel/framework/pull/25167 https://github.com/laravel/framework/pull/25301 https://github.com/laravel/framework/pull/42316 --- src/auth/src/Recaller.php | 3 ++- tests/Auth/RecallerTest.php | 41 ++++++++++++++----------------------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/src/auth/src/Recaller.php b/src/auth/src/Recaller.php index 0d7b6f9d0..9e9742e49 100644 --- a/src/auth/src/Recaller.php +++ b/src/auth/src/Recaller.php @@ -21,7 +21,8 @@ class Recaller */ public function __construct(string $recaller) { - $this->recaller = @unserialize($recaller, ['allowed_classes' => false]) ?: $recaller; + // Cookie middleware owns serialization; legacy cookie decoding is intentionally omitted. + $this->recaller = $recaller; $this->segments = explode('|', $this->recaller); } diff --git a/tests/Auth/RecallerTest.php b/tests/Auth/RecallerTest.php index c48955f95..1f1f57921 100644 --- a/tests/Auth/RecallerTest.php +++ b/tests/Auth/RecallerTest.php @@ -9,101 +9,90 @@ class RecallerTest extends TestCase { - public function testIdReturnsFirstSegment() + public function testIdReturnsFirstSegment(): void { $recaller = new Recaller('123|token|hash'); $this->assertSame('123', $recaller->id()); } - public function testTokenReturnsSecondSegment() + public function testTokenReturnsSecondSegment(): void { $recaller = new Recaller('123|token|hash'); $this->assertSame('token', $recaller->token()); } - public function testHashReturnsThirdSegment() + public function testHashReturnsThirdSegment(): void { $recaller = new Recaller('123|token|hash'); $this->assertSame('hash', $recaller->hash()); } - public function testHashDoesNotIncludeFourthSegment() + public function testHashDoesNotIncludeFourthSegment(): void { $recaller = new Recaller('123|token|hash|extra'); $this->assertSame('hash', $recaller->hash()); } - public function testSegmentsReturnsAllParts() + public function testSegmentsReturnsAllParts(): void { - $recaller = new Recaller('123|token|hash'); + $recaller = new Recaller('123|token|hash|extra'); - $this->assertSame(['123', 'token', 'hash'], $recaller->segments()); + $this->assertSame(['123', 'token', 'hash', 'extra'], $recaller->segments()); + $this->assertTrue($recaller->valid()); } - public function testValidReturnsTrueForProperRecaller() + public function testValidReturnsTrueForProperRecaller(): void { $recaller = new Recaller('123|token|hash'); $this->assertTrue($recaller->valid()); } - public function testValidReturnsFalseWhenNoPipes() + public function testValidReturnsFalseWhenNoPipes(): void { $recaller = new Recaller('invalid'); $this->assertFalse($recaller->valid()); } - public function testValidReturnsFalseWhenOnlyTwoSegments() + public function testValidReturnsFalseWhenOnlyTwoSegments(): void { $recaller = new Recaller('123|token'); $this->assertFalse($recaller->valid()); } - public function testValidReturnsFalseWhenIdIsEmpty() + public function testValidReturnsFalseWhenIdIsEmpty(): void { $recaller = new Recaller('|token|hash'); $this->assertFalse($recaller->valid()); } - public function testValidReturnsFalseWhenTokenIsEmpty() + public function testValidReturnsFalseWhenTokenIsEmpty(): void { $recaller = new Recaller('123||hash'); $this->assertFalse($recaller->valid()); } - public function testValidReturnsFalseWhenIdIsWhitespace() + public function testValidReturnsFalseWhenIdIsWhitespace(): void { $recaller = new Recaller(' |token|hash'); $this->assertFalse($recaller->valid()); } - public function testRawStringFallsBackWhenUnserializeFails() + public function testPlainCookieStringPreservesIdentifierAndToken(): void { - // The constructor attempts unserialize — a non-serialized string - // fails unserialize and falls back to the raw string. $raw = '123|token|hash'; $recaller = new Recaller($raw); $this->assertSame('123', $recaller->id()); $this->assertSame('token', $recaller->token()); } - - public function testSerializedStringIsUnserializedInConstructor() - { - // The constructor successfully unserializes a serialized string. - $raw = '123|token|hash'; - $recaller = new Recaller(serialize($raw)); - - $this->assertSame('123', $recaller->id()); - $this->assertSame('token', $recaller->token()); - } } From 4db0ab726598eef315b1d10edc8aef577fad81ff Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 02:51:32 +0000 Subject: [PATCH 29/32] Port optional query binding masking and complete read/write event coverage Port Laravel framework #61326 from 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. Connections can opt into mask_bindings_in_exception_messages, leaving placeholders in the SQL appended to QueryException messages. Original database errors, bindings, raw SQL accessors and query events retain their existing behavior. Expose DB_MASK_BINDINGS on all five supported SQL connection records, including the separate PostgreSQL pooler endpoint. Normalize the optional boolean once in Connection construction rather than repeating upstream's consumer cast in runQueryCallback. This also covers direct PdoConnection construction and raw configuration values; factory-only normalization would miss that supported path. Refresh already copies the normalized configuration from its fresh connection, without another state field. Retain every upstream masking assertion and extend execution coverage for explicit null and raw string configuration. Keep PDO execution tests on PdoConnection while shared exception construction remains driver-neutral. Document the opt-in setting and its limits, including getRawSql retaining bindings. Connection details from #58218 and #58331 are already covered. Complete #58156's missing nested-listener cases for explicitly selected read and write connections. Preserve each upstream event and query-log assertion and Hypervel's isolated SQLite fixtures. Update the README test badge URL to the current workflow endpoint from #58222. Validation: changed test classes, Database unit suite through ParaTest, scheduling and foundation configuration tests, formatting, full source and type-fixture PHPStan, and diff checks all pass. https://github.com/laravel/framework/pull/61326 https://github.com/laravel/framework/pull/58156 https://github.com/laravel/framework/pull/58222 https://github.com/laravel/framework/pull/58218 https://github.com/laravel/framework/pull/58331 --- README.md | 2 +- src/database/src/Connection.php | 6 ++- src/database/src/QueryException.php | 18 +++++-- src/docs/database.md | 14 +++++ src/foundation/config/database.php | 5 ++ .../DatabaseConnectionFactoryTest.php | 3 +- tests/Database/DatabasePdoConnectionTest.php | 51 +++++++++++++++++++ tests/Database/DatabaseQueryExceptionTest.php | 25 +++++++++ .../Database/DatabaseConnectionsTest.php | 48 ++++++++++------- 9 files changed, 145 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index ac12337b4..a94c6cf15 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@

-Build Status +Build Status Total Downloads Latest Stable Version License diff --git a/src/database/src/Connection.php b/src/database/src/Connection.php index f26b7aa83..c855d21cb 100755 --- a/src/database/src/Connection.php +++ b/src/database/src/Connection.php @@ -215,6 +215,7 @@ public function __construct(string $database = '', string $tablePrefix = '', arr $this->configuredTablePrefix = $tablePrefix; $this->config = $config; + $this->config['mask_bindings_in_exception_messages'] = (bool) ($config['mask_bindings_in_exception_messages'] ?? false); $this->readWriteType = $config[self::READ_WRITE_TYPE_CONFIG_KEY] ?? null; @@ -616,8 +617,8 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal } // If an exception occurs when attempting to run a query, we'll format the error - // message to include the bindings with SQL, which will make this exception a - // lot more helpful to the developer instead of just the database's errors. + // message to include the SQL and, unless masked, its bindings. This provides + // more context for the developer than just the database's original error. catch (CanceledException $exception) { throw $exception; } catch (Exception $e) { @@ -634,6 +635,7 @@ protected function runQueryCallback(string $query, array $bindings, Closure $cal $e, $this->getConnectionDetails(), $this->latestReadWriteTypeUsed(), + $this->getConfig('mask_bindings_in_exception_messages'), ); if ($isUniqueConstraintError && $queryException instanceof UniqueConstraintViolationException) { diff --git a/src/database/src/QueryException.php b/src/database/src/QueryException.php index e12135efd..881c97b50 100644 --- a/src/database/src/QueryException.php +++ b/src/database/src/QueryException.php @@ -49,7 +49,8 @@ public function __construct( array $bindings, Throwable $previous, array $connectionDetails = [], - ?string $readWriteType = null + ?string $readWriteType = null, + bool $maskBindings = false ) { parent::__construct('', 0, $previous); @@ -59,7 +60,7 @@ public function __construct( $this->connectionDetails = $connectionDetails; $this->readWriteType = $readWriteType; $this->code = $previous->getCode(); - $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous); + $this->message = $this->formatMessage($connectionName, $sql, $bindings, $previous, $maskBindings); if ($previous instanceof PDOException) { $this->errorInfo = $previous->errorInfo; @@ -69,11 +70,18 @@ public function __construct( /** * Format the SQL error message. */ - protected function formatMessage(?string $connectionName, string $sql, array $bindings, Throwable $previous): string - { + protected function formatMessage( + ?string $connectionName, + string $sql, + array $bindings, + Throwable $previous, + bool $maskBindings = false + ): string { $details = $this->formatConnectionDetails(); - return $previous->getMessage() . ' (Connection: ' . $connectionName . $details . ', SQL: ' . Str::replaceArray('?', $bindings, $sql) . ')'; + $sql = $maskBindings ? $sql : Str::replaceArray('?', $bindings, $sql); + + return $previous->getMessage() . ' (Connection: ' . $connectionName . $details . ', SQL: ' . $sql . ')'; } /** diff --git a/src/docs/database.md b/src/docs/database.md index 2259c4c55..545914413 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -2,6 +2,7 @@ - [Introduction](#introduction) - [Configuration](#configuration) + - [Masking Bindings in Exception Messages](#masking-bindings-in-exception-messages) - [Lock Timeouts](#lock-timeouts) - [Read and Write Connections](#read-and-write-connections) - [Connection Pooling](#connection-pooling) @@ -54,6 +55,19 @@ By default, foreign key constraints are enabled for SQLite connections. If you w DB_FOREIGN_KEYS=false ``` + +#### Masking Bindings in Exception Messages + +By default, database exceptions include bound values in the SQL shown in their messages. To leave placeholders in that SQL instead, set the `DB_MASK_BINDINGS` environment variable: + +```ini +DB_MASK_BINDINGS=true +``` + +For custom connections, set `mask_bindings_in_exception_messages` to `true` in the connection's configuration. Omitting this option or setting it to `null` disables masking. + +This option does not change the database's original error message, query logs, or query events. The exception's binding accessors remain available, and `getRawSql()` still returns SQL with the bindings included. + #### Lock Timeouts diff --git a/src/foundation/config/database.php b/src/foundation/config/database.php index eb6a04c6d..988afbce7 100644 --- a/src/foundation/config/database.php +++ b/src/foundation/config/database.php @@ -51,6 +51,7 @@ 'database' => env('DB_DATABASE', database_path('database.sqlite')), 'prefix' => '', 'prefix_indexes' => null, + 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false), 'foreign_key_constraints' => (bool) env('DB_FOREIGN_KEYS', true), 'busy_timeout' => null, 'journal_mode' => null, @@ -72,6 +73,7 @@ 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => env('DB_PREFIX', ''), 'prefix_indexes' => true, + 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false), 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ @@ -102,6 +104,7 @@ 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), 'prefix' => env('DB_PREFIX', ''), 'prefix_indexes' => true, + 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false), 'strict' => true, 'engine' => null, 'options' => extension_loaded('pdo_mysql') ? array_filter([ @@ -130,6 +133,7 @@ 'charset' => env('DB_CHARSET', 'utf8'), 'prefix' => env('DB_PREFIX', ''), 'prefix_indexes' => true, + 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false), 'search_path' => 'public', 'sslmode' => env('DB_SSLMODE', 'prefer'), 'options' => [ @@ -158,6 +162,7 @@ 'charset' => env('DB_CHARSET', 'utf8'), 'prefix' => env('DB_PREFIX', ''), 'prefix_indexes' => true, + 'mask_bindings_in_exception_messages' => (bool) env('DB_MASK_BINDINGS', false), 'search_path' => 'public', 'sslmode' => env('DB_POOLED_SSLMODE', env('DB_SSLMODE', 'prefer')), 'options' => [ diff --git a/tests/Database/DatabaseConnectionFactoryTest.php b/tests/Database/DatabaseConnectionFactoryTest.php index 664d71ea1..75e9442be 100755 --- a/tests/Database/DatabaseConnectionFactoryTest.php +++ b/tests/Database/DatabaseConnectionFactoryTest.php @@ -59,7 +59,7 @@ public function testConnectionCanBeCreated() $this->assertInstanceOf(PDO::class, $this->db->getConnection('url')->getReadPdo()); } - public function testConnectionFromUrlHasProperConfig() + public function testConnectionFromUrlHasProperConfig(): void { $this->db->addConnection([ 'url' => 'mysql://root:pass@db/local?strict=true', @@ -86,6 +86,7 @@ public function testConnectionFromUrlHasProperConfig() 'prefix_indexes' => true, 'strict' => true, 'engine' => null, + 'mask_bindings_in_exception_messages' => false, ], $this->db->getConnection('url-config')->getConfig()); } diff --git a/tests/Database/DatabasePdoConnectionTest.php b/tests/Database/DatabasePdoConnectionTest.php index a2db98468..d759169d4 100755 --- a/tests/Database/DatabasePdoConnectionTest.php +++ b/tests/Database/DatabasePdoConnectionTest.php @@ -734,6 +734,57 @@ public function testOnLostConnectionPDOIsNotSwappedWithinATransaction(): void $connection->statement('foo'); } + public function testQueryExceptionEmbedsBindingsByDefault(): void + { + foreach ([[], ['mask_bindings_in_exception_messages' => null]] as $config) { + $connection = new PdoConnection($this->getFailingPdo(), '', '', $config); + + try { + $connection->statement('SELECT * FROM users WHERE email = ?', ['foo@example.com']); + + $this->fail('A QueryException was not thrown.'); + } catch (QueryException $e) { + $this->assertStringContainsString('SQL: SELECT * FROM users WHERE email = foo@example.com', $e->getMessage()); + } + } + } + + public function testQueryExceptionMasksBindingsWhenEnabledOnTheConnection(): void + { + foreach ([true, '1'] as $maskBindings) { + $connection = new PdoConnection($this->getFailingPdo(), '', '', [ + 'mask_bindings_in_exception_messages' => $maskBindings, + ]); + + try { + $connection->statement('SELECT * FROM users WHERE email = ?', ['foo@example.com']); + + $this->fail('A QueryException was not thrown.'); + } catch (QueryException $e) { + $this->assertStringContainsString('SQL: SELECT * FROM users WHERE email = ?', $e->getMessage()); + $this->assertStringNotContainsString('foo@example.com', $e->getMessage()); + $this->assertSame(['foo@example.com'], $e->getBindings()); + } + } + } + + /** + * Create a PDO connection whose statement execution fails. + */ + protected function getFailingPdo(): PDO + { + $statement = m::mock(PDOStatement::class); + $statement->shouldReceive('bindValue')->once(); + $statement->shouldReceive('execute')->once()->andThrow( + new PDOException('SQLSTATE[42S02]: Base table or view not found') + ); + + $pdo = m::mock(PDO::class); + $pdo->shouldReceive('prepare')->once()->andReturn($statement); + + return $pdo; + } + public function testOnLostConnectionPDOIsSwappedOutsideTransaction(): void { $pdo = m::mock(PDO::class); diff --git a/tests/Database/DatabaseQueryExceptionTest.php b/tests/Database/DatabaseQueryExceptionTest.php index 405dac094..ce7912317 100755 --- a/tests/Database/DatabaseQueryExceptionTest.php +++ b/tests/Database/DatabaseQueryExceptionTest.php @@ -151,6 +151,31 @@ public function testBackwardCompatibilityWithoutConnectionInfo() $this->assertSame([], $exception->getConnectionDetails()); } + public function testBindingsAreEmbeddedInTheMessageByDefault(): void + { + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException); + + $this->assertSame('Mock SQL error (Connection: mysql, SQL: SELECT * FROM users WHERE email = foo@example.com)', $exception->getMessage()); + } + + public function testBindingsCanBeMaskedInTheMessage(): void + { + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException, [], null, true); + + $this->assertSame('Mock SQL error (Connection: mysql, SQL: SELECT * FROM users WHERE email = ?)', $exception->getMessage()); + } + + public function testMaskingBindingsDoesNotAffectTheAccessors(): void + { + $pdoException = new PDOException('Mock SQL error'); + $exception = new QueryException('mysql', 'SELECT * FROM users WHERE email = ?', ['foo@example.com'], $pdoException, [], null, true); + + $this->assertSame(['foo@example.com'], $exception->getBindings()); + $this->assertSame('SELECT * FROM users WHERE email = ?', $exception->getSql()); + } + protected function getMockConnection() { $connection = m::mock(Connection::class); diff --git a/tests/Integration/Database/DatabaseConnectionsTest.php b/tests/Integration/Database/DatabaseConnectionsTest.php index 1073908f7..c5c4388e0 100644 --- a/tests/Integration/Database/DatabaseConnectionsTest.php +++ b/tests/Integration/Database/DatabaseConnectionsTest.php @@ -10,6 +10,7 @@ use Hypervel\Support\Facades\DB; use Hypervel\Testing\ParallelTesting; use InvalidArgumentException; +use PHPUnit\Framework\Attributes\DataProvider; class DatabaseConnectionsTest extends DatabaseTestCase { @@ -183,9 +184,10 @@ public function testQueryExceptionsProvideReadWriteType(): void } } - public function testQueryInEventListenerCannotInterfereWithReadWriteType(): void + #[DataProvider('readWriteExpectations')] + public function testQueryInEventListenerCannotInterfereWithReadWriteType(string $connectionName, array $expectedTypes, ?string $loggedType): void { - $connection = DB::connection('sqlite_readwrite'); + $connection = DB::connection($connectionName); $events = collect(); $connection->listen($events->push(...)); @@ -198,32 +200,42 @@ public function testQueryInEventListenerCannotInterfereWithReadWriteType(): void }); $connection->statement('select 1'); - $this->assertSame('write', $events->shift()->readWriteType); - $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); $connection->select('select 1'); - $this->assertSame('read', $events->shift()->readWriteType); - $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); $connection->statement('select 1'); - $this->assertSame('write', $events->shift()->readWriteType); - $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); $connection->select('select 1'); - $this->assertSame('read', $events->shift()->readWriteType); - $this->assertSame('read', $events->shift()->readWriteType); + $this->assertSame(array_shift($expectedTypes), $events->shift()->readWriteType); + $this->assertSame($loggedType ?? 'read', $events->shift()->readWriteType); $this->assertSame([ - ['query' => 'select 2', 'readWriteType' => 'read'], - ['query' => 'select 1', 'readWriteType' => 'write'], - ['query' => 'select 2', 'readWriteType' => 'read'], - ['query' => 'select 1', 'readWriteType' => 'read'], - ['query' => 'select 2', 'readWriteType' => 'read'], - ['query' => 'select 1', 'readWriteType' => 'write'], - ['query' => 'select 2', 'readWriteType' => 'read'], - ['query' => 'select 1', 'readWriteType' => 'read'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'write'], + ['query' => 'select 2', 'readWriteType' => $loggedType ?? 'read'], + ['query' => 'select 1', 'readWriteType' => $loggedType ?? 'read'], ], Arr::select($connection->getQueryLog(), [ 'query', 'readWriteType', ])); } + + /** + * Provide the expected query roles for split connections. + */ + public static function readWriteExpectations(): iterable + { + yield 'sqlite' => ['sqlite_readwrite', ['write', 'read', 'write', 'read'], null]; + yield 'sqlite::read' => ['sqlite_readwrite::read', ['read', 'read', 'read', 'read'], 'read']; + yield 'sqlite::write' => ['sqlite_readwrite::write', ['write', 'write', 'write', 'write'], 'write']; + } } From c5416dc18213b6f6e8a358c9b9be018bba970a28 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:13:14 +0000 Subject: [PATCH 30/32] Fix session enum key deletion and complete helper support Complete the session enum-key port from Laravel framework PR #58241, including the already-covered additional methods and tests in #58343 and #58459, against 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. A single backed enum was cast to an array before forget(), exposing both its case name and backing value as deletion targets. Wrap the enum as one key before normalization so unrelated case-name entries survive. Use the existing array primitives instead of allocating a collection, and preserve Hypervel's coroutine-local attribute access. Pinned Laravel shares this bug. Allow enum keys through the global session() helper, matching the store and Laravel's forwarding behavior. Correct its conditional return metadata without adding runtime normalization or changing string/array/null paths. Extend the existing upstream deletion test with the colliding key and add a helper regression using existing enum fixtures. Remove the false test comment claiming PHP permits enums as array keys. Document enum session keys and the helper with a short public example; pinned Laravel docs have no corresponding enum-key section. Both regressions fail before their fixes and pass afterward. Session tests, foundation helper tests, Blade session coverage, full source/type analysis, formatting and diff checks pass. Existing upstream assertions are retained. https://github.com/laravel/framework/pull/58241 https://github.com/laravel/framework/pull/58343 https://github.com/laravel/framework/pull/58459 --- src/docs/session.md | 20 +++++++++++++++++++- src/foundation/src/helpers.php | 4 ++-- src/session/src/Store.php | 4 +++- tests/Foundation/FoundationHelpersTest.php | 10 ++++++++++ tests/Session/SessionStoreBackedEnumTest.php | 6 ------ tests/Session/SessionStoreTest.php | 2 ++ 6 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/docs/session.md b/src/docs/session.md index fbffced25..0bd82c3c7 100644 --- a/src/docs/session.md +++ b/src/docs/session.md @@ -133,7 +133,7 @@ $value = $request->session()->get('key', function () { #### The Global Session Helper -You may also use the global `session` PHP function to retrieve and store data in the session. When the `session` helper is called with a single, string argument, it will return the value of that session key. When the helper is called with an array of key / value pairs, those values will be stored in the session: +You may also use the global `session` PHP function to retrieve and store data in the session. When the `session` helper is called with a single string or enum argument, it will return the value of that session key. When the helper is called with an array of key / value pairs, those values will be stored in the session: ```php Route::get('/home', function () { @@ -151,6 +151,24 @@ Route::get('/home', function () { > [!NOTE] > There is little practical difference between using the session via an HTTP request instance versus using the global `session` helper. Both methods are [testable](/docs/{{version}}/testing) via the `assertSessionHas` method which is available in all of your test cases. + +#### Enum Session Keys + +You may use enums as session keys. Backed enums use their value as the key, while unbacked enums use their case name: + +```php +enum SessionKey: string +{ + case Cart = 'cart'; +} + +session()->put(SessionKey::Cart, $items); + +$items = session(SessionKey::Cart); + +session()->forget(SessionKey::Cart); +``` + #### Retrieving All Session Data diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php index a1e2fcb57..e011e6479 100644 --- a/src/foundation/src/helpers.php +++ b/src/foundation/src/helpers.php @@ -862,9 +862,9 @@ function secure_url(string $path, mixed $parameters = []): string * * If an array is passed as the key, we will assume you want to set an array of values. * - * @return ($key is null ? SessionManager : ($key is string ? mixed : null)) + * @return ($key is null ? SessionManager : ($key is array ? null : mixed)) */ - function session(array|string|null $key = null, mixed $default = null): mixed + function session(array|UnitEnum|string|null $key = null, mixed $default = null): mixed { $session = app('session'); diff --git a/src/session/src/Store.php b/src/session/src/Store.php index adadb4c2a..c512eae97 100644 --- a/src/session/src/Store.php +++ b/src/session/src/Store.php @@ -567,7 +567,9 @@ public function remove(UnitEnum|string $key): mixed public function forget(array|UnitEnum|string $keys): void { $attributes = $this->getAttributes(); - Arr::forget($attributes, collect((array) $keys)->map(fn ($key) => enum_value($key))->all()); + + // Casting an enum to an array would make its name and value separate keys to remove. + Arr::forget($attributes, array_map(enum_value(...), Arr::wrap($keys))); $this->setAttributes($attributes); } diff --git a/tests/Foundation/FoundationHelpersTest.php b/tests/Foundation/FoundationHelpersTest.php index 4174f9074..5ed42db5a 100644 --- a/tests/Foundation/FoundationHelpersTest.php +++ b/tests/Foundation/FoundationHelpersTest.php @@ -223,6 +223,16 @@ public function testCache(): void $this->assertSame('default', cache('baz', 'default')); } + public function testSessionAcceptsEnumKeys(): void + { + session(['America/New_York' => 'string-backed', 1 => 'integer-backed', 'UTC' => 'unit']); + + $this->assertSame('string-backed', session(StringEnum::NewYork)); + $this->assertSame('integer-backed', session(IntEnum::One)); + $this->assertSame('unit', session(UnitEnum::UTC)); + $this->assertSame('default', session(UnitEnum::EST, 'default')); + } + public function testLogsResolvesAChannelNamedZero(): void { $manager = m::mock(LogManager::class); diff --git a/tests/Session/SessionStoreBackedEnumTest.php b/tests/Session/SessionStoreBackedEnumTest.php index 0e98dac1f..7933738b7 100644 --- a/tests/Session/SessionStoreBackedEnumTest.php +++ b/tests/Session/SessionStoreBackedEnumTest.php @@ -85,12 +85,6 @@ public function testPutWithArrayOfStringKeys(): void $this->assertSame('abc123', $session->get(SessionKey::Token)); } - /** - * Test that put() normalizes enum keys in arrays. - * Note: PHP auto-converts BackedEnums to their values when used as array keys, - * so by the time the array reaches put(), keys are already strings. - * This test verifies the overall behavior works correctly. - */ public function testPutWithMixedArrayKeysUsingEnumValues(): void { $session = $this->getSession(); diff --git a/tests/Session/SessionStoreTest.php b/tests/Session/SessionStoreTest.php index 72f1a6f0f..33a332443 100644 --- a/tests/Session/SessionStoreTest.php +++ b/tests/Session/SessionStoreTest.php @@ -818,10 +818,12 @@ public function testBackedEnumKeyForget(): void { $session = $this->getSession(); $session->put(SessionTestKey::User, 'Taylor'); + $session->put('User', 'keep'); $this->assertTrue($session->has('user')); $session->forget(SessionTestKey::User); $this->assertFalse($session->has('user')); + $this->assertSame('keep', $session->get('User')); $session->put(SessionTestKey::User, 'Taylor'); $session->put(SessionTestKey::Settings, 'dark-mode'); From 3e94a67a142307cbb5ee27a2aadab1c6827da171 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 03:51:05 +0000 Subject: [PATCH 31/32] Complete model inspection types and process assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the current Laravel model-inspection metadata, collection-operation changes, enum unions and process assertion APIs. Ordered assertions check the recording count before individual commands; array commands retain strict comparison against the original invocation. Port the current ordered, array and falsy-command regressions and the matching process documentation. Preserve Hypervel’s native reserved-name array with array_any, use short-circuit collection predicates without intermediate filtered collections, and retain pooled/runtime adaptations in model inspection, logging and SES v2. BackedEnum remains accepted through UnitEnum. ModelInfo uses native property types and a class-owned model template shared with its array representation, correcting upstream’s collection arity and constructor/return metadata mismatch without runtime workarounds. Upstream: https://github.com/laravel/framework/pull/58230 https://github.com/laravel/framework/pull/60745 https://github.com/laravel/framework/pull/53563 https://github.com/laravel/framework/pull/58818 https://github.com/laravel/framework/pull/61193 https://github.com/laravel/framework/pull/61197 https://github.com/laravel/framework/pull/60947 Ported from Laravel framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2 and docs at 2914ba0b06c6be40c2f1f992555853f6266707d6. Verified ModelInspector on SQLite, ProcessTest, affected parallel tests, generated facade consistency, formatting and full source/type analysis. Peer review independently verified the full affected package suites. --- src/broadcasting/src/AnonymousEvent.php | 3 +- src/console/src/GeneratorCommand.php | 7 +- src/database/src/Eloquent/Collection.php | 2 +- src/database/src/Eloquent/ModelInfo.php | 58 ++++++++++---- src/docs/processes.md | 22 +++++ src/log/src/LogManager.php | 8 +- src/mail/src/Transport/SesV2Transport.php | 5 +- src/process/src/Factory.php | 63 ++++++++++++--- src/support/src/Facades/Process.php | 9 ++- src/support/src/Testing/Fakes/BusFake.php | 16 ++-- src/support/src/Testing/Fakes/QueueFake.php | 6 +- src/validation/src/Rules/Contains.php | 3 +- src/validation/src/Rules/DoesntContain.php | 3 +- .../Database/ModelInspectorTest.php | 26 ++++-- tests/Process/ProcessTest.php | 80 +++++++++++++++++++ 15 files changed, 246 insertions(+), 65 deletions(-) diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php index 28da52f06..f5e34c2bd 100644 --- a/src/broadcasting/src/AnonymousEvent.php +++ b/src/broadcasting/src/AnonymousEvent.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Support\Arrayable; use Hypervel\Foundation\Events\Dispatchable; use Hypervel\Support\Arr; +use Hypervel\Support\Collection; class AnonymousEvent implements ShouldBroadcast { @@ -75,7 +76,7 @@ public function with(array|Arrayable $payload): static { $this->payload = $payload instanceof Arrayable ? $payload->toArray() - : collect($payload)->map( + : (new Collection($payload))->map( fn ($p) => $p instanceof Arrayable ? $p->toArray() : $p )->all(); diff --git a/src/console/src/GeneratorCommand.php b/src/console/src/GeneratorCommand.php index bff08873d..60225dbcf 100644 --- a/src/console/src/GeneratorCommand.php +++ b/src/console/src/GeneratorCommand.php @@ -485,10 +485,9 @@ protected function userProviderModel(): ?string */ protected function isReservedName(string $name): bool { - return in_array( - strtolower($name), - array_map('strtolower', $this->reservedNames) - ); + $name = strtolower($name); + + return array_any($this->reservedNames, fn ($reservedName) => strtolower($reservedName) === $name); } /** diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 285f2aede..4fdda4fa1 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -993,7 +993,7 @@ public function toQuery(): Builder $class = get_class($model); - if ($this->reject(fn ($model) => $model instanceof $class)->isNotEmpty()) { + if ($this->contains(fn ($model) => ! $model instanceof $class)) { throw new LogicException('Unable to create query for collection with mixed types.'); } diff --git a/src/database/src/Eloquent/ModelInfo.php b/src/database/src/Eloquent/ModelInfo.php index 62f053efc..dfc1ed2a8 100644 --- a/src/database/src/Eloquent/ModelInfo.php +++ b/src/database/src/Eloquent/ModelInfo.php @@ -12,12 +12,18 @@ use LogicException; /** + * @template TModel of Model = Model + * * @implements Arrayable + * + * @internal */ class ModelInfo implements Arrayable, ArrayAccess { /** - * @param class-string $class the model's fully-qualified class + * Create a new model info instance. + * + * @param class-string $class the model's fully-qualified class * @param null|string $database the database connection name * @param string $table the database table name * @param null|class-string $policy the policy that applies to the model @@ -25,22 +31,22 @@ class ModelInfo implements Arrayable, ArrayAccess * @param BaseCollection}> $relations the relations defined on the model * @param BaseCollection $events the events that the model dispatches * @param BaseCollection}> $observers the observers registered for the model - * @param class-string> $collection the Collection class that collects the models - * @param class-string> $builder the Builder class registered for the model + * @param class-string> $collection the Collection class that collects the models + * @param class-string> $builder the Builder class registered for the model * @param null|class-string $resource the JSON resource class that represents the model */ public function __construct( - public $class, - public $database, - public $table, - public $policy, - public $attributes, - public $relations, - public $events, - public $observers, - public $collection, - public $builder, - public $resource + public string $class, + public ?string $database, + public string $table, + public ?string $policy, + public BaseCollection $attributes, + public BaseCollection $relations, + public BaseCollection $events, + public BaseCollection $observers, + public string $collection, + public string $builder, + public ?string $resource ) { } @@ -48,7 +54,7 @@ public function __construct( * Convert the model info to an array. * * @return array{ - * "class": class-string, + * "class": class-string, * database: null|string, * table: string, * policy: null|class-string, @@ -56,8 +62,8 @@ public function __construct( * relations: BaseCollection}>, * events: BaseCollection, * observers: BaseCollection}>, - * collection: class-string>, - * builder: class-string>, + * collection: class-string>, + * builder: class-string>, * resource: null|class-string * } */ @@ -78,21 +84,39 @@ public function toArray(): array ]; } + /** + * Determine if the given offset exists. + */ public function offsetExists(mixed $offset): bool { return property_exists($this, $offset); } + /** + * Get the value for a given offset. + * + * @throws InvalidArgumentException + */ public function offsetGet(mixed $offset): mixed { return property_exists($this, $offset) ? $this->{$offset} : throw new InvalidArgumentException("Property {$offset} does not exist."); } + /** + * Set the value at the given offset. + * + * @throws LogicException + */ public function offsetSet(mixed $offset, mixed $value): void { throw new LogicException(self::class . ' may not be mutated using array access.'); } + /** + * Unset the value at the given offset. + * + * @throws LogicException + */ public function offsetUnset(mixed $offset): void { throw new LogicException(self::class . ' may not be mutated using array access.'); diff --git a/src/docs/processes.md b/src/docs/processes.md index d04b5afcb..5b8ea1444 100644 --- a/src/docs/processes.md +++ b/src/docs/processes.md @@ -766,6 +766,14 @@ use Hypervel\Support\Facades\Process; Process::assertRan('ls -la'); ``` +When the process was invoked with an array of arguments, you may pass the same array to the assertion: + +```php +Process::assertRan(['php', 'artisan', 'migrate']); +``` + +The `assertRanTimes` and `assertDidntRun` methods also accept array commands. + The `assertRan` method also accepts a closure, which will receive an instance of a process and a process result, allowing you to inspect the process' configured options. If this closure returns `true`, the assertion will "pass": ```php @@ -818,6 +826,20 @@ Process::assertRanTimes(function (PendingProcess $process, ProcessResult $result }, times: 3); ``` + +#### assertRanInOrder + +Assert that processes were invoked in a given order: + +```php +Process::assertRanInOrder([ + 'git fetch', + 'composer install', +]); +``` + +The `assertRanInOrder` method accepts command strings, arrays of command arguments, or closures like the other process assertions. + #### assertNothingRan diff --git a/src/log/src/LogManager.php b/src/log/src/LogManager.php index ecdce17a8..3570568bb 100644 --- a/src/log/src/LogManager.php +++ b/src/log/src/LogManager.php @@ -263,13 +263,13 @@ protected function createStackDriver(array $config): LoggerInterface $config['channels'] = explode(',', $config['channels']); } - $handlers = Collection::make($config['channels'])->flatMap(function ($channel) { + $handlers = (new Collection($config['channels']))->flatMap(function ($channel) { return $channel instanceof LoggerInterface ? $channel->getHandlers() // @phpstan-ignore-line : $this->channel($channel)->getHandlers(); // @phpstan-ignore-line })->all(); - $processors = Collection::make($config['channels'])->flatMap(function ($channel) { + $processors = (new Collection($config['channels']))->flatMap(function ($channel) { return $channel instanceof LoggerInterface ? $channel->getProcessors() // @phpstan-ignore-line : $this->channel($channel)->getProcessors(); // @phpstan-ignore-line @@ -382,7 +382,7 @@ protected function createMonologDriver(array $config): LoggerInterface ); } - Collection::make($config['processors'] ?? [])->each(function ($processor) { + (new Collection($config['processors'] ?? []))->each(function ($processor) { $processor = $processor['processor'] ?? $processor; if (! is_a($processor, ProcessorInterface::class, true)) { @@ -409,7 +409,7 @@ protected function createMonologDriver(array $config): LoggerInterface $config ); - $processors = Collection::make($config['processors'] ?? []) + $processors = (new Collection($config['processors'] ?? [])) ->map(function ($processor) { $resolved = $this->app->make( $processor['processor'] ?? $processor, diff --git a/src/mail/src/Transport/SesV2Transport.php b/src/mail/src/Transport/SesV2Transport.php index 5c605d8a8..95c9cced8 100644 --- a/src/mail/src/Transport/SesV2Transport.php +++ b/src/mail/src/Transport/SesV2Transport.php @@ -6,6 +6,7 @@ use Aws\Exception\AwsException; use Aws\SesV2\SesV2Client; +use Hypervel\Support\Collection; use Stringable; use Symfony\Component\Mailer\Exception\TransportException; use Symfony\Component\Mailer\Header\MetadataHeader; @@ -26,6 +27,8 @@ public function __construct( } /** + * Send the given message. + * * @throws TransportException */ protected function doSend(SentMessage $message): void @@ -55,7 +58,7 @@ protected function doSend(SentMessage $message): void [ 'Source' => $message->getEnvelope()->getSender()->toString(), 'Destination' => [ - 'ToAddresses' => collect($message->getEnvelope()->getRecipients()) + 'ToAddresses' => (new Collection($message->getEnvelope()->getRecipients())) ->map ->toString() ->values() // @phpstan-ignore method.nonObject (HigherOrderProxy: ->map->toString() returns Collection, not string) diff --git a/src/process/src/Factory.php b/src/process/src/Factory.php index 33dce94e8..96fb8501e 100644 --- a/src/process/src/Factory.php +++ b/src/process/src/Factory.php @@ -157,15 +157,17 @@ public function preventingStrayProcesses(): bool /** * Assert that a process was recorded matching a given truth test. + * + * @param array|Closure|string $callback */ - public function assertRan(Closure|string $callback): static + public function assertRan(Closure|array|string $callback): static { - $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback; + $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback; PHPUnit::assertTrue( - (new Collection($this->recorded))->filter(function ($pair) use ($callback) { + (new Collection($this->recorded))->contains(function ($pair) use ($callback) { return $callback($pair[0], $pair[1]); - })->count() > 0, + }), 'An expected process was not invoked.' ); @@ -174,10 +176,12 @@ public function assertRan(Closure|string $callback): static /** * Assert that a process was recorded a given number of times matching a given truth test. + * + * @param array|Closure|string $callback */ - public function assertRanTimes(Closure|string $callback, int $times = 1): static + public function assertRanTimes(Closure|array|string $callback, int $times = 1): static { - $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback; + $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback; $count = (new Collection($this->recorded)) ->filter(fn ($pair) => $callback($pair[0], $pair[1])) @@ -192,17 +196,52 @@ public function assertRanTimes(Closure|string $callback, int $times = 1): static return $this; } + /** + * Assert that the given processes were run in the given order. + * + * @param list|Closure|string> $callbacks + */ + public function assertRanInOrder(array $callbacks): static + { + $this->assertRanCount(count($callbacks)); + + foreach ($callbacks as $index => $callback) { + $callback = $callback instanceof Closure + ? $callback + : fn ($process) => $process->command === $callback; + + PHPUnit::assertTrue( + $callback($this->recorded[$index][0], $this->recorded[$index][1]), + 'An expected process (#' . ($index + 1) . ') was not invoked.' + ); + } + + return $this; + } + + /** + * Assert how many processes have been recorded. + */ + protected function assertRanCount(int $count): static + { + PHPUnit::assertCount($count, $this->recorded); + + return $this; + } + /** * Assert that a process was not recorded matching a given truth test. + * + * @param array|Closure|string $callback */ - public function assertNotRan(Closure|string $callback): static + public function assertNotRan(Closure|array|string $callback): static { - $callback = is_string($callback) ? fn ($process) => $process->command === $callback : $callback; + $callback = $callback instanceof Closure ? $callback : fn ($process) => $process->command === $callback; PHPUnit::assertTrue( - (new Collection($this->recorded))->filter(function ($pair) use ($callback) { + (new Collection($this->recorded))->doesntContain(function ($pair) use ($callback) { return $callback($pair[0], $pair[1]); - })->count() === 0, + }), 'An unexpected process was invoked.' ); @@ -211,8 +250,10 @@ public function assertNotRan(Closure|string $callback): static /** * Assert that a process was not recorded matching a given truth test. + * + * @param array|Closure|string $callback */ - public function assertDidntRun(Closure|string $callback): static + public function assertDidntRun(Closure|array|string $callback): static { return $this->assertNotRan($callback); } diff --git a/src/support/src/Facades/Process.php b/src/support/src/Facades/Process.php index 8c9d17533..5844c80ef 100644 --- a/src/support/src/Facades/Process.php +++ b/src/support/src/Facades/Process.php @@ -24,11 +24,12 @@ * @method static mixed unless(mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static mixed when(mixed $value = null, null|callable $callback = null, null|callable $default = null) * @method static \Hypervel\Process\PendingProcess withFakeHandlers(array $fakeHandlers) - * @method static \Hypervel\Process\Factory assertDidntRun(Closure|string $callback) + * @method static \Hypervel\Process\Factory assertDidntRun(array|Closure|string $callback) * @method static \Hypervel\Process\Factory assertNothingRan() - * @method static \Hypervel\Process\Factory assertNotRan(Closure|string $callback) - * @method static \Hypervel\Process\Factory assertRan(Closure|string $callback) - * @method static \Hypervel\Process\Factory assertRanTimes(Closure|string $callback, int $times = 1) + * @method static \Hypervel\Process\Factory assertNotRan(array|Closure|string $callback) + * @method static \Hypervel\Process\Factory assertRan(array|Closure|string $callback) + * @method static \Hypervel\Process\Factory assertRanInOrder(array|Closure|string> $callbacks) + * @method static \Hypervel\Process\Factory assertRanTimes(array|Closure|string $callback, int $times = 1) * @method static \Hypervel\Process\ProcessPoolResults concurrently(callable $callback, callable|null $output = null) * @method static \Hypervel\Process\FakeProcessDescription describe() * @method static void flushMacros() diff --git a/src/support/src/Testing/Fakes/BusFake.php b/src/support/src/Testing/Fakes/BusFake.php index 77bdb4f69..b84fbb7bd 100644 --- a/src/support/src/Testing/Fakes/BusFake.php +++ b/src/support/src/Testing/Fakes/BusFake.php @@ -375,7 +375,7 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex $chain = $expectedChain; PHPUnit::assertTrue( - $this->dispatched($command, $callback)->filter(function ($job) use ($chain) { + $this->dispatched($command, $callback)->contains(function ($job) use ($chain) { if (count($chain) !== count($job->chained)) { return false; } @@ -412,7 +412,7 @@ protected function assertDispatchedWithChainOfObjects(string $command, array $ex } return true; - })->isNotEmpty(), + }), 'The expected chain was not dispatched.' ); } @@ -701,12 +701,12 @@ protected function shouldFakeJob(mixed $command): bool return true; } - return Collection::make($this->jobsToFake) - ->filter(function ($job) use ($command) { + return (new Collection($this->jobsToFake)) + ->contains(function ($job) use ($command) { return $job instanceof Closure ? $job($command) : $job === get_class($command); - })->isNotEmpty(); + }); } /** @@ -714,12 +714,12 @@ protected function shouldFakeJob(mixed $command): bool */ protected function shouldDispatchCommand(mixed $command): bool { - return Collection::make($this->jobsToDispatch) - ->filter(function ($job) use ($command) { + return (new Collection($this->jobsToDispatch)) + ->contains(function ($job) use ($command) { return $job instanceof Closure ? $job($command) : $job === get_class($command); - })->isNotEmpty(); + }); } /** diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php index 9f61cad4f..7a261de01 100644 --- a/src/support/src/Testing/Fakes/QueueFake.php +++ b/src/support/src/Testing/Fakes/QueueFake.php @@ -224,10 +224,10 @@ public function assertPushedWithoutChain(string $job, ?callable $callback = null */ protected function assertPushedWithChainOfObjects(string $job, array $expectedChain, ?callable $callback): void { - $chain = Collection::make($expectedChain)->map(fn ($job) => serialize($job))->all(); + $chain = (new Collection($expectedChain))->map(fn ($job) => serialize($job))->all(); PHPUnit::assertTrue( - $this->pushed($job, $callback)->filter(fn ($job) => $job->chained === $chain)->isNotEmpty(), + $this->pushed($job, $callback)->contains(fn ($job) => $job->chained === $chain), 'The expected chain was not pushed.' ); } @@ -272,7 +272,7 @@ public function assertClosureNotPushed(?callable $callback = null): void */ protected function isChainOfObjects(array $chain): bool { - return ! Collection::make($chain)->contains(fn ($job) => ! is_object($job)); + return (new Collection($chain))->doesntContain(fn ($job) => ! is_object($job)); } /** diff --git a/src/validation/src/Rules/Contains.php b/src/validation/src/Rules/Contains.php index e50362e68..38f0fca1d 100644 --- a/src/validation/src/Rules/Contains.php +++ b/src/validation/src/Rules/Contains.php @@ -4,7 +4,6 @@ namespace Hypervel\Validation\Rules; -use BackedEnum; use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; @@ -21,7 +20,7 @@ class Contains implements Stringable /** * Create a new contains rule instance. */ - public function __construct(array|Arrayable|BackedEnum|string|UnitEnum $values) + public function __construct(array|Arrayable|UnitEnum|string $values) { if ($values instanceof Arrayable) { $values = $values->toArray(); diff --git a/src/validation/src/Rules/DoesntContain.php b/src/validation/src/Rules/DoesntContain.php index 74ed48fa3..7c5580daf 100644 --- a/src/validation/src/Rules/DoesntContain.php +++ b/src/validation/src/Rules/DoesntContain.php @@ -4,7 +4,6 @@ namespace Hypervel\Validation\Rules; -use BackedEnum; use Hypervel\Contracts\Support\Arrayable; use Stringable; use UnitEnum; @@ -21,7 +20,7 @@ class DoesntContain implements Stringable /** * Create a new doesnt_contain rule instance. */ - public function __construct(array|Arrayable|BackedEnum|string|UnitEnum $values) + public function __construct(array|Arrayable|UnitEnum|string $values) { if ($values instanceof Arrayable) { $values = $values->toArray(); diff --git a/tests/Integration/Database/ModelInspectorTest.php b/tests/Integration/Database/ModelInspectorTest.php index 22ae53e1a..b88028f1e 100644 --- a/tests/Integration/Database/ModelInspectorTest.php +++ b/tests/Integration/Database/ModelInspectorTest.php @@ -46,7 +46,7 @@ protected function afterRefreshingDatabase(): void }); } - public function testExtractsModelData() + public function testExtractsModelData(): void { $extractor = new ModelInspector($this->app); $modelInfo = $extractor->inspect(ModelInspectorTestModel::class); @@ -65,11 +65,14 @@ public function testCommandReturnsJson(): void $this->assertModelInfo($modelInfo); } - private function assertModelInfo(ModelInfo|array $modelInfo) + /** + * Assert the extracted model details. + */ + private function assertModelInfo(ModelInfo|array $modelInfo): void { $this->assertEquals(ModelInspectorTestModel::class, $modelInfo['class']); $this->assertEquals(Schema::getConnection()->getConfig()['name'], $modelInfo['database']); - $this->assertEquals('model_info_extractor_test_model', $modelInfo['table']); + $this->assertSame('model_info_extractor_test_model', $modelInfo['table']); $this->assertNull($modelInfo['policy']); $this->assertCount(8, $modelInfo['attributes']); @@ -178,14 +181,17 @@ private function assertModelInfo(ModelInfo|array $modelInfo) $this->assertEmpty($modelInfo['events']); $this->assertCount(1, $modelInfo['observers']); - $this->assertEquals('created', $modelInfo['observers'][0]['event']); + $this->assertSame('created', $modelInfo['observers'][0]['event']); $this->assertCount(1, $modelInfo['observers'][0]['observer']); - $this->assertEquals('Hypervel\Tests\Integration\Database\ModelInspectorTestModelObserver@created', $modelInfo['observers'][0]['observer'][0]); + $this->assertSame('Hypervel\Tests\Integration\Database\ModelInspectorTestModelObserver@created', $modelInfo['observers'][0]['observer'][0]); $this->assertEquals(ModelInspectorTestModelEloquentCollection::class, $modelInfo['collection']); $this->assertEquals(ModelInspectorTestModelBuilder::class, $modelInfo['builder']); } - private function assertAttributes($expectedAttributes, $actualAttributes) + /** + * Assert the database-independent column attributes. + */ + private function assertAttributes(array $expectedAttributes, array $actualAttributes): void { foreach (['name', 'increments', 'nullable', 'unique', 'fillable', 'hidden', 'appended', 'cast'] as $key) { $this->assertEquals($expectedAttributes[$key], $actualAttributes[$key]); @@ -211,6 +217,9 @@ class ModelInspectorTestModel extends Model protected array $casts = ['nullable_date' => 'datetime', 'a_bool' => 'bool']; + /** + * Get the parent model relationship. + */ public function parentModel(): BelongsTo { return $this->belongsTo(ParentTestModel::class); @@ -226,7 +235,10 @@ class ParentTestModel extends Model class ModelInspectorTestModelObserver { - public function created() + /** + * Handle the model's created event. + */ + public function created(): void { } } diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php index 8804bbc4c..b4cf9cb83 100644 --- a/tests/Process/ProcessTest.php +++ b/tests/Process/ProcessTest.php @@ -18,6 +18,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use OutOfBoundsException; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; use RuntimeException; use Symfony\Component\Process\Process as SymfonyProcess; @@ -1549,6 +1550,85 @@ public function testBasicFakeAssertions() }); } + public function testAssertRanWithFalsyCommandString(): void + { + $factory = new Factory; + + $factory->fake(); + + $factory->run('0'); + + $factory->assertRan('0'); + $factory->assertRanTimes('0', 1); + $factory->assertNotRan('ls -la'); + } + + public function testAssertRanWithFalsyStartedCommandString(): void + { + $factory = new Factory; + + $factory->fake(); + + $factory->start('0')->wait(); + + $factory->assertRan('0'); + } + + public function testAssertingProcessesRanInOrder(): void + { + $factory = new Factory; + $factory->fake(); + + $factory->run('git fetch'); + $factory->run('git reset --hard origin/main'); + $factory->run('composer install --no-dev'); + + $factory->assertRanInOrder([ + 'git fetch', + 'git reset --hard origin/main', + fn ($process) => str_starts_with($process->command, 'composer install'), + ]); + } + + public function testAssertingProcessesRanInOrderFailsWhenOutOfOrder(): void + { + $this->expectException(AssertionFailedError::class); + + $factory = new Factory; + $factory->fake(); + + $factory->run('composer install'); + $factory->run('git fetch'); + + $factory->assertRanInOrder(['git fetch', 'composer install']); + } + + public function testAssertingProcessesRanInOrderFailsWhenCountDiffers(): void + { + $this->expectException(AssertionFailedError::class); + + $factory = new Factory; + $factory->fake(); + + $factory->run('git fetch'); + + $factory->assertRanInOrder(['git fetch', 'composer install']); + } + + public function testFakeAssertionsWithArrayCommands(): void + { + $factory = new Factory; + $factory->fake(); + + $factory->run(['php', 'artisan', 'migrate']); + + $factory->assertRan(['php', 'artisan', 'migrate']); + $factory->assertRanTimes(['php', 'artisan', 'migrate'], 1); + $factory->assertNotRan(['php', 'artisan', 'migrate:rollback']); + $factory->assertDidntRun(['php', 'artisan', 'migrate:rollback']); + $factory->assertRanInOrder([['php', 'artisan', 'migrate']]); + } + public function testAssertingThatNothingRan() { $factory = new Factory; From 84749d74d4e10f25838eb9cacee2006116f768db Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Wed, 9 Sep 2026 04:09:32 +0000 Subject: [PATCH 32/32] Complete cache enum and Redis event parity documentation Document enum cache keys and both Redis command listeners, including boot-time registration and propagation of command failures. The cache source and complete upstream enum-key test already exist; add the missing public usage guidance without duplicating coverage. Restore the upstream exception expectation in RedisEventsTest so dispatching CommandFailed without rethrowing cannot pass. Require exactly one native command invocation in the throwing mock branch while preserving unused mocks for listener-registration tests. Laravel PRs: https://github.com/laravel/framework/pull/58246 and https://github.com/laravel/framework/pull/58251 Port source: laravel/framework 13.x at 01d008c9b5f32cb7c5e50a9a22273113d810b2a2. The pinned Laravel documentation does not cover these APIs, so the examples are written for Hypervel. Verified the Redis event tests, focused cache enum and repository tests through ParaTest with in-memory SQLite, formatting, and the complete diff. No runtime source or API changes. --- src/docs/cache.md | 10 ++++++++++ src/docs/redis.md | 18 ++++++++++++++++++ tests/Redis/RedisEventsTest.php | 15 ++++++++++----- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/docs/cache.md b/src/docs/cache.md index e96946d25..0ecf65d71 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -357,6 +357,16 @@ $value = Cache::get('key', function () { }); ``` +You may also use enums as cache keys. Backed enums use their values, while unit enums use their case names: + +```php +use App\Enums\CacheKey; + +Cache::put(CacheKey::Visits, 10, 600); + +$visits = Cache::get(CacheKey::Visits); +``` + #### Determining Item Existence diff --git a/src/docs/redis.md b/src/docs/redis.md index 4deb4b70d..c423b3cfc 100644 --- a/src/docs/redis.md +++ b/src/docs/redis.md @@ -391,6 +391,24 @@ Redis::disableEvents(); These methods are intended for application boot. If a pool was created earlier in the same startup lifecycle with the other setting, Hypervel replaces that pool generation on its next use. Matching pools are left untouched. Connections already checked out from a replaced generation may finish their current work and are destroyed when returned. +To listen for failed commands, register a callback using the `Redis` facade's `listenForFailures` method in the `boot` method of a service provider: + +```php +use Hypervel\Redis\Events\CommandFailed; +use Hypervel\Support\Facades\Log; +use Hypervel\Support\Facades\Redis; + +Redis::listenForFailures(function (CommandFailed $event): void { + Log::error('Redis command failed.', [ + 'connection' => $event->connectionName, + 'command' => $event->command, + 'exception' => $event->exception, + ]); +}); +``` + +Listening for a failure does not suppress the command's exception. You may also use `Redis::listen` to register a callback that receives a `Hypervel\Redis\Events\CommandExecuted` event after each successful command. + #### Holding a Pooled Connection diff --git a/tests/Redis/RedisEventsTest.php b/tests/Redis/RedisEventsTest.php index f1083b91e..cc5dbf01e 100644 --- a/tests/Redis/RedisEventsTest.php +++ b/tests/Redis/RedisEventsTest.php @@ -49,11 +49,9 @@ public function testCommandFailedEventIsDispatched(): void $redis = $this->createRedis($connection); - try { - $redis->get('key'); - } catch (Exception) { - // Expected - } + $this->expectExceptionObject($exception); + + $redis->get('key'); } public function testCommandExecutedEventIsNotDispatchedWhenCommandFails(): void @@ -258,6 +256,9 @@ public function testListenForFailuresNoOpsWhenEventsUnbound(): void $this->assertTrue(true); } + /** + * Create a Redis proxy using the given connection. + */ private function createRedis(m\MockInterface|RedisConnection $connection): RedisProxy { $pool = m::mock(RedisPool::class); @@ -274,6 +275,9 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis ); } + /** + * Create a mock Redis connection for the given command. + */ private function createMockRedisConnection( string $command = 'get', mixed $returnValue = 'value', @@ -284,6 +288,7 @@ private function createMockRedisConnection( if ($exception !== null) { $mockPhpRedis->shouldReceive($command) + ->once() ->andThrow($exception); } else { $mockPhpRedis->shouldReceive($command)