From 6fcbe2142af55d9e1d67df1a1c862cb573afe3be Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:06:35 +0000 Subject: [PATCH 01/30] Fix pagination loop detection for advancing continuation links and cursors Include provider-supplied continuation in Link and cursor page checksums so valid empty pages do not trigger the repeated-body heuristic. Preserve body-only comparison for page and offset pagination, existing body checksum overrides, and the upstream checksum-storage property name. Map page items before checking for repetition so pagination control readers can reuse decoded values. Publish the response, items and totals only after mapping and detection succeed. Preserve bounded checksum storage, retry replacement, rewind behavior and pooled requests. Keep cursor scalar types distinct and distinguish nonterminal null cursors from terminal pages without reading a nonexistent next cursor. Add regression coverage for advancing empty pages, genuine repeated pages with custom body checksums, and typed cursor termination. Update the pagination guide and exception wording. Verified formatting, full static analysis and the targeted pagination test suite. --- src/docs/saloon.md | 4 +- src/saloon/src/Pagination/CursorPaginator.php | 16 +++ src/saloon/src/Pagination/LinkPaginator.php | 13 ++ src/saloon/src/Pagination/Paginator.php | 25 +++- .../Feature/InfiniteLoopDetectionTest.php | 4 +- tests/Saloon/Pagination/PaginatorTest.php | 125 ++++++++++++++++++ 6 files changed, 177 insertions(+), 10 deletions(-) diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 3e15a64411..f1699b8c1e 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -2037,9 +2037,9 @@ protected string $perPageName = 'pageSize'; `PagedPaginator` defaults to `page` and `per_page`, and sends a page size only when a per-page limit is set. To send one by default, declare `protected ?int $perPageLimit = 100;` on your paginator. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure. -During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages return the same response body. Check that your paginator correctly identifies the last page. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator. +During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages are identical. Page and offset paginators compare response bodies. Link and cursor paginators also compare the next URL or cursor, so empty pages can continue when the API supplies an advancing continuation. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator. -The check compares each page's body after all response middleware has run. If your API adds a value that changes on every request, such as a request ID, override `getBodyChecksum` to compare only the page contents: +The check runs after response middleware and item mapping. If your API adds a value that changes on every request, such as a request ID, override `getBodyChecksum` to compare only the page contents. Link and cursor paginators still include continuation information when using this override: ```php protected function getBodyChecksum(Response $response): string diff --git a/src/saloon/src/Pagination/CursorPaginator.php b/src/saloon/src/Pagination/CursorPaginator.php index 3bbcd306a1..659cb5dde2 100644 --- a/src/saloon/src/Pagination/CursorPaginator.php +++ b/src/saloon/src/Pagination/CursorPaginator.php @@ -44,6 +44,22 @@ protected function applyPagination(Request $request): Request return $request; } + /** + * Compare page contents together with the provider's continuation cursor. + * + * @param Response $response + */ + protected function getPageChecksum(Response $response): string + { + $lastPage = $this->isLastPage($response); + + return hash('xxh128', serialize([ + $this->getBodyChecksum($response), + $lastPage, + $lastPage ? null : $this->getNextCursor($response), + ])); + } + /** * Get the next cursor. * diff --git a/src/saloon/src/Pagination/LinkPaginator.php b/src/saloon/src/Pagination/LinkPaginator.php index e7ca29aeeb..77675f7c41 100644 --- a/src/saloon/src/Pagination/LinkPaginator.php +++ b/src/saloon/src/Pagination/LinkPaginator.php @@ -55,6 +55,19 @@ public function current(): Response return $response; } + /** + * Compare page contents together with the provider's continuation URL. + * + * @param Response $response + */ + protected function getPageChecksum(Response $response): string + { + $links = $this->getLinks($response, $response->toPsrRequest()->getUri()); + $next = $links['next'] ?? null; + + return hash('xxh128', serialize([$this->getBodyChecksum($response), $next === null ? null : (string) $next])); + } + /** * Apply numbered pagination or the provider's continuation URL. * diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php index 684c130488..5ad5aeec6f 100644 --- a/src/saloon/src/Pagination/Paginator.php +++ b/src/saloon/src/Pagination/Paginator.php @@ -85,7 +85,7 @@ abstract class Paginator implements Countable, Iterator protected bool $pooling = false; /** - * The latest response body checksums. + * The latest response checksums, including continuation where available. * * @var array */ @@ -128,16 +128,19 @@ public function current(): Response $response = $this->connector->send($request); + // Mapping may decode pagination controls that the checksum can then reuse. + $items = $this->pageItems($response); + // Checked here instead of in request middleware, where the callback would keep the paginator alive through // its own request and every response. if ($this->detectInfiniteLoop && ! $this->pooling) { - // Loading a page again after a mapping failure replaces its checksum instead of adding another page. - $this->lastFiveBodyChecksums[$this->currentPage] = $this->getBodyChecksum($response); + // Retrying a failed load replaces its checksum instead of adding another page. + $this->lastFiveBodyChecksums[$this->currentPage] = $this->getPageChecksum($response); if (count($this->lastFiveBodyChecksums) >= 5) { if (count(array_unique($this->lastFiveBodyChecksums)) === 1) { throw new PaginationException( - 'Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', + 'Potential infinite loop detected! The last 5 pages were identical. You can use the $detectInfiniteLoop property on your paginator to disable this check.', ); } @@ -145,8 +148,8 @@ public function current(): Response } } - $this->currentPageItems = $this->pageItems($response); - // Keep the preceding response until mapping succeeds so a failed load retries the same page. + // Keep the preceding response until mapping and loop detection succeed so a failed load retries the same page. + $this->currentPageItems = $items; $this->currentResponse = $response; $this->totalResults += count($this->currentPageItems); $this->currentPageLoaded = true; @@ -392,6 +395,16 @@ public function count(): int return $count; } + /** + * Get the checksum used to detect repeated pages. + * + * @param Response $response + */ + protected function getPageChecksum(Response $response): string + { + return $this->getBodyChecksum($response); + } + /** * Get the checksum of the response body. * diff --git a/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php index a6101bc34c..fccfb55d9b 100644 --- a/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php +++ b/tests/Saloon/Pagination/Feature/InfiniteLoopDetectionTest.php @@ -38,7 +38,7 @@ public function testThePaginationPluginCanDetectAPotentialInfiniteLoop(): void try { iterator_to_array($paginator); } catch (PaginationException $exception) { - $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); + $this->assertSame('Potential infinite loop detected! The last 5 pages were identical. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); $mockClient->assertSentCount(5); @@ -75,7 +75,7 @@ public function testThePaginationPluginCanDetectAPotentialInfiniteLoopAfterTheIn try { iterator_to_array($paginator); } catch (PaginationException $exception) { - $this->assertSame('Potential infinite loop detected! The last 5 pages have had exactly the same body. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); + $this->assertSame('Potential infinite loop detected! The last 5 pages were identical. You can use the $detectInfiniteLoop property on your paginator to disable this check.', $exception->getMessage()); $mockClient->assertSentCount(7); diff --git a/tests/Saloon/Pagination/PaginatorTest.php b/tests/Saloon/Pagination/PaginatorTest.php index ccac0fa51d..5b5cd8b474 100644 --- a/tests/Saloon/Pagination/PaginatorTest.php +++ b/tests/Saloon/Pagination/PaginatorTest.php @@ -1015,6 +1015,85 @@ public function testBodyChecksumsCanIgnoreFieldsThatChangeOnEveryRequest(): void $this->assertSame(5, $requested); } + #[DataProvider('continuationPaginators')] + public function testEmptyPagesWithAdvancingContinuationReachLaterItems(string $class): void + { + $manager = $this->manager(); + $responses = []; + + for ($page = 1; $page <= 7; ++$page) { + $responses[] = MockResponse::make(['data' => $page === 6 ? [99] : []], headers: $page < 7 ? [ + 'Link' => '; rel=next', + 'X-Next-Cursor' => json_encode($page + 1), + ] : []); + } + + $fake = $manager->fake($responses); + $paginator = new $class(new PaginationConnectorStub($manager), new PagedRequestStub); + + $this->assertSame([99], iterator_to_array($paginator->items())); + $this->assertSame(1, $paginator->totalResults()); + $fake->assertSentCount(7); + } + + #[DataProvider('continuationPaginators')] + public function testRepeatedContentsAndContinuationStillDetectLoopsWithCustomBodyChecksums(string $class): void + { + $manager = $this->manager(); + $requested = 0; + $manager->fake([PagedRequestStub::class => static function () use (&$requested): MockResponse { + return MockResponse::make(['data' => [], 'request_id' => ++$requested], headers: [ + 'Link' => '; rel=next', + 'X-Next-Cursor' => '2', + ]); + }]); + $paginator = (new $class(new PaginationConnectorStub($manager), new PagedRequestStub))->maxPages(6); + $caught = null; + + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $caught = $exception; + } + + $this->assertInstanceOf(PaginationException::class, $caught); + $this->assertSame(5, $requested); + } + + /** + * Provide continuation strategies with customized body checksums. + */ + public static function continuationPaginators(): iterable + { + yield 'link' => [DataChecksumLinkPaginatorStub::class]; + yield 'cursor header' => [HeaderCursorPaginatorStub::class]; + } + + #[DataProvider('cursorChecksumValues')] + public function testCursorChecksumsDistinguishScalarKindsAndTerminalPages(array $cursors): void + { + $manager = $this->manager(); + $responses = array_map(static fn (mixed $cursor): MockResponse => MockResponse::make( + ['data' => []], + headers: ['X-Next-Cursor' => json_encode($cursor, JSON_PRESERVE_ZERO_FRACTION)], + ), $cursors); + $responses[] = MockResponse::make(['data' => []]); + $fake = $manager->fake($responses); + $paginator = new HeaderCursorPaginatorStub(new PaginationConnectorStub($manager), new PagedRequestStub); + + $this->assertSame([], iterator_to_array($paginator->items())); + $fake->assertSentCount(count($responses)); + } + + /** + * Provide cursor values that must not collide with each other or with termination. + */ + public static function cursorChecksumValues(): iterable + { + yield 'scalar kinds' => [[0, false, 0.0, '0', 0]]; + yield 'nonterminal null' => [[null, null, null, null]]; + } + public function testLoopDetectionChecksTheResponseReturnedByRequestMiddleware(): void { $manager = $this->manager(); @@ -1335,6 +1414,41 @@ protected function getPageItems(Response $response, Request $request): array } } +class HeaderCursorPaginatorStub extends CursorPaginator +{ + /** + * Read a typed cursor, rejecting calls for a terminal response. + */ + protected function getNextCursor(Response $response): bool|float|int|string|null + { + return json_decode($response->header('X-Next-Cursor'), flags: JSON_THROW_ON_ERROR); + } + + /** + * Determine whether the next cursor header is absent. + */ + protected function isLastPage(Response $response): bool + { + return $response->header('X-Next-Cursor') === ''; + } + + /** + * Get the items from one page. + */ + protected function getPageItems(Response $response, Request $request): array + { + return $response->json('data'); + } + + /** + * Ignore request metadata when comparing page bodies. + */ + protected function getBodyChecksum(Response $response): string + { + return hash('xxh128', json_encode($response->json('data'))); + } +} + class RenamedOffsetPaginatorStub extends OffsetPaginatorStub { protected string $limitName = 'take'; @@ -1393,6 +1507,17 @@ protected function getPageItems(Response $response, Request $request): array } } +class DataChecksumLinkPaginatorStub extends LinkPaginatorStub +{ + /** + * Ignore request metadata when comparing page bodies. + */ + protected function getBodyChecksum(Response $response): string + { + return hash('xxh128', json_encode($response->json('data'))); + } +} + class RenamedLinkPaginatorStub extends LinkPaginatorStub { protected string $pageName = 'number'; From 30795dcfbdc80f520bf86db5feba14508419d4cb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:27:48 +0000 Subject: [PATCH 02/30] Fix retained database migration state in tests --- src/docs/testbench.md | 4 +- .../InteractsWithTestCaseLifecycle.php | 21 ++++ .../src/Testing/DatabaseTruncation.php | 2 +- .../src/Concerns/InteractsWithMigrations.php | 8 +- src/testbench/src/TestCase.php | 1 + tests/Foundation/Testing/BootTraitsTest.php | 63 +++++++++++ .../Testing/DatabaseTruncationTest.php | 35 ++++++ .../Concerns/InteractsWithMigrationsTest.php | 21 ++++ ...aseTruncationWithInMemorySecondaryTest.php | 102 ++++++++++++++++++ ...DatabaseWithRetainedMigrationStateTest.php | 51 +++++++++ ..._000000_create_secondary_records_table.php | 30 ++++++ 11 files changed, 331 insertions(+), 7 deletions(-) create mode 100644 tests/Testbench/Databases/DatabaseTruncationWithInMemorySecondaryTest.php create mode 100644 tests/Testbench/Databases/RefreshDatabaseWithRetainedMigrationStateTest.php create mode 100644 tests/Testbench/Fixtures/database/secondary-migrations/2026_10_23_000000_create_secondary_records_table.php diff --git a/src/docs/testbench.md b/src/docs/testbench.md index b6e11c5889..6589fb5443 100644 --- a/src/docs/testbench.md +++ b/src/docs/testbench.md @@ -700,7 +700,7 @@ Testbench also provides helpers and attributes for loading package migrations in ### Loading Migrations -You may load migrations from a package path using the `loadMigrationsFrom` method: +You may load migrations from a package path using the `loadMigrationsFrom` method. The method accepts a single path or an array of paths: ```php use function Hypervel\Testbench\workbench_path; @@ -714,7 +714,7 @@ protected function defineDatabaseMigrations(): void } ``` -When used with `RefreshDatabase`, Testbench registers the migration paths before the database is refreshed. Otherwise, the migrations are run immediately and rolled back during teardown. +Testbench registers these paths for `DatabaseMigrations`, for `RefreshDatabase` when a refresh is pending, and for `DatabaseTruncation` before its first migration or when the default database is in memory. Otherwise, the migrations are run immediately and rolled back during teardown. Passing an array of command options, such as `['--path' => $paths, '--database' => 'secondary']`, always runs the migrations immediately on the selected connection and rolls them back during teardown. You may also define database migrations with the `#[DefineDatabase]` attribute: diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php index 04ec46c5b3..1d113547c0 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php @@ -16,6 +16,7 @@ use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Foundation\Testing\DatabaseTruncation; use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Foundation\Testing\WithFaker; use Hypervel\Foundation\Testing\WithoutEvents; use Hypervel\Foundation\Testing\WithoutMiddleware; @@ -239,6 +240,8 @@ protected function setUpTheTestEnvironmentTraitToBeIgnored(string $use): bool */ protected function setUpDatabaseTraits(array $uses): void { + $this->forgetStaleMigrationState($uses); + if (isset($uses[RefreshDatabase::class])) { $this->refreshDatabase(); // @phpstan-ignore method.notFound } @@ -256,6 +259,24 @@ protected function setUpDatabaseTraits(array $uses): void } } + /** + * Forget retained migration state when a selected in-memory database is missing. + */ + protected function forgetStaleMigrationState(array $uses): void + { + if (! RefreshDatabaseState::$migrated) { + return; + } + + if ( + (isset($uses[RefreshDatabase::class]) && $this->hasMissingInMemoryDatabaseForRefresh()) // @phpstan-ignore method.notFound + || (isset($uses[DatabaseTruncation::class]) && $this->hasMissingInMemoryDatabaseForTruncation()) // @phpstan-ignore method.notFound + ) { + // A persistent database's migrated flag does not describe an uncached in-memory database. + RefreshDatabaseState::$migrated = false; + } + } + /** * Clean up the testing environment before the next test case. * diff --git a/src/foundation/src/Testing/DatabaseTruncation.php b/src/foundation/src/Testing/DatabaseTruncation.php index 6c92a945db..2b224b5291 100644 --- a/src/foundation/src/Testing/DatabaseTruncation.php +++ b/src/foundation/src/Testing/DatabaseTruncation.php @@ -69,7 +69,7 @@ protected function truncateDatabaseTables(): void $this->beforeTruncatingDatabase(); // Migrate and seed the database on first run... - if (! RefreshDatabaseState::$migrated) { + if (! RefreshDatabaseState::$migrated || $this->hasMissingInMemoryDatabaseForTruncation()) { $this->artisan('migrate:fresh', $this->migrateFreshUsing()); $this->app->make(Kernel::class)->setArtisan(null); diff --git a/src/testbench/src/Concerns/InteractsWithMigrations.php b/src/testbench/src/Concerns/InteractsWithMigrations.php index 569cebae82..4288b7d767 100644 --- a/src/testbench/src/Concerns/InteractsWithMigrations.php +++ b/src/testbench/src/Concerns/InteractsWithMigrations.php @@ -37,7 +37,8 @@ trait InteractsWithMigrations */ protected function setUpInteractsWithMigrations(): void { - if ($this->usesInMemoryDatabaseForMigrationState()) { + if ($this->usesInMemoryDatabaseForMigrationState() + && ! static::resolvePhpUnitAttributesForMethod(static::class)->has(ResetRefreshDatabaseState::class)) { // A later setup hook may skip or fail before after-application callbacks run. static::usesTestingFeature(new ResetRefreshDatabaseState); } @@ -105,7 +106,6 @@ protected function loadMigrationsFrom(array|string $paths): void return; } - /** @var array|string $paths */ $this->runMigrationProcessor($app, $this->resolvePackageMigrationsOptions($paths)); } @@ -143,14 +143,14 @@ protected function shouldRegisterMigrationPaths(): bool * * @internal * - * @param array|string $paths + * @param array|string $paths * @return array * * @throws InvalidArgumentException */ protected function resolvePackageMigrationsOptions(array|string $paths = []): array { - $options = is_array($paths) ? $paths : ['--path' => $paths]; + $options = is_string($paths) || Arr::isList($paths) ? ['--path' => $paths] : $paths; if (isset($options['--realpath']) && ! is_bool($options['--realpath'])) { throw new InvalidArgumentException('Expect --realpath to be a boolean.'); diff --git a/src/testbench/src/TestCase.php b/src/testbench/src/TestCase.php index 83b7f1be98..c8fb34958e 100644 --- a/src/testbench/src/TestCase.php +++ b/src/testbench/src/TestCase.php @@ -139,6 +139,7 @@ protected function setUpDatabaseTraits(array $uses): void // Reset before database attributes register paths against retained schema state. $this->prepareDatabaseTruncationForMethod($uses); + $this->forgetStaleMigrationState($uses); // Version requirements and migration hooks may query before the database traits run. if (isset($uses[RefreshDatabase::class]) diff --git a/tests/Foundation/Testing/BootTraitsTest.php b/tests/Foundation/Testing/BootTraitsTest.php index 64b4233053..9de03338c5 100644 --- a/tests/Foundation/Testing/BootTraitsTest.php +++ b/tests/Foundation/Testing/BootTraitsTest.php @@ -4,10 +4,17 @@ namespace Hypervel\Tests\Foundation\Testing; +use Hypervel\Config\Repository; +use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Foundation\Application; use Hypervel\Foundation\Testing\Attributes\SetUp; use Hypervel\Foundation\Testing\Attributes\TearDown; +use Hypervel\Foundation\Testing\DatabaseTruncation; +use Hypervel\Foundation\Testing\RefreshDatabase; +use Hypervel\Foundation\Testing\RefreshDatabaseState; use Hypervel\Foundation\Testing\TestCase as FoundationTestCase; use Hypervel\Tests\TestCase; +use Mockery as m; use ReflectionMethod; class BootTraitsTest extends TestCase @@ -58,6 +65,62 @@ public function testConventionalAndAttributeTraitsWorkTogether(): void $this->assertTrue($testCase->tearDown); $this->assertTrue($testCase->attributeTearDown); } + + public function testMissingTruncatedDatabaseInvalidatesStateBeforeEagerRefresh(): void + { + $app = new Application; + $app->instance('config', new Repository([ + 'database' => [ + 'default' => 'persistent', + 'connections' => [ + 'persistent' => ['driver' => 'sqlite', 'database' => '/unused/database.sqlite'], + 'secondary' => ['driver' => 'sqlite', 'database' => ':memory:'], + ], + ], + ])); + $testCase = m::mock(TestCaseWithComposedDatabaseResets::class, ['foo']) + ->makePartial()->shouldAllowMockingProtectedMethods(); + $testCase->expects('refreshDatabase')->ordered()->andReturnUsing(function (): void { + $this->assertFalse(RefreshDatabaseState::$migrated); + RefreshDatabaseState::$migrated = true; + }); + $testCase->expects('truncateDatabaseTables')->ordered()->andReturnUsing(function (): void { + $this->assertTrue(RefreshDatabaseState::$migrated); + }); + RefreshDatabaseState::$migrated = true; + RefreshDatabaseState::$inMemoryConnections = []; + + try { + $testCase->prepare($app); + } finally { + RefreshDatabaseState::$migrated = false; + RefreshDatabaseState::$inMemoryConnections = []; + } + } +} + +class TestCaseWithComposedDatabaseResets extends FoundationTestCase +{ + use RefreshDatabase; + use DatabaseTruncation; + + protected array $connectionsToTruncate = ['secondary']; + + /** + * Run Foundation's database setup without booting a Testbench application. + */ + public function prepare(ApplicationContract $app): void + { + $this->app = $app; + $this->setUpDatabaseTraits(class_uses_recursive(static::class)); + } + + /** + * Provide the test method required by PHPUnit's lifecycle. + */ + public function foo(): void + { + } } class TestCaseWithTrait extends FoundationTestCase diff --git a/tests/Foundation/Testing/DatabaseTruncationTest.php b/tests/Foundation/Testing/DatabaseTruncationTest.php index 6a2bf10f5b..a55089187a 100644 --- a/tests/Foundation/Testing/DatabaseTruncationTest.php +++ b/tests/Foundation/Testing/DatabaseTruncationTest.php @@ -6,12 +6,14 @@ use Hypervel\Config\Repository; use Hypervel\Container\Container; +use Hypervel\Contracts\Console\Kernel as KernelContract; use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseManager; use Hypervel\Database\PdoConnection; use Hypervel\Database\Schema\Builder; use Hypervel\Database\Schema\PostgresBuilder; +use Hypervel\Foundation\Testing\Concerns\InteractsWithConsole; use Hypervel\Foundation\Testing\DatabaseMigrations; use Hypervel\Foundation\Testing\DatabaseTransactions; use Hypervel\Foundation\Testing\DatabaseTruncation; @@ -27,6 +29,7 @@ class DatabaseTruncationTest extends TestCase { use DatabaseTruncation; + use InteractsWithConsole; private ?Container $app; @@ -419,6 +422,38 @@ public function truncate(): void $testCase->truncate(); } + public function testMigratesWhenRetainedStateHasNoSelectedInMemoryPdo(): void + { + $pdo = m::mock(PDO::class); + $connection = m::mock(PdoConnection::class); + $connection->expects('getPdo')->andReturn($pdo); + $database = m::mock(DatabaseManager::class); + $database->expects('connection')->with(null)->andReturn($connection); + $kernel = m::mock(KernelContract::class); + $kernel->expects('call')->with('migrate:fresh', [ + '--drop-views' => false, + '--drop-types' => false, + '--seed' => false, + ])->andReturn(0); + $kernel->expects('setArtisan')->with(null); + $this->app->instance(KernelContract::class, $kernel); + $this->app->instance('db', $database); + $this->app->instance('config', new Repository([ + 'database' => [ + 'default' => 'testing', + 'connections' => ['testing' => ['driver' => 'sqlite', 'database' => ':memory:']], + ], + ])); + RefreshDatabaseState::$migrated = true; + RefreshDatabaseState::$inMemoryConnections = []; + $this->withoutMockingConsoleOutput(); + + $this->truncateDatabaseTables(); + + $this->assertTrue(RefreshDatabaseState::$migrated); + $this->assertSame(['testing' => $pdo], RefreshDatabaseState::$inMemoryConnections); + } + public function testAdoptsAMissingInMemoryPdoBeforeTheTruncationHook(): void { $pdo = m::mock(PDO::class); diff --git a/tests/Testbench/Concerns/InteractsWithMigrationsTest.php b/tests/Testbench/Concerns/InteractsWithMigrationsTest.php index 5410b50ece..659a6991c0 100644 --- a/tests/Testbench/Concerns/InteractsWithMigrationsTest.php +++ b/tests/Testbench/Concerns/InteractsWithMigrationsTest.php @@ -50,6 +50,27 @@ public function itPreparesHypervelMigrationOptionsOnce(): void ], $this->migrationOptions); } + #[Test] + public function itProcessesPackageMigrationPathListsAndNamedOptions(): void + { + $paths = [__DIR__ . '/../Fixtures/database/migrations', __DIR__ . '/../Fixtures/database/secondary-migrations']; + + $this->loadMigrationsFrom($paths); + + $this->assertSame([ + '--path' => $paths, + '--realpath' => true, + ], $this->migrationOptions); + + $this->loadMigrationsFrom(['--path' => $paths, '--database' => 'secondary']); + + $this->assertSame([ + '--path' => $paths, + '--database' => 'secondary', + '--realpath' => true, + ], $this->migrationOptions); + } + /** * Resolve Hypervel migration options. * diff --git a/tests/Testbench/Databases/DatabaseTruncationWithInMemorySecondaryTest.php b/tests/Testbench/Databases/DatabaseTruncationWithInMemorySecondaryTest.php new file mode 100644 index 0000000000..1f6fffd7a8 --- /dev/null +++ b/tests/Testbench/Databases/DatabaseTruncationWithInMemorySecondaryTest.php @@ -0,0 +1,102 @@ +ensureDirectoryExists(self::$databaseDirectory); + $files->put(self::$databaseDirectory . '/database.sqlite', ''); + } + + /** + * Configure persistent and in-memory connections. + */ + #[Override] + protected function defineEnvironment(ApplicationContract $app): void + { + $app->make('config')->set([ + 'database.default' => 'testing', + 'database.connections.testing.database' => self::$databaseDirectory . '/database.sqlite', + 'database.connections.secondary' => ['driver' => 'sqlite', 'database' => ':memory:'], + ]); + } + + /** + * Load migrations for both connections. + */ + #[Override] + protected function defineDatabaseMigrations(): void + { + $this->loadMigrationsFrom([ + workbench_path('database/migrations'), + dirname(__DIR__) . '/Fixtures/database/secondary-migrations', + ]); + } + + #[Test] + public function itMigratesTheMissingSecondaryBeforeTruncating(): void + { + $this->assertTrue(Schema::hasTable('testbench_users')); + $this->assertTrue(Schema::connection('secondary')->hasTable('secondary_records')); + DB::table('testbench_users')->insert(['email' => 'retained@example.com', 'password' => 'secret']); + DB::connection('secondary')->table('secondary_records')->insert(['value' => 'truncated']); + } + + #[Test] + #[Depends('itMigratesTheMissingSecondaryBeforeTruncating')] + public function itReusesTheSchemaAndTruncatesOnlyTheSelectedConnection(): void + { + $this->assertSame(1, DB::table('testbench_users')->where('email', 'retained@example.com')->count()); + $this->assertSame(0, DB::connection('secondary')->table('secondary_records')->count()); + } + + /** + * Verify migration-state cleanup and remove the persistent database. + */ + public static function tearDownAfterClass(): void + { + try { + parent::tearDownAfterClass(); + + self::assertFalse(RefreshDatabaseState::$migrated); + self::assertSame([], RefreshDatabaseState::$inMemoryConnections); + } finally { + (new Filesystem)->deleteDirectory(self::$databaseDirectory); + } + } +} diff --git a/tests/Testbench/Databases/RefreshDatabaseWithRetainedMigrationStateTest.php b/tests/Testbench/Databases/RefreshDatabaseWithRetainedMigrationStateTest.php new file mode 100644 index 0000000000..5545f42ea4 --- /dev/null +++ b/tests/Testbench/Databases/RefreshDatabaseWithRetainedMigrationStateTest.php @@ -0,0 +1,51 @@ +loadMigrationsFrom(workbench_path('database/migrations')); + } + + #[Test] + public function itMigratesDefaultAndPackageTablesDespiteRetainedPersistentState(): void + { + $this->assertTrue(Schema::hasTable('users')); + $this->assertTrue(Schema::hasTable('testbench_users')); + } +} diff --git a/tests/Testbench/Fixtures/database/secondary-migrations/2026_10_23_000000_create_secondary_records_table.php b/tests/Testbench/Fixtures/database/secondary-migrations/2026_10_23_000000_create_secondary_records_table.php new file mode 100644 index 0000000000..80e4f88823 --- /dev/null +++ b/tests/Testbench/Fixtures/database/secondary-migrations/2026_10_23_000000_create_secondary_records_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('value'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('secondary_records'); + } +}; From c414f40d22c4b72863aa9dc2f851c792bfae53d7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 24 Oct 2026 05:36:49 +0000 Subject: [PATCH 03/30] Add Pennant database write guards and shared cache flushing Allow applications to register a database write guard before stores are resolved. Pass the store name and current partition to the guard before mutations and before resolving known missing values, while leaving stored-value reads, unknown features, and array stores unaffected. Reject duplicate partition and guard registrations so resolved stores retain consistent configuration. Expose shared cache invalidation through database stores and the Feature facade. Reuse the existing commit-aware invalidation and overlapping-store targets without deleting stored features or clearing the current coroutine memo. Document both APIs and cover guarded mutations, registration errors, partition arguments, and shared versus local cache behavior. Formatting and full static analysis pass. The Pennant unit suite and integration coverage passed, including Redis and explicit MySQL, MariaDB, and PostgreSQL runs; affected tests were rerun after review corrections. --- src/docs/pennant.md | 26 +++- src/pennant/src/Drivers/DatabaseDriver.php | 28 +++++ src/pennant/src/Drivers/Decorator.php | 10 ++ src/pennant/src/Feature.php | 2 + src/pennant/src/FeatureManager.php | 39 +++++- tests/Pennant/PartitionTest.php | 14 +++ tests/Pennant/StoredValueCacheTest.php | 14 +++ tests/Pennant/WriteGuardTest.php | 140 +++++++++++++++++++++ 8 files changed, 270 insertions(+), 3 deletions(-) create mode 100644 tests/Pennant/WriteGuardTest.php diff --git a/src/docs/pennant.md b/src/docs/pennant.md index 8bce02234a..0b44532617 100644 --- a/src/docs/pennant.md +++ b/src/docs/pennant.md @@ -28,6 +28,7 @@ - [Partitioning](#partitioning) - [Registering a Partition](#registering-a-partition) - [Partitioned Schema](#partitioned-schema) +- [Guarding Database Writes](#guarding-database-writes) - [Shared Cache](#shared-cache) - [Testing](#testing) - [Adding Custom Pennant Drivers](#adding-custom-pennant-drivers) @@ -972,7 +973,7 @@ Partitioning keeps the feature values of separate tenants, workspaces, or teams ### Registering a Partition -You may register a partition using the `Feature` facade's `resolvePartitionUsing` method. Pennant must know about the partition before any store is used, so register it in the `register` method of one of your application's service providers. Registering it after a store has been resolved throws an exception: +You may register a partition using the `Feature` facade's `resolvePartitionUsing` method. Pennant must know about the partition before any store is used, so register it once in the `register` method of one of your application's service providers. Registering it again or after a store has been resolved throws an exception: ```php use Hypervel\Pennant\Feature; @@ -1025,6 +1026,25 @@ Schema::create('features', function (Blueprint $table) { Use a column type that matches your partition values, such as `foreignUuid` for UUIDs. + +## Guarding Database Writes + +You may use `Feature::guardWritesUsing()` to prevent changes to read-only storage. Register the callback once during provider registration, before any store is resolved. It receives the store name and its current `FeaturePartition`, or `null` for an unpartitioned store: + +```php +use Hypervel\Pennant\Feature; +use Hypervel\Pennant\FeaturePartition; +use RuntimeException; + +Feature::guardWritesUsing(function (string $store, ?FeaturePartition $partition): void { + if ($partition !== null && workspace()->isReadOnly()) { + throw new RuntimeException('This workspace is read-only.'); + } +}); +``` + +Throw an exception to refuse the write. The callback runs before database mutations, including before resolving a known feature whose value has not been stored yet. Reading existing values, checking unknown features, and using the `array` driver do not invoke it. Custom drivers are responsible for their own write guards. + ## Shared Cache @@ -1046,7 +1066,9 @@ The `ttl` defaults to one hour and must be a positive integer. The database remains the source of truth. Only values stored in the database are cached: values returned by [`before` methods](#intercepting-feature-checks) and the `false` result of unknown features are never cached. When a cached value expires or is evicted, Pennant reads it from the database again rather than resolving it again, so the cache never changes a feature's stored value. -Updating values through any `database` store invalidates the cached values of every store that reads the same table on the same connection, even when the store making the change has no cache of its own. Invalidation happens after the surrounding database transaction commits. Changes made to the table without Pennant, such as manual queries, aren't seen until the cached values expire. While a transaction is open, Pennant reads from the database and doesn't use the shared cache, so it never caches values that might be rolled back. +Updating values through any `database` store invalidates the cached values of every store that reads the same table on the same connection, even when the store making the change has no cache of its own. Invalidation happens after the surrounding database transaction commits. While a transaction is open, Pennant reads from the database and doesn't use the shared cache, so it never caches values that might be rolled back. + +After changing the table without Pennant, call `Feature::store('database')->flushSharedCache()` to invalidate the current partition's shared cached values after the transaction commits. Otherwise, readers may keep seeing old values until they expire. This method leaves stored rows and the current coroutine's memoized values intact; also call `flushCache()` if the current coroutine needs to reread values it has already checked. Calling `flushSharedCache()` on a non-database store has no effect. A store whose `connection` is `null` shares invalidation with stores on every connection, since it may use any connection selected with `DB::usingConnection()`. A connection's `::write` side shares cached values with the connection itself. If several connection names point to the same database, use the same name for all of your Pennant stores, since Pennant can't tell that they share data. diff --git a/src/pennant/src/Drivers/DatabaseDriver.php b/src/pennant/src/Drivers/DatabaseDriver.php index d44b972253..4f5551e0a1 100644 --- a/src/pennant/src/Drivers/DatabaseDriver.php +++ b/src/pennant/src/Drivers/DatabaseDriver.php @@ -54,6 +54,13 @@ class DatabaseDriver implements CanListStoredFeatures, CanSetManyFeaturesForScop */ protected ?StoredValueCache $cache; + /** + * The callback that guards database writes. + * + * @var null|(Closure(?FeaturePartition): void) + */ + protected ?Closure $writeGuard; + /** * The feature state resolvers. * @@ -98,6 +105,7 @@ class DatabaseDriver implements CanListStoredFeatures, CanSetManyFeaturesForScop * * @param null|(Closure(): FeaturePartition) $partitionResolver * @param array $featureStateResolvers + * @param null|(Closure(?FeaturePartition): void) $writeGuard */ public function __construct( DatabaseManager $db, @@ -106,6 +114,7 @@ public function __construct( ?Closure $partitionResolver, ?StoredValueCache $cache, array $featureStateResolvers, + ?Closure $writeGuard = null, ) { $this->db = $db; $this->connection = $connection; @@ -113,6 +122,7 @@ public function __construct( $this->partitionResolver = $partitionResolver; $this->cache = $cache; $this->featureStateResolvers = $featureStateResolvers; + $this->writeGuard = $writeGuard; $this->unknownFeatureValue = new stdClass; } @@ -378,6 +388,7 @@ protected function resolveValue(string $feature, mixed $scope): mixed public function set(string $feature, mixed $scope, mixed $value): void { $partition = $this->partition(); + $this->writeGuard?->__invoke($partition); $scope = Feature::serializeScope($scope); $now = CarbonImmutable::now(); @@ -402,6 +413,7 @@ public function setAll(array $features): void } $partition = $this->partition(); + $this->writeGuard?->__invoke($partition); $now = CarbonImmutable::now(); $rows = []; $scopes = []; @@ -441,6 +453,7 @@ public function setAll(array $features): void public function setForAllScopes(string $feature, mixed $value): void { $partition = $this->partition(); + $this->writeGuard?->__invoke($partition); $this->newPartitionQuery($partition) ->where('name', $feature) @@ -467,6 +480,11 @@ public function setForAllScopes(string $feature, mixed $value): void */ protected function insertMany(?FeaturePartition $partition, array $pairs): array { + // Unknown features do not write. Guard known values before their resolvers or events run. + if ($this->writeGuard !== null && array_intersect_key($pairs, $this->featureStateResolvers) !== []) { + ($this->writeGuard)($partition); + } + $values = []; $stored = []; @@ -609,6 +627,7 @@ protected function uniqueBy(?FeaturePartition $partition): array public function delete(string $feature, mixed $scope): void { $partition = $this->partition(); + $this->writeGuard?->__invoke($partition); $scope = Feature::serializeScope($scope); $this->newPartitionQuery($partition) @@ -630,6 +649,7 @@ public function purge(?array $features): void } $partition = $this->partition(); + $this->writeGuard?->__invoke($partition); if ($features === null) { $this->newPartitionQuery($partition)->delete(); @@ -642,6 +662,14 @@ public function purge(?array $features): void $this->invalidate($partition); } + /** + * Invalidate the current partition's shared cached values after the transaction commits. + */ + public function flushSharedCache(): void + { + $this->invalidate($this->partition()); + } + /** * Rotate the shared cache tokens of the given scopes, or of the whole partition, once the write commits. * diff --git a/src/pennant/src/Drivers/Decorator.php b/src/pennant/src/Drivers/Decorator.php index 2e85772dcb..66e63356d7 100644 --- a/src/pennant/src/Drivers/Decorator.php +++ b/src/pennant/src/Drivers/Decorator.php @@ -1027,6 +1027,16 @@ public function flushCache(): void } } + /** + * Invalidate shared cached database values for the current partition. + */ + public function flushSharedCache(): void + { + if ($this->driver instanceof DatabaseDriver) { + $this->driver->flushSharedCache(); + } + } + /** * Get the underlying feature driver. */ diff --git a/src/pennant/src/Feature.php b/src/pennant/src/Feature.php index 4c00a45ec5..f129aa27ab 100644 --- a/src/pennant/src/Feature.php +++ b/src/pennant/src/Feature.php @@ -15,6 +15,7 @@ * @method static \Hypervel\Pennant\FeatureManager forgetDriver(null|array|string $name = null) * @method static \Hypervel\Pennant\FeatureManager forgetDrivers() * @method static string getDefaultDriver() + * @method static void guardWritesUsing(\Closure $guard) * @method static void resolvePartitionUsing(string $column, \Closure $resolver) * @method static void resolveScopeUsing(callable $resolver) * @method static string serializeScope(mixed $scope) @@ -28,6 +29,7 @@ * @method static array defined() * @method static void discover(string $namespace = 'App\\Features', string|null $path = null) * @method static void flushMacros() + * @method static void flushSharedCache() * @method static \Hypervel\Pennant\Contracts\Driver getDriver() * @method static \Hypervel\Pennant\PendingScopedFeatureInteraction globally() * @method static bool hasMacro(string $name) diff --git a/src/pennant/src/FeatureManager.php b/src/pennant/src/FeatureManager.php index e9a0aefe31..0d2881137b 100644 --- a/src/pennant/src/FeatureManager.php +++ b/src/pennant/src/FeatureManager.php @@ -66,6 +66,13 @@ class FeatureManager */ protected ?Closure $partitionResolver = null; + /** + * The callback that guards database writes. + * + * @var null|(Closure(string, ?FeaturePartition): void) + */ + protected ?Closure $writeGuard = null; + /** * Create a new Pennant manager instance. */ @@ -165,6 +172,8 @@ public function createArrayDriver(array $config, string $name): ArrayDriver */ public function createDatabaseDriver(array $config, string $name): DatabaseDriver { + $writeGuard = $this->writeGuard; + return new DatabaseDriver( $this->container->make('db'), $this->databaseConnection($config), @@ -172,6 +181,7 @@ public function createDatabaseDriver(array $config, string $name): DatabaseDrive $this->partitionResolver($name, $config), $this->storedValueCache($config, $name), [], + $writeGuard === null ? null : static fn (?FeaturePartition $partition) => $writeGuard($name, $partition), ); } @@ -353,7 +363,7 @@ public function resolveScopeUsing(callable $resolver): void * @param Closure(string): (null|int|string) $resolver * * @throws InvalidArgumentException when the column is not a simple SQL identifier - * @throws RuntimeException when a store has already been resolved + * @throws RuntimeException when a store has already been resolved or partitioning is already configured */ public function resolvePartitionUsing(string $column, Closure $resolver): void { @@ -366,10 +376,37 @@ public function resolvePartitionUsing(string $column, Closure $resolver): void throw new RuntimeException('Register the Pennant partition resolver before resolving any Pennant store.'); } + if ($this->partitionResolver !== null) { + throw new RuntimeException('The Pennant partition resolver has already been registered.'); + } + $this->partitionColumn = $column; $this->partitionResolver = $resolver; } + /** + * Guard database writes, including the first resolution of a stored value. + * + * Boot-only. Register once, before any store is resolved. The callback persists + * on database stores for the worker lifetime and affects every subsequent write. + * + * @param Closure(string, ?FeaturePartition): void $guard + * + * @throws RuntimeException when a store has already been resolved or a guard is already registered + */ + public function guardWritesUsing(Closure $guard): void + { + if ($this->stores !== []) { + throw new RuntimeException('Register the Pennant write guard before resolving any Pennant store.'); + } + + if ($this->writeGuard !== null) { + throw new RuntimeException('The Pennant write guard has already been registered.'); + } + + $this->writeGuard = $guard; + } + /** * The resolver of a store's current partition. * diff --git a/tests/Pennant/PartitionTest.php b/tests/Pennant/PartitionTest.php index 7f975b501a..88f286b7b7 100644 --- a/tests/Pennant/PartitionTest.php +++ b/tests/Pennant/PartitionTest.php @@ -10,6 +10,7 @@ use Hypervel\Foundation\Testing\RefreshDatabase; use Hypervel\Pennant\Exceptions\FeaturePartitionNotResolved; use Hypervel\Pennant\Feature; +use Hypervel\Pennant\FeaturePartition; use Hypervel\Support\Facades\Artisan; use Hypervel\Support\Facades\DB; use Hypervel\Support\Facades\Schema; @@ -28,6 +29,11 @@ class PartitionTest extends TestCase */ protected array $resolvedStores = []; + /** + * @var list + */ + protected array $guardedWrites = []; + protected function defineEnvironment(Application $app): void { $config = $app->make('config'); @@ -65,6 +71,10 @@ protected function setUpInCoroutine(): void return $this->team; }); + Feature::guardWritesUsing(function (string $store, ?FeaturePartition $partition): void { + $this->guardedWrites[] = [$store, $partition]; + }); + Feature::define('foo', fn () => "team-{$this->team}"); Feature::define('bar', fn () => "team-{$this->team}"); } @@ -86,6 +96,10 @@ public function testReadsAndFirstResolutionStayInThePartition(): void [2, 'foo', 'taylor', '"team-2"'], ], $this->rows()); $this->assertSame(['database'], array_values(array_unique($this->resolvedStores))); + $this->assertEquals([ + ['database', new FeaturePartition('team_id', 1)], + ['database', new FeaturePartition('team_id', 2)], + ], $this->guardedWrites); } public function testGroupedReadsStayInThePartition(): void diff --git a/tests/Pennant/StoredValueCacheTest.php b/tests/Pennant/StoredValueCacheTest.php index cbe87fa5ec..49b0445a9b 100644 --- a/tests/Pennant/StoredValueCacheTest.php +++ b/tests/Pennant/StoredValueCacheTest.php @@ -118,6 +118,20 @@ public function testWritesToEveryScopeInvalidateThePartition(): void $this->assertSame(['everyone', 'everyone'], Feature::for(['taylor', 'otwell'])->load('foo')['foo']); } + public function testSharedCacheCanBeFlushedAfterRawChangesWithoutDeletingStoredValues(): void + { + Feature::for('taylor')->value('foo'); + DB::table('features')->where('name', 'foo')->update(['value' => '"changed"']); + Feature::store('database')->flushSharedCache(); + + // The current request retains its memoized snapshot until explicitly flushed. + $this->assertSame('resolved', Feature::for('taylor')->value('foo')); + Feature::flushCache(); + + $this->assertSame('changed', Feature::for('taylor')->value('foo')); + $this->assertDatabaseCount('features', 1); + } + public function testUnknownFeatureValuesAreNotCached(): void { $this->assertFalse(Feature::for('taylor')->value('unknown')); diff --git a/tests/Pennant/WriteGuardTest.php b/tests/Pennant/WriteGuardTest.php new file mode 100644 index 0000000000..5fd5390308 --- /dev/null +++ b/tests/Pennant/WriteGuardTest.php @@ -0,0 +1,140 @@ +make('config')->set('pennant.default', 'database'); + } + + /** + * Register the guard before defining features. + */ + protected function setUpInCoroutine(): void + { + $this->failure = new RuntimeException('Feature storage is read-only.'); + Feature::guardWritesUsing(function (string $store, ?FeaturePartition $partition): void { + $this->assertSame('database', $store); + $this->assertNull($partition); + ++$this->writes; + + if ($this->blocked) { + throw $this->failure; + } + }); + Feature::define('flag', function (): int { + return ++$this->resolutions; + }); + } + + #[DataProvider('mutations')] + public function testGuardRejectsWritesBeforeResolvingValues(Closure $mutate): void + { + $this->blocked = true; + + try { + $mutate(); + $this->fail('The write guard must reject the mutation.'); + } catch (RuntimeException $exception) { + $this->assertSame($this->failure, $exception); + } + + $this->assertSame(1, $this->writes); + $this->assertSame(0, $this->resolutions); + $this->assertDatabaseCount('features', 0); + } + + /** + * Provide the database mutation surfaces. + */ + public static function mutations(): array + { + return [ + 'first resolution' => [fn () => Feature::for('person')->value('flag')], + 'bulk first resolution' => [fn () => Feature::for(['one', 'two'])->load('flag')], + 'set' => [fn () => Feature::store()->set('flag', 'person', true)], + 'set all' => [fn () => Feature::for(['one', 'two'])->activate('flag')], + 'set for all scopes' => [fn () => Feature::activateForEveryone('flag')], + 'delete' => [fn () => Feature::for('person')->forget('flag')], + 'purge' => [fn () => Feature::purge('flag')], + 'purge all' => [fn () => Feature::purge()], + ]; + } + + public function testReadsUnknownFeaturesAndEmptyMutationsDoNotInvokeTheGuard(): void + { + $this->assertSame(1, Feature::for('person')->value('flag')); + $this->blocked = true; + Feature::flushCache(); + + $this->assertSame(1, Feature::for('person')->value('flag')); + $this->assertFalse(Feature::for('person')->value('unknown')); + Feature::purge([]); + Feature::activate([]); + Feature::store('array')->for('person')->activate('flag'); + Feature::store('array')->flushSharedCache(); + + $this->assertTrue(Feature::store('array')->for('person')->active('flag')); + $this->assertSame(1, $this->writes); + } + + #[DataProvider('registrations')] + public function testRegistrationMustBeOnceAndBeforeResolvingStores(string $method, bool $resolve): void + { + $manager = new FeatureManager($this->app); + $register = $method === 'partition' + ? fn () => $manager->resolvePartitionUsing('team_id', fn () => 1) + : fn () => $manager->guardWritesUsing(static function (): void {}); + $name = $method === 'partition' ? 'partition resolver' : 'write guard'; + + if ($resolve) { + $manager->store('array'); + } else { + $register(); + } + + $this->expectExceptionObject(new RuntimeException($resolve + ? "Register the Pennant {$name} before resolving any Pennant store." + : "The Pennant {$name} has already been registered.")); + + $register(); + } + + /** + * Provide conflicting boot registrations. + */ + public static function registrations(): array + { + return [ + 'duplicate partition' => ['partition', false], + 'duplicate guard' => ['guard', false], + 'late guard' => ['guard', true], + ]; + } +} From 4a5065a3d0dfeb0493c8b66e90d6cd1e6ce64624 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:08:28 +0000 Subject: [PATCH 04/30] fix(testing): catch native alarms during coroutine timeout cancellation The coroutine watchdog and PHPUnit's native SIGALRM can reach the test limit together. If the watchdog throws its timeout first, the native alarm can arrive while the catch block calls pcntl_alarm(0). That second exception escapes the raw coroutine callback and terminates the child process instead of producing PHPUnit's normal timeout report. This caused an intermittent PHP 8.5 CI failure for an incomplete test with a pending child coroutine. Cancel the native alarm inside the protected try block, before dispatching pending signals and throwing the watchdog timeout. A signal delivered at cancellation is now caught before child cleanup. If the native alarm wins first, its one-shot timer has already expired. Non-yielding tests retain their native timeout, and normal execution, original exception reporting, and child cancellation behavior remain unchanged. Add an isolated regression fixture that disables the scheduled native alarm after watchdog setup and delivers the only SIGALRM at cancellation. It uses PHPUnit's real signal handler without depending on timer ordering, clock manipulation, or repeated attempts. The parent checks the timeout report, test identity, unsuccessful exit, and delivery event so the regression cannot silently stop exercising the cancellation boundary. Verified that the regression fails with the original uncaught exception before the source change. After the fix, all 14 timeout cases and 52 assertions pass on PHP 8.4 and the exact PHP 8.5 CI image. The related PHPUnit suite passes with 77 tests and 237 assertions under ParaTest. Source and type-fixture PHPStan checks, changed-file formatting, and whitespace checks also pass. --- .../Testing/Concerns/RunTestsInCoroutine.php | 3 +- .../Fixtures/TimeLimitAlarmFixture.php | 44 +++++++++++++++++++ tests/Testing/PHPUnit/TimeLimitTest.php | 15 ++++++- 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 tests/Testing/PHPUnit/Fixtures/TimeLimitAlarmFixture.php diff --git a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php index eb38e870b6..63ad5cefb5 100644 --- a/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php +++ b/src/foundation/src/Testing/Concerns/RunTestsInCoroutine.php @@ -182,12 +182,13 @@ protected function enforceCoroutineTestTimeLimit(?TimeoutException &$timeoutExce } } + // Cancel the native alarm while protected so it cannot fire during cleanup. + pcntl_alarm(0); pcntl_signal_dispatch(); throw new TimeoutException('The coroutine test exceeded its time limit.'); } catch (TimeoutException $exception) { $timeoutException = $exception; - pcntl_alarm(0); foreach (Coroutine::list() as $coroutine) { if ($coroutine !== $current) { diff --git a/tests/Testing/PHPUnit/Fixtures/TimeLimitAlarmFixture.php b/tests/Testing/PHPUnit/Fixtures/TimeLimitAlarmFixture.php new file mode 100644 index 0000000000..7522bab5d5 --- /dev/null +++ b/tests/Testing/PHPUnit/Fixtures/TimeLimitAlarmFixture.php @@ -0,0 +1,44 @@ +fail('The sleeping test was not interrupted.'); + } + } +} diff --git a/tests/Testing/PHPUnit/TimeLimitTest.php b/tests/Testing/PHPUnit/TimeLimitTest.php index 581fff01aa..3c854cf638 100644 --- a/tests/Testing/PHPUnit/TimeLimitTest.php +++ b/tests/Testing/PHPUnit/TimeLimitTest.php @@ -64,6 +64,17 @@ public static function timedOutTests(): array ]; } + public function testNativeAlarmAtCancellationIsReportedAsTimeout(): void + { + $process = $this->runFixture('testAlarmAtCancellationIsReportedAsTimeout', 1, 'TimeLimitAlarmFixture.php'); + $output = $process->getOutput() . $process->getErrorOutput(); + + $this->assertFalse($process->isSuccessful()); + $this->assertStringContainsString('TimeLimitAlarmFixture::testAlarmAtCancellationIsReportedAsTimeout', $output); + $this->assertStringContainsString('This test was aborted after 1 second', $output); + $this->assertContains('alarm delivered', $this->fixtureEvents()); + } + #[DataProvider('failedTests')] public function testFailuresKeepTheirCauseWhenChildrenReachTheTimeLimit(string $method, array $messages): void { @@ -134,7 +145,7 @@ public static function completedTests(): array /** * Run a fixture with its own PHPUnit time limit. */ - protected function runFixture(string $method, int $limit): Process + protected function runFixture(string $method, int $limit, string $fixture = 'TimeLimitFixture.php'): Process { // Leave headroom for the native timeout report, but do not wait for the fixture's 30-second sleep. $process = new Process( @@ -144,7 +155,7 @@ protected function runFixture(string $method, int $limit): Process '--no-progress', '--default-time-limit=' . $limit, '--filter=' . $method, - 'tests/Testing/PHPUnit/Fixtures/TimeLimitFixture.php', + 'tests/Testing/PHPUnit/Fixtures/' . $fixture, ], cwd: dirname(__DIR__, 3), env: ['TIME_LIMIT_FIXTURE_LOG' => $this->tempDirectory . '/events.log'], From ede0375170de4f024d4c1b20c83087a401ff6aa1 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:35:09 +0000 Subject: [PATCH 05/30] Clarify verification timing and scope Run changed test files during implementation, then perform formatting, full-project static analysis and affected tests at the end of a coherent implementation slice. Reserve earlier static analysis for typing investigations that guide implementation. Keep Composer scripts as the standard verification entry points. Limit full test-suite runs to changes with framework-wide impact, and repeat only affected checks after corrections instead of restarting verification for every review round. --- AGENTS.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5bab5ab185..553771997f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,13 +113,17 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi ### Verification -**Run verification commands sequentially** — Never overlap test, lint, formatting, or static-analysis commands. Wait for each to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine. +**Run verification commands sequentially** — Never overlap formatting, static analysis or tests. Wait for each command to finish before starting another. A tool’s built-in parallelism, such as ParaTest workers, is fine. -During implementation, run new or changed test files immediately. After completing a coherent implementation slice, run the affected package or focused test suite. +**During implementation**, run each new or changed test file immediately with `./vendor/bin/phpunit --no-progress path/to/TestClass.php`. -Use checks that match the change. For isolated changes, run `composer lint:fix`, `composer analyse`, and the affected tests. Run a single affected test file with PHPUnit. Use ParaTest when the affected tests span multiple files. Only run `composer fix` when changes could affect code beyond the affected tests; it already runs formatting, analysis, and all test suites, so do not run those checks separately first. +Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. -After a failure, fix the cause and rerun only affected tests, formatting or static analysis. Then finish any verification not yet run. Repeat the full test suite only if the fix itself has framework-wide impact. +**At the end of a coherent implementation slice**, run `composer lint:fix`, `composer analyse`, and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection. + +Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows. + +After a failure or review correction, fix the cause, repeat only checks affected by the correction, and finish any outstanding verification. Do not restart the entire sequence merely because another review round occurred. ## Development Conventions From becb7457634ec9c9e02cecd7b2943fd68798d43d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:40:28 +0000 Subject: [PATCH 06/30] Correct OAuth client-credentials request encoding Omit the optional scope parameter when the combined default and requested scopes are empty after filtering. Preserve nonempty scopes, including zero, and the configured separator in both client-credentials request variants. Form-encode each client credential before constructing the OAuth Basic authentication header, as RFC 6749 section 2.3.1 requires. This preserves colons, plus signs, spaces and literal percent escapes when the server decodes the credentials. Generic HTTP Basic authentication and credentials sent in form bodies retain their existing behavior. Extend the grant tests with exact encoded header expectations and filtered-empty scope cases. Document token-request customization for providers that expect unencoded Basic credentials. Verified formatting, source and type-fixture static analysis, and the affected OAuth and authentication tests. --- src/docs/saloon.md | 2 + ...ClientCredentialsTokenBasicAuthRequest.php | 9 ++-- .../GetClientCredentialsTokenRequest.php | 4 +- .../ClientCredentialsFlowConnectorTest.php | 41 ++++++++++++------- .../ClientCredentialsBasicAuthConnector.php | 15 ++++++- 5 files changed, 51 insertions(+), 20 deletions(-) diff --git a/src/docs/saloon.md b/src/docs/saloon.md index 650c455e69..d9c0308535 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -1615,6 +1615,8 @@ $authenticator = $connector->getAccessToken(['reports:read']); The given scopes are added to the configuration's `defaultScopes` and separated by spaces unless you pass a `scopeSeparator`. Client credentials connectors need no `redirectUri`. Use `ClientCredentialsBasicAuthGrant` when the provider requires the client credentials in an HTTP Basic authentication header. +`ClientCredentialsBasicAuthGrant` form-encodes the client ID and secret before adding them to the header, as the OAuth 2 specification requires. If your provider expects unencoded credentials, you may [customize the token request](#customizing-oauth-requests). + OAuth token grant methods return an OAuth authenticator by default. Pass `returnResponse: true` when you need the original Saloon response instead: ```php diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php index b1c82e659a..85b55058f1 100644 --- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php +++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenBasicAuthRequest.php @@ -16,9 +16,11 @@ class GetClientCredentialsTokenBasicAuthRequest extends GetClientCredentialsToke */ protected function defaultBody(): array { + $scopes = $this->oauthConfig->scopes($this->scopes); + return [ 'grant_type' => 'client_credentials', - 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)), + ...($scopes === [] ? [] : ['scope' => implode($this->scopeSeparator, $scopes)]), ]; } @@ -27,9 +29,10 @@ protected function defaultBody(): array */ protected function defaultAuth(): ?Authenticator { + // RFC 6749 §2.3.1 requires form encoding each credential before HTTP Basic encoding. return new BasicAuthenticator( - $this->oauthConfig->clientId, - $this->oauthConfig->clientSecret, + urlencode($this->oauthConfig->clientId), + urlencode($this->oauthConfig->clientSecret), ); } } diff --git a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php index a3123e106f..0aa724d611 100644 --- a/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php +++ b/src/saloon/src/Http/OAuth2/GetClientCredentialsTokenRequest.php @@ -55,11 +55,13 @@ public function allowsBaseUrlOverride(): bool */ protected function defaultBody(): array { + $scopes = $this->oauthConfig->scopes($this->scopes); + return [ 'grant_type' => 'client_credentials', 'client_id' => $this->oauthConfig->clientId, 'client_secret' => $this->oauthConfig->clientSecret, - 'scope' => implode($this->scopeSeparator, $this->oauthConfig->scopes($this->scopes)), + ...($scopes === [] ? [] : ['scope' => implode($this->scopeSeparator, $scopes)]), ]; } } diff --git a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php index a1cca9205b..bcbf9ca551 100644 --- a/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php +++ b/tests/Saloon/Feature/Oauth2/ClientCredentialsFlowConnectorTest.php @@ -24,6 +24,7 @@ use Hypervel\Tests\Saloon\Fixtures\Connectors\NoConfigClientCredentialsConnector; use Hypervel\Tests\Saloon\Fixtures\Requests\OAuth\CustomClientCredentialsAccessTokenRequest; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\TestWith; use UnexpectedValueException; // Connectors take no mock client, so sends are faked through Saloon::fake(), and changed configuration is passed to @@ -61,7 +62,6 @@ public function testYouCanGetTheAuthenticatorFromTheConnector(): void 'grant_type' => 'client_credentials', 'client_id' => 'client-id', 'client_secret' => 'client-secret', - 'scope' => '', ], $mockClient->lastPendingRequest()->body()); } @@ -224,17 +224,14 @@ public function testNullAndEmptyScopesAreIgnored(ClientCredentialsBasicAuthConne $this->assertSame('clients 0', $mockClient->lastPendingRequest()->body()['scope']); } - /** - * Get connectors with the default and Basic authentication token requests. - * - * @return array - */ - public static function scopeConnectors(): array + #[DataProvider('scopeConnectors')] + public function testScopeIsOmittedWhenAllScopesAreEmpty(ClientCredentialsBasicAuthConnector|ClientCredentialsConnector $connector): void { - return [ - 'default request' => [new ClientCredentialsConnector], - 'basic auth request' => [new ClientCredentialsBasicAuthConnector], - ]; + $mockClient = Saloon::fake([MockResponse::make(['access_token' => 'access'])]); + + $connector->getAccessToken(['', null]); + + $this->assertArrayNotHasKey('scope', $mockClient->lastPendingRequest()->body()); } public function testIfYouAttemptToUseTheClientCredentialsFlowWithoutAClientIdItWillThrowAnException(): void @@ -281,13 +278,15 @@ public function testOnTheConnectorYouCanOverwriteTheGetAccessTokenRequest(): voi $this->assertInstanceOf(CustomClientCredentialsAccessTokenRequest::class, $accessTokenResponse->request()); } - public function testTheClientCredentialsGrantCanUseBasicAuth(): void + #[TestWith(['client-id', 'client-secret', 'client-id:client-secret'])] + #[TestWith(['id:a+b c%20', 's:e+c ret%20', 'id%3Aa%2Bb+c%2520:s%3Ae%2Bc+ret%2520'])] + public function testTheClientCredentialsGrantCanUseBasicAuth(string $clientId, string $clientSecret, string $encodedCredentials): void { $mockClient = new MockClient([ MockResponse::make(['access_token' => 'access', 'expires_in' => 3600], 200), ]); - $connector = new ClientCredentialsBasicAuthConnector; + $connector = new ClientCredentialsBasicAuthConnector(['clientId' => $clientId, 'clientSecret' => $clientSecret]); Saloon::fake($mockClient); $authenticator = $connector->getAccessToken(); @@ -302,11 +301,10 @@ public function testTheClientCredentialsGrantCanUseBasicAuth(): void $this->assertSame([ 'grant_type' => 'client_credentials', - 'scope' => '', ], $mockClient->lastPendingRequest()->body()); $this->assertSame( - 'Basic ' . base64_encode('client-id:client-secret'), + 'Basic ' . base64_encode($encodedCredentials), $mockClient->lastPendingRequest()->headers()['Authorization'], ); } @@ -336,6 +334,19 @@ public function testReportedScopesArePreservedWithoutInferringRequestedPermissio } } + /** + * Get connectors with the default and Basic authentication token requests. + * + * @return array + */ + public static function scopeConnectors(): array + { + return [ + 'default request' => [new ClientCredentialsConnector(['defaultScopes' => ['', null]])], + 'basic auth request' => [new ClientCredentialsBasicAuthConnector(['defaultScopes' => ['', null]])], + ]; + } + public function testACustomAuthenticatorReceivesTheExpiry(): void { Date::setTestNow('2026-01-01T00:00:00+00:00'); diff --git a/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php b/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php index be4d2f0405..40e997f878 100644 --- a/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php +++ b/tests/Saloon/Fixtures/Connectors/ClientCredentialsBasicAuthConnector.php @@ -12,6 +12,15 @@ class ClientCredentialsBasicAuthConnector extends Connector { use ClientCredentialsBasicAuthGrant; + /** + * Create the connector, replacing any of its OAuth configuration arguments. + * + * @param array $config + */ + public function __construct(protected array $config = []) + { + } + /** * Define the base URL. */ @@ -25,6 +34,10 @@ public function resolveBaseUrl(): string */ protected function defaultOauthConfig(): OAuthConfig { - return new OAuthConfig(clientId: 'client-id', clientSecret: 'client-secret'); + return new OAuthConfig(...[ + 'clientId' => 'client-id', + 'clientSecret' => 'client-secret', + ...$this->config, + ]); } } From 9aec8c5d26f1cd7452ffbc566117576846e3a935 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:11 +0000 Subject: [PATCH 07/30] Port queue job durations, timeout notification, flush filters and enum pause checks Port laravel/framework#61672, #62118 and #62155. JobProcessed and JobAttempted carry the job's run time in milliseconds as a nullable float. Worker::process() and SyncQueue::processPayload() time fire() in a finally block, so failed jobs report it too, and the sync path covers coroutine queues. Port #61651 with its test follow-up #61787. When the timeout monitor handles a timed-out job, it first notifies an Interruptible job with SIGALRM through the extracted notifyJobOfSignal(), reporting a failing notification instead of skipping the timeout. Port #61630. queue:flush accepts --queue, alone or with --hours, and the database, UUID, file and null providers filter by queue. The owner-approved FailedJobProviderInterface::flush() signature declares the ?string $queue parameter, so custom providers receive the filter instead of flushing every queue. Messages keep a queue named "0". The Cloud and DynamoDB providers are not ported. Port #61725 and its facade update (bc9329ca53). QueueManager::isPaused() accepts enums, keeping the queue-first argument order adopted from master (#61076). These changes share queues.md, which now documents the duration, the timeout notification, the flush options and isPaused() enums. Port the upstream tests into QueueWorkerTest, QueueSyncQueueTest, the failed-job provider tests and QueuePauseResumeTest, add UUID provider and flush command queue cases, and update the command signature fixture. Validation: composer lint:fix, composer analyse, the queue suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/docs/queues.md | 15 +++- src/queue/src/Console/FlushFailedCommand.php | 26 +++--- src/queue/src/Events/JobAttempted.php | 4 + src/queue/src/Events/JobProcessed.php | 5 +- .../src/Failed/DatabaseFailedJobProvider.php | 6 +- .../Failed/DatabaseUuidFailedJobProvider.php | 6 +- .../src/Failed/FailedJobProviderInterface.php | 4 +- .../src/Failed/FileFailedJobProvider.php | 14 +++- .../src/Failed/NullFailedJobProvider.php | 4 +- src/queue/src/QueueManager.php | 4 +- src/queue/src/SyncQueue.php | 20 +++-- src/queue/src/Worker.php | 69 ++++++++++------ src/support/src/Facades/Queue.php | 2 +- tests/Console/Fixtures/command_signatures.php | 11 +++ tests/Queue/DatabaseFailedJobProviderTest.php | 27 ++++++ .../DatabaseUuidFailedJobProviderTest.php | 10 +++ tests/Queue/FileFailedJobProviderTest.php | 32 ++++++++ tests/Queue/QueueFlushFailedCommandTest.php | 11 ++- tests/Queue/QueuePauseResumeTest.php | 11 +++ tests/Queue/QueueSyncQueueTest.php | 21 +++++ tests/Queue/QueueWorkerTest.php | 82 ++++++++++++++++++- 21 files changed, 318 insertions(+), 66 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index cfdd22ed70..332cb8a2bb 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3030,7 +3030,7 @@ class ImportProducts implements ShouldQueue, Interruptible } ``` -The `interrupted` method is only invoked when the worker receives a process signal while the job is currently running. In Hypervel, a worker may run several jobs concurrently, so every currently running job that implements `Interruptible` will be notified. Keep this method lightweight, such as setting a flag or quickly saving progress, since the worker calls it synchronously while handling the signal. It is not a replacement for [timeouts](#worker-timeouts) or the job's [`failed` method](#cleaning-up-after-failed-jobs). +The `interrupted` method is invoked when the worker receives a process signal while the job is currently running. In Hypervel, a worker may run several jobs concurrently, so every currently running job that implements `Interruptible` will be notified. The worker also calls the method with `SIGALRM` when the job exceeds its [timeout](#timeout), just before the timeout is handled. Keep this method lightweight, such as setting a flag or quickly saving progress, since the worker calls it synchronously while handling the signal. It is not a replacement for [timeouts](#worker-timeouts) or the job's [`failed` method](#cleaning-up-after-failed-jobs). ### Job Expirations and Timeouts @@ -3118,7 +3118,7 @@ Queue::getPausedQueues(['emails', 'notifications']); Queue::resume('emails'); ``` -The `pause`, `pauseFor`, and `resume` methods also accept enums for queue and connection names. +The `pause`, `pauseFor`, `resume`, and `isPaused` methods also accept enums for queue and connection names. Queue workers report paused and resumed queues in their console output. @@ -3444,6 +3444,14 @@ The `queue:flush` command removes all failed job records from your queue, no mat php artisan queue:flush --hours=48 ``` +The `--queue` option limits the command to jobs that failed on the given queue. You may combine it with the `--hours` option: + +```shell +php artisan queue:flush --queue=emails + +php artisan queue:flush --queue=emails --hours=48 +``` + To prevent `queue:flush` from running in production, call the command's `prohibit` method from your `AppServiceProvider`'s `boot` method: ```php @@ -4065,11 +4073,14 @@ class AppServiceProvider extends ServiceProvider // $event->connectionName // $event->job // $event->job->payload() + // $event->duration }); } } ``` +The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. + Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. The `JobPayloadFinalizing` event runs immediately before `JobQueueing` and may replace its encoded `payload`. It also provides the connection, queue, job, and normalized delay in seconds. Use this event for last-mile payload changes that must reach the queue backend. Listening to both events deliberately runs both listeners for each asynchronous job. diff --git a/src/queue/src/Console/FlushFailedCommand.php b/src/queue/src/Console/FlushFailedCommand.php index a989d220c9..81f9b4d5c5 100644 --- a/src/queue/src/Console/FlushFailedCommand.php +++ b/src/queue/src/Console/FlushFailedCommand.php @@ -17,12 +17,14 @@ class FlushFailedCommand extends Command /** * The console command name. */ - protected ?string $signature = 'queue:flush {--hours= : The number of hours to retain failed job data}'; + protected ?string $signature = 'queue:flush + {--hours= : The number of hours to retain failed job data} + {--queue= : Flush all of the failed jobs for the specified queue}'; /** * The console command description. */ - protected string $description = 'Flush all of the failed queue jobs'; + protected string $description = 'Flush the failed queue jobs'; /** * Execute the console command. @@ -34,18 +36,22 @@ public function handle(): int } $hours = $this->option('hours'); + $queue = $this->option('queue'); $this->hypervel->make(FailedJobProviderInterface::class) - ->flush($hours ? (int) $hours : null); - - if ($this->option('hours')) { - $this->components->info("All jobs that failed more than {$this->option('hours')} hours ago have been deleted successfully."); - - return self::SUCCESS; + ->flush($hours ? (int) $hours : null, $queue); + + // A queue named "0" is valid, so check the queue option against null. + if ($hours && $queue !== null) { + $this->components->info("All jobs on the [{$queue}] queue that failed more than {$hours} hours ago have been deleted successfully."); + } elseif ($hours) { + $this->components->info("All jobs that failed more than {$hours} hours ago have been deleted successfully."); + } elseif ($queue !== null) { + $this->components->info("All failed jobs on the [{$queue}] queue have been deleted successfully."); + } else { + $this->components->info('All failed jobs deleted successfully.'); } - $this->components->info('All failed jobs deleted successfully.'); - return self::SUCCESS; } } diff --git a/src/queue/src/Events/JobAttempted.php b/src/queue/src/Events/JobAttempted.php index 1fba23a28d..be3efc8b17 100644 --- a/src/queue/src/Events/JobAttempted.php +++ b/src/queue/src/Events/JobAttempted.php @@ -11,11 +11,15 @@ class JobAttempted { /** * Create a new event instance. + * + * @param null|Throwable $exception the exception, if one occurred while processing the job + * @param null|float $duration the number of milliseconds the job took to process */ public function __construct( public string $connectionName, public Job $job, public ?Throwable $exception = null, + public ?float $duration = null, ) { } diff --git a/src/queue/src/Events/JobProcessed.php b/src/queue/src/Events/JobProcessed.php index 33590ca6ea..6d42c4d3f1 100644 --- a/src/queue/src/Events/JobProcessed.php +++ b/src/queue/src/Events/JobProcessed.php @@ -10,10 +10,13 @@ class JobProcessed { /** * Create a new event instance. + * + * @param null|float $duration the number of milliseconds the job took to process */ public function __construct( public string $connectionName, - public Job $job + public Job $job, + public ?float $duration = null, ) { } } diff --git a/src/queue/src/Failed/DatabaseFailedJobProvider.php b/src/queue/src/Failed/DatabaseFailedJobProvider.php index 8afd121f19..397db5d732 100644 --- a/src/queue/src/Failed/DatabaseFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseFailedJobProvider.php @@ -73,12 +73,14 @@ public function forget(mixed $id): bool } /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. */ - public function flush(?int $hours = null): void + public function flush(?int $hours = null, ?string $queue = null): void { $this->getTable()->when($hours, function ($query, $hours) { $query->where('failed_at', '<=', Date::now()->subHours($hours)); + })->when(! is_null($queue), function ($query) use ($queue) { + $query->where('queue', $queue); })->delete(); } diff --git a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php index 7568bd878a..04561e3bd8 100644 --- a/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php +++ b/src/queue/src/Failed/DatabaseUuidFailedJobProvider.php @@ -95,12 +95,14 @@ public function forget(mixed $id): bool } /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. */ - public function flush(?int $hours = null): void + public function flush(?int $hours = null, ?string $queue = null): void { $this->getTable()->when($hours, function ($query, $hours) { $query->where('failed_at', '<=', Date::now()->subHours($hours)); + })->when(! is_null($queue), function ($query) use ($queue) { + $query->where('queue', $queue); })->delete(); } diff --git a/src/queue/src/Failed/FailedJobProviderInterface.php b/src/queue/src/Failed/FailedJobProviderInterface.php index ca049d0964..9557007eb5 100644 --- a/src/queue/src/Failed/FailedJobProviderInterface.php +++ b/src/queue/src/Failed/FailedJobProviderInterface.php @@ -34,7 +34,7 @@ public function find(mixed $id): ?object; public function forget(mixed $id): bool; /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. */ - public function flush(?int $hours = null): void; + public function flush(?int $hours = null, ?string $queue = null): void; } diff --git a/src/queue/src/Failed/FileFailedJobProvider.php b/src/queue/src/Failed/FileFailedJobProvider.php index d1ee53f827..2bbb7c163a 100644 --- a/src/queue/src/Failed/FileFailedJobProvider.php +++ b/src/queue/src/Failed/FileFailedJobProvider.php @@ -99,11 +99,19 @@ public function forget(mixed $id): bool } /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. */ - public function flush(?int $hours = null): void + public function flush(?int $hours = null, ?string $queue = null): void { - $this->prune(Date::now()->subHours($hours ?: 0)); + $before = Date::now()->subHours($hours ?: 0)->getTimestamp(); + + $this->lock(function () use ($before, $queue): void { + $this->write((new Collection($this->read())) + ->reject(fn (stdClass $job): bool => $job->failed_at_timestamp <= $before + && (is_null($queue) || $job->queue === $queue)) + ->values() + ->all()); + }); } /** diff --git a/src/queue/src/Failed/NullFailedJobProvider.php b/src/queue/src/Failed/NullFailedJobProvider.php index ea2a74d859..fd7e2696b1 100644 --- a/src/queue/src/Failed/NullFailedJobProvider.php +++ b/src/queue/src/Failed/NullFailedJobProvider.php @@ -49,9 +49,9 @@ public function forget(mixed $id): bool } /** - * Flush all of the failed jobs from storage. + * Flush the failed jobs from storage. */ - public function flush(?int $hours = null): void + public function flush(?int $hours = null, ?string $queue = null): void { } diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php index 7edbff1dbc..4a93fa9735 100644 --- a/src/queue/src/QueueManager.php +++ b/src/queue/src/QueueManager.php @@ -305,8 +305,10 @@ public function resumeAll(): void /** * Determine if a queue is paused. */ - public function isPaused(string $queue, ?string $connection = null): bool + public function isPaused(UnitEnum|string $queue, UnitEnum|string|null $connection = null): bool { + $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue; + $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection; $connection = $connection === null || $connection === '' ? $this->getDefaultDriver() : $connection; // IMPORTANT: Uses Laravel's key for cross-framework queue interoperability. diff --git a/src/queue/src/SyncQueue.php b/src/queue/src/SyncQueue.php index 69d4be2d7a..900a3bc690 100644 --- a/src/queue/src/SyncQueue.php +++ b/src/queue/src/SyncQueue.php @@ -240,9 +240,15 @@ protected function processPayload(string $payload, ?string $queue = null): int try { $this->raiseBeforeJobEvent($queueJob); - $queueJob->fire(); + $startedAt = hrtime(true); - $this->raiseAfterJobEvent($queueJob); + try { + $queueJob->fire(); + } finally { + $duration = round((hrtime(true) - $startedAt) / 1e6, 2); + } + + $this->raiseAfterJobEvent($queueJob, $duration); } catch (CanceledException $exception) { $canceled = true; @@ -259,7 +265,7 @@ protected function processPayload(string $payload, ?string $queue = null): int } } finally { if (! $canceled) { - $this->raiseJobAttemptedEvent($queueJob, $exceptionOccurred ?? null); + $this->raiseJobAttemptedEvent($queueJob, $exceptionOccurred ?? null, $duration ?? null); } } @@ -294,14 +300,14 @@ protected function raiseBeforeJobEvent(JobContract $job): void /** * Raise the after queue job event. */ - protected function raiseAfterJobEvent(JobContract $job): void + protected function raiseAfterJobEvent(JobContract $job, ?float $duration = null): void { if ($this->container->bound('events')) { /** @var EventDispatcher $events */ $events = $this->container->make('events'); if ($events->hasListeners(JobProcessed::class)) { - $events->dispatch(new JobProcessed($this->connectionName, $job)); + $events->dispatch(new JobProcessed($this->connectionName, $job, $duration)); } } } @@ -309,14 +315,14 @@ protected function raiseAfterJobEvent(JobContract $job): void /** * Raise the job attempted event. */ - protected function raiseJobAttemptedEvent(JobContract $job, ?Throwable $exceptionOccurred = null): void + protected function raiseJobAttemptedEvent(JobContract $job, ?Throwable $exceptionOccurred = null, ?float $duration = null): void { if ($this->container->bound('events')) { /** @var EventDispatcher $events */ $events = $this->container->make('events'); if ($events->hasListeners(JobAttempted::class)) { - $events->dispatch(new JobAttempted($this->connectionName, $job, $exceptionOccurred)); + $events->dispatch(new JobAttempted($this->connectionName, $job, $exceptionOccurred, $duration)); } } } diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index f7d67db8ee..c04f76eb0f 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -597,6 +597,12 @@ protected function hasTimeoutJobs(): bool */ protected function handleTimeoutJob(JobContract $job, WorkerOptions $options): void { + try { + $this->notifyJobOfSignal($job, SIGALRM); + } catch (Throwable $exception) { + $this->exceptions->report($exception); + } + $this->markJobAsFailedIfWillExceedMaxAttempts( $job->getConnectionName(), $job, @@ -943,7 +949,13 @@ public function process(string $connectionName, JobContract $job, WorkerOptions // Here we will fire off the job and let it process. We will catch any exceptions, so // they can be reported to the developer's logs, etc. Once the job is finished the // proper events will be fired to let any listeners know this job has completed. - $job->fire(); + $startedAt = $this->currentTime(); + + try { + $job->fire(); + } finally { + $duration = round(($this->currentTime() - $startedAt) * 1000, 2); + } // The monitor has already handled this timeout; do not report normal completion. if (isset($this->timeoutJobIds[$runningJobId])) { @@ -952,7 +964,7 @@ public function process(string $connectionName, JobContract $job, WorkerOptions return; } - $this->raiseAfterJobEvent($connectionName, $job); + $this->raiseAfterJobEvent($connectionName, $job, $duration); if ($job->isReleased() && ! $job->isDeleted() && $this->events->hasListeners(JobReleased::class)) { $this->events->dispatch(new JobReleased( @@ -994,7 +1006,8 @@ public function process(string $connectionName, JobContract $job, WorkerOptions $this->events->dispatch(new JobAttempted( $connectionName, $job, - $exceptionOccurred ?? null + $exceptionOccurred ?? null, + $duration ?? null )); } @@ -1254,12 +1267,13 @@ protected function raiseBeforeJobEvent(string $connectionName, ?JobContract $job /** * Raise the after queue job event. */ - protected function raiseAfterJobEvent(string $connectionName, JobContract $job): void + protected function raiseAfterJobEvent(string $connectionName, JobContract $job, ?float $duration = null): void { if ($this->events->hasListeners(JobProcessed::class)) { $this->events->dispatch(new JobProcessed( $connectionName, - $job + $job, + $duration )); } } @@ -1377,33 +1391,38 @@ protected function handleResumeSignal(string $connectionName, string $queue, Wor protected function notifyJobsOfSignal(int $signal): void { foreach ($this->runningJobs as $runningJob) { - /** @var JobContract $job */ - $job = $runningJob['job']; + $this->notifyJobOfSignal($runningJob['job'], $signal); + } + } - $getResolvedJob = [$job, 'getResolvedJob']; + /** + * Pass the signal to the given job. + */ + protected function notifyJobOfSignal(JobContract $job, int $signal): void + { + $getResolvedJob = [$job, 'getResolvedJob']; - if (! is_callable($getResolvedJob)) { - continue; - } + if (! is_callable($getResolvedJob)) { + return; + } - $handler = $getResolvedJob(); + $handler = $getResolvedJob(); - if (! $handler instanceof CallQueuedHandler) { - continue; - } + if (! $handler instanceof CallQueuedHandler) { + return; + } - $command = $handler->getRunningCommand(); + $command = $handler->getRunningCommand(); - if ($command instanceof Interruptible) { - $command->interrupted($signal); + if ($command instanceof Interruptible) { + $command->interrupted($signal); - if ($this->events->hasListeners(JobInterrupted::class)) { - $this->events->dispatch(new JobInterrupted( - $job->getConnectionName(), - $job, - $signal, - )); - } + if ($this->events->hasListeners(JobInterrupted::class)) { + $this->events->dispatch(new JobInterrupted( + $job->getConnectionName(), + $job, + $signal, + )); } } } diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php index ff7e6d685a..0396182126 100644 --- a/src/support/src/Facades/Queue.php +++ b/src/support/src/Facades/Queue.php @@ -26,7 +26,7 @@ * @method static array getPausedQueues(array $queues, string|null $connection = null) * @method static array getPoolableDrivers() * @method static \Closure|null getReleaseCallback(string $driver) - * @method static bool isPaused(string $queue, string|null $connection = null) + * @method static bool isPaused(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null) * @method static void looping(mixed $callback) * @method static void pause(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null) * @method static void pauseAll() diff --git a/tests/Console/Fixtures/command_signatures.php b/tests/Console/Fixtures/command_signatures.php index 18d0c45234..2fb1843817 100644 --- a/tests/Console/Fixtures/command_signatures.php +++ b/tests/Console/Fixtures/command_signatures.php @@ -3488,6 +3488,17 @@ 'default' => null, 'description' => 'The number of hours to retain failed job data', ], + [ + 'name' => 'queue', + 'shortcut' => null, + 'negatable' => false, + 'valueRequired' => false, + 'valueOptional' => true, + 'isArray' => false, + 'acceptValue' => true, + 'default' => null, + 'description' => 'Flush all of the failed jobs for the specified queue', + ], ], ], \Hypervel\Queue\Console\ForgetFailedCommand::class => [ diff --git a/tests/Queue/DatabaseFailedJobProviderTest.php b/tests/Queue/DatabaseFailedJobProviderTest.php index e64ca6c3ee..1b0c9d68e2 100644 --- a/tests/Queue/DatabaseFailedJobProviderTest.php +++ b/tests/Queue/DatabaseFailedJobProviderTest.php @@ -139,6 +139,33 @@ public function testCanFlushFailedJobs(): void $this->assertSame(0, $this->failedJobsTable()->count()); } + public function testCanFlushFailedJobsForASingleQueue(): void + { + $this->createFailedJobsRecord(['queue' => 'emails']); + $this->createFailedJobsRecord(['queue' => 'emails']); + $this->createFailedJobsRecord(['queue' => 'default']); + + $this->provider->flush(queue: 'emails'); + + $this->assertSame(1, $this->failedJobsTable()->count()); + $this->assertSame('default', $this->failedJobsTable()->first()->queue); + } + + public function testCanFlushFailedJobsForASingleQueueByHours(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + + $this->createFailedJobsRecord(['queue' => 'emails', 'failed_at' => $now->subDays(10)]); + $this->createFailedJobsRecord(['queue' => 'emails', 'failed_at' => $now->subDays(1)]); + $this->createFailedJobsRecord(['queue' => 'default', 'failed_at' => $now->subDays(10)]); + + $this->provider->flush(5 * 24, 'emails'); + + $this->assertSame(2, $this->failedJobsTable()->count()); + $this->assertSame(1, $this->failedJobsTable()->where('queue', 'emails')->count()); + $this->assertSame(1, $this->failedJobsTable()->where('queue', 'default')->count()); + } + public function testCanProperlyLogFailedJob(): void { $uuid = Str::uuid(); diff --git a/tests/Queue/DatabaseUuidFailedJobProviderTest.php b/tests/Queue/DatabaseUuidFailedJobProviderTest.php index fae0892a70..5b4fcf9a76 100644 --- a/tests/Queue/DatabaseUuidFailedJobProviderTest.php +++ b/tests/Queue/DatabaseUuidFailedJobProviderTest.php @@ -122,6 +122,16 @@ public function testRemovingAllFailedJobs(): void $this->assertEmpty($this->provider->all()); } + public function testRemovingAllFailedJobsForAQueue(): void + { + $this->provider->log('connection-1', 'queue-1', json_encode(['uuid' => 'uuid-1']), new RuntimeException); + $this->provider->log('connection-2', 'queue-2', json_encode(['uuid' => 'uuid-2']), new RuntimeException); + + $this->provider->flush(queue: 'queue-1'); + + $this->assertSame(['uuid-2'], $this->provider->ids()); + } + public function testPruningFailedJobs(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromDate(2024, 4, 28)); diff --git a/tests/Queue/FileFailedJobProviderTest.php b/tests/Queue/FileFailedJobProviderTest.php index 8267b4f685..53602fdec7 100644 --- a/tests/Queue/FileFailedJobProviderTest.php +++ b/tests/Queue/FileFailedJobProviderTest.php @@ -173,6 +173,38 @@ public function testCanFlushFailedJobs(): void $this->assertEmpty($failedJobs); } + public function testCanFlushFailedJobsForASingleQueue(): void + { + $this->logFailedJob('connection', 'emails'); + $this->logFailedJob('connection', 'emails'); + $this->logFailedJob('connection', 'default'); + + $this->provider->flush(queue: 'emails'); + + $failedJobs = $this->provider->all(); + + $this->assertCount(1, $failedJobs); + $this->assertSame('default', $failedJobs[0]->queue); + } + + public function testCanFlushFailedJobsForASingleQueueByHours(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + + $this->logFailedJob('connection', 'emails'); + $this->logFailedJob('connection', 'default'); + + CarbonImmutable::setTestNow($now->addHours(10)); + + $this->logFailedJob('connection', 'emails'); + + $this->provider->flush(5, 'emails'); + + $this->assertCount(2, $this->provider->all()); + $this->assertSame(1, $this->provider->count(queue: 'emails')); + $this->assertSame(1, $this->provider->count(queue: 'default')); + } + public function testCanPruneFailedJobs(): void { $this->logFailedJob(); diff --git a/tests/Queue/QueueFlushFailedCommandTest.php b/tests/Queue/QueueFlushFailedCommandTest.php index 4e1c088281..a19a6c4f39 100644 --- a/tests/Queue/QueueFlushFailedCommandTest.php +++ b/tests/Queue/QueueFlushFailedCommandTest.php @@ -32,10 +32,10 @@ public function testProhibitedCommandDoesNotFlushFailedJobs(): void } #[DataProvider('retentionOptions')] - public function testFlushesFailedJobs(array $options, ?int $hours, string $message): void + public function testFlushesFailedJobs(array $options, ?int $hours, ?string $queue, string $message): void { $provider = m::mock(FailedJobProviderInterface::class); - $provider->expects('flush')->with($hours); + $provider->expects('flush')->with($hours, $queue); $app = new Application; $app->instance(FailedJobProviderInterface::class, $provider); $command = new FlushFailedCommand; @@ -52,8 +52,11 @@ public function testFlushesFailedJobs(array $options, ?int $hours, string $messa public static function retentionOptions(): array { return [ - [[], null, 'All failed jobs deleted successfully.'], - [['--hours' => '48'], 48, 'All jobs that failed more than 48 hours ago have been deleted successfully.'], + [[], null, null, 'All failed jobs deleted successfully.'], + [['--hours' => '48'], 48, null, 'All jobs that failed more than 48 hours ago have been deleted successfully.'], + [['--queue' => 'emails'], null, 'emails', 'All failed jobs on the [emails] queue have been deleted successfully.'], + [['--queue' => '0'], null, '0', 'All failed jobs on the [0] queue have been deleted successfully.'], + [['--hours' => '48', '--queue' => 'emails'], 48, 'emails', 'All jobs on the [emails] queue that failed more than 48 hours ago have been deleted successfully.'], ]; } } diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index f0758b8a98..1072e675e2 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -392,9 +392,11 @@ public function testEnumsAreAccepted(UnitEnum $queue, UnitEnum $connection, stri { $this->manager->pause($queue, $connection); $this->assertTrue($this->manager->isPaused($queueName, $connectionName)); + $this->assertTrue($this->manager->isPaused($queue, $connection)); $this->manager->resume($queue, $connection); $this->assertFalse($this->manager->isPaused($queueName, $connectionName)); + $this->assertFalse($this->manager->isPaused($queue, $connection)); $this->manager->pauseFor($queue, 30, $connection); $this->assertTrue($this->manager->isPaused($queueName, $connectionName)); @@ -411,6 +413,15 @@ public static function enumQueueProvider(): array 'unit' => [PauseQueueUnitIdentifier::Emails, PauseQueueUnitIdentifier::Redis, 'Emails', 'Redis'], ]; } + + public function testEnumsAreAcceptedWhenCheckingIfAQueueIsPaused(): void + { + $this->assertFalse($this->manager->isPaused(PauseQueueName::Emails, PauseQueueConnection::Redis)); + + $this->manager->pause('emails', 'redis'); + + $this->assertTrue($this->manager->isPaused(PauseQueueName::Emails, PauseQueueConnection::Redis)); + } } enum PauseQueueConnection: string diff --git a/tests/Queue/QueueSyncQueueTest.php b/tests/Queue/QueueSyncQueueTest.php index 12ef40d826..80ecd3533b 100644 --- a/tests/Queue/QueueSyncQueueTest.php +++ b/tests/Queue/QueueSyncQueueTest.php @@ -169,6 +169,27 @@ public function testFailedJobGetsHandledWhenAnExceptionIsThrown(): void } } + public function testDurationIsPassedToJobEvents(): void + { + $sync = new SyncQueue; + $sync->setConnectionName('sync'); + $container = $this->getContainer(); + $events = new EventsDispatcher($container); + $dispatched = []; + $events->listen([JobProcessed::class, JobAttempted::class], function ($event) use (&$dispatched) { + $dispatched[$event::class] = $event; + }); + $container->instance('events', $events); + $container->instance(EventDispatcher::class, $events); + $sync->setContainer($container); + + $sync->push(SyncQueueTestHandler::class, ['foo' => 'bar']); + + $this->assertIsFloat($dispatched[JobProcessed::class]->duration); + $this->assertIsFloat($dispatched[JobAttempted::class]->duration); + $this->assertTrue($dispatched[JobAttempted::class]->successful()); + } + public function testProcessingAndAttemptedEventsSurroundSuccessfulSyncJob(): void { SyncQueueEventOrder::reset(); diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 64f3c09f5a..ca7e607d67 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -21,7 +21,6 @@ use Hypervel\Contracts\Queue\IndexAwareQueue; use Hypervel\Contracts\Queue\Interruptible; use Hypervel\Contracts\Queue\Job; -use Hypervel\Contracts\Queue\Job as QueueJobContract; use Hypervel\Contracts\Queue\Queue; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\Timer; @@ -108,7 +107,9 @@ public function testJobCanBeFired(): void $this->events->shouldHaveReceived('dispatch')->with(m::type(JobPopping::class))->once(); $this->events->shouldHaveReceived('dispatch')->with(m::type(JobPopped::class))->once(); $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessing::class))->once(); - $this->events->shouldHaveReceived('dispatch')->with(m::type(JobProcessed::class))->once(); + $this->events->shouldHaveReceived('dispatch')->with(m::on(function ($event) { + return $event instanceof JobProcessed && is_float($event->duration); + }))->once(); } public function testJobLifecycleEventsAreNotDispatchedWithoutListeners(): void @@ -1397,6 +1398,28 @@ public function testJobIsReleasedOnException(): void $this->events->shouldNotHaveReceived('dispatch', [m::type(JobProcessed::class)]); } + public function testJobAttemptedEventIsDispatched(): void + { + $e = new RuntimeException; + + $job = new WorkerFakeJob(function () use ($e) { + throw $e; + }); + + $worker = $this->getWorker('default', ['queue' => [$job]]); + $worker->runNextJob('default', 'queue', $this->workerOptions()); + + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobAttempted::class))->once(); + $this->events->shouldHaveReceived('dispatch')->with(m::on(function ($event) use ($job, $e) { + return $event instanceof JobAttempted + && $event->connectionName === 'default' + && $event->job === $job + && $event->exception === $e + && ! $event->successful() + && is_float($event->duration); + }))->once(); + } + public function testJobIsFailedIfExceptionHandlerSaysItShouldntRetry(): void { $exception = new RuntimeException; @@ -2118,6 +2141,44 @@ public function testJobInterruptedEventIsDispatchedForInterruptibleJobs(): void ))->once(); } + public function testInterruptibleJobIsNotifiedWhenTheJobTimesOut(): void + { + $interruptible = new WorkerInterruptibleJob; + + $worker = $this->getWorker('default', ['queue' => []]); + $this->timeOutJob($worker, $this->workerJobWithRunningCommand($interruptible)); + + $this->assertSame([SIGALRM], $interruptible->signals); + } + + public function testTimeoutIsStillHandledWhenNotifyingTheJobFails(): void + { + $exception = new Exception('Failed to interrupt.'); + $interruptible = new class($exception) implements Interruptible { + /** + * Create a new interruptible job instance. + */ + public function __construct( + protected Exception $exception, + ) { + } + + /** + * Handle the job being interrupted by the given signal. + */ + public function interrupted(int $signal): void + { + throw $this->exception; + } + }; + + $worker = $this->getWorker('default', ['queue' => []]); + $this->timeOutJob($worker, $this->workerJobWithRunningCommand($interruptible)); + + $this->exceptionHandler->shouldHaveReceived('report')->with($exception)->once(); + $this->events->shouldHaveReceived('dispatch')->with(m::type(JobTimedOut::class))->once(); + } + public function testJobInterruptedEventIsSkippedWithoutListenersButTheJobIsStillNotified(): void { $this->events->shouldReceive('hasListeners')->andReturnFalse(); @@ -2288,6 +2349,19 @@ private function workerOptions(array $overrides = []) return $options; } + /** + * Register the job as running and handle it as timed out. + */ + private function timeOutJob(InsomniacWorker $worker, Job $job): void + { + $options = $this->workerOptions(); + $worker->currentTime = 100; + $jobId = $worker->registerCoroutineJobForTest($job, $options); + $worker->currentTime = $worker->runningJobExpiryForTest($jobId); + + $worker->terminateTimeoutJobsForTest($options); + } + private function workerJobWithRunningCommand(object $command): WorkerFakeJob { $handler = m::mock(CallQueuedHandler::class); @@ -2850,7 +2924,7 @@ public function shouldStopRetries(Throwable $e): bool } } -class WorkerFakeJob implements QueueJobContract +class WorkerFakeJob implements Job { public $id = ''; @@ -3034,7 +3108,7 @@ public function timeout(): int } } -class WorkerContractOnlyJob implements QueueJobContract +class WorkerContractOnlyJob implements Job { public function uuid(): ?string { From 2844140bc223c41e42bfd92cac7b0f81c1304fd4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 08/30] Port LazyCollection between-clause tests laravel/framework#61678 lets whereBetween() and havingBetween() take LazyCollection bounds. Hypervel already converts traversable bounds, so port the test cases into DatabaseQueryBuilderTest::testWhereBetweens and testHavingAggregate, in upstream order. Validation: the database suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- tests/Database/DatabaseQueryBuilderTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/Database/DatabaseQueryBuilderTest.php b/tests/Database/DatabaseQueryBuilderTest.php index 247cc3ef24..f886870cca 100755 --- a/tests/Database/DatabaseQueryBuilderTest.php +++ b/tests/Database/DatabaseQueryBuilderTest.php @@ -38,6 +38,7 @@ use Hypervel\Pagination\LengthAwarePaginator; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; +use Hypervel\Support\LazyCollection; use Hypervel\Support\Str; use Hypervel\Tests\Database\Fixtures\Enums\Bar; use Hypervel\Tests\Database\Fixtures\Enums\IntegerStatus; @@ -1518,6 +1519,11 @@ public function testWhereBetweens(): void $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->whereBetween('id', LazyCollection::make([1, 2])); + $this->assertSame('select * from "users" where "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); + $subqueryBuilder = $this->getBuilder(); $subqueryBuilder->select('id')->from('posts')->where('status', 'published')->orderByDesc('created_at')->limit(1); $builder = $this->getBuilder(); @@ -2395,6 +2401,11 @@ public function testHavingAggregate(): void $query->from('videos')->select('count(*)')->whereColumn('posts.id', '=', 'videos.post_id'); }, 'videos_count')->having('videos_count', '>', 1); $builder->count(); + + $builder = $this->getBuilder(); + $builder->select('*')->from('users')->havingBetween('id', LazyCollection::make([1, 2])); + $this->assertSame('select * from "users" having "id" between ? and ?', $builder->toSql()); + $this->assertEquals([0 => 1, 1 => 2], $builder->getBindings()); } public function testSubSelectWhereIns(): void From f6b850758ad08f0ad207e1005dee2d0e99bd2214 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 09/30] Port refreshed attribute test for increment and decrement laravel/framework#61680 syncs refreshed attributes to the original state after increment() and decrement(). Hypervel's syncIncrementedOriginals() already does this, so port the test and its generated_users.votes column. The final extra-column assertion expects a clean attribute, matching Hypervel's reviewed increment behavior (9fdc4d843). Validation: the database suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- .../DatabaseEloquentIntegrationTest.php | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index afaeb2a090..ca205e2ff5 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -84,6 +84,7 @@ protected function createSchema() $table->string('first_name'); $table->string('last_name'); $table->string('name')->virtualAs("first_name || ' ' || last_name"); + $table->integer('votes')->default(0); }); $this->schema('default')->create('generated_totals', function (Blueprint $table): void { @@ -314,6 +315,58 @@ public function testConfiguredAttributesAreRefreshedAfterInsertAndUpdate(): void $this->assertSame('Jess Otwell', GeneratedUser::find(10)->name); } + public function testConfiguredAttributesAreRefreshedAfterIncrementAndDecrement(): void + { + $user = GeneratedUser::create([ + 'first_name' => 'Taylor', + 'last_name' => 'Otwell', + 'votes' => 1, + ]); + + $user->increment('votes', 1); + + $this->assertSame(2, $user->votes); + $this->assertFalse($user->isDirty()); + $this->assertSame([], $user->getDirty()); + + $user->save(); + + $user->decrement('votes', 1); + + $this->assertSame(1, $user->votes); + $this->assertFalse($user->isDirty()); + $this->assertSame([], $user->getDirty()); + + $user->save(); + + $user->incrementEach(['votes' => 2]); + + $this->assertSame(3, $user->votes); + $this->assertFalse($user->isDirty()); + $this->assertSame([], $user->getDirty()); + + $user->save(); + + $user->decrementEach(['votes' => 1]); + + $this->assertSame(2, $user->votes); + $this->assertFalse($user->isDirty()); + $this->assertSame([], $user->getDirty()); + + $user->save(); + + $user->increment('votes', 1, ['first_name' => 'Abigail']); + + $this->assertSame(3, $user->votes); + $this->assertSame('Abigail', $user->first_name); + $this->assertSame('Abigail Otwell', $user->name); + $this->assertFalse($user->isDirty('name')); + // Increments record the extra values they store as original values. + $this->assertFalse($user->isDirty('first_name')); + + $user->save(); + } + public function testRefreshedAttributesAreNotWrittenBackAfterIncrements(): void { $item = GeneratedTotal::create(['price' => 2, 'quantity' => 3, 'options' => ['color' => 'red']]); From 2d5900a42807f4ba26441fecbc9a2d9471a6596f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 10/30] Ignore unusable translation groups and reject more unsafe locales Port laravel/framework bbdfa09688 and its 12.x copy c84b5ff4d8. FileLoader::load() returns no lines for an empty locale, an empty group or a group containing "..", a backslash or a null byte. Groups may still name subdirectories inside the locale directory. The locale check shared by FileLoader and Translator::ensureValidLocale() now also rejects ".." anywhere and null bytes. Hypervel keeps throwing for invalid locales instead of ignoring them, so the translation README's difference is updated. Port the group, empty-locale and subdirectory tests into TranslationFileLoaderTest, folding upstream's unsafe-locale cases into the existing rejection data provider, and port TranslationTranslatorTest::testSetLocaleRejectsPathTraversal. Validation: the translation suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/translation/README.md | 1 + src/translation/src/FileLoader.php | 9 +++- src/translation/src/Translator.php | 2 +- .../Translation/TranslationFileLoaderTest.php | 42 ++++++++++++++++++- .../Translation/TranslationTranslatorTest.php | 17 ++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/src/translation/README.md b/src/translation/README.md index f83b84f49c..c7340e40c7 100644 --- a/src/translation/README.md +++ b/src/translation/README.md @@ -9,5 +9,6 @@ Documentation: https://hypervel.org/docs/localization - `Translator::setLocale()` changes the locale only for the current coroutine and does not affect other concurrent requests in the worker. Use the boot-only `setBaseLocale()` method to change the worker's default locale. The `setFallback()` method likewise changes the fallback shared by the worker. - `Translator` rejects JSON translation files whose top-level values are not strings or arrays, naming the file and key. A `null` value is allowed and means the key is untranslated. +- Locales containing path separators, `..`, null bytes, or equal to `.` throw `InvalidArgumentException`, including locales passed directly to `get()` or `FileLoader::load()`. Laravel's file loader returns no lines for them instead. Validate user-supplied locales before using them. Ported from: https://github.com/laravel/framework diff --git a/src/translation/src/FileLoader.php b/src/translation/src/FileLoader.php index d4544d3713..1b1fbf1f67 100644 --- a/src/translation/src/FileLoader.php +++ b/src/translation/src/FileLoader.php @@ -46,10 +46,17 @@ public function __construct( public function load(string $locale, string $group, ?string $namespace = null): array { // Mirrors Translator::ensureValidLocale(); keep both predicates identical. - if (Str::contains($locale, ['/', '\\']) || $locale === '.' || $locale === '..') { + if (Str::contains($locale, ['/', '\\', '..', "\0"]) || $locale === '.') { throw new InvalidArgumentException('Invalid characters present in locale.'); } + // An empty locale has no locale directory, and groups come from translation keys, + // so either one being unusable means there are no lines. Groups may name + // subdirectories but must stay inside the locale directory. + if ($locale === '' || ($group !== '*' && ($group === '' || Str::contains($group, ['..', '\\', "\0"])))) { + return []; + } + if ($group === '*' && $namespace === '*') { return $this->loadJsonPaths($locale); } diff --git a/src/translation/src/Translator.php b/src/translation/src/Translator.php index 9b318805b4..23e7790f54 100644 --- a/src/translation/src/Translator.php +++ b/src/translation/src/Translator.php @@ -674,7 +674,7 @@ public function setBaseLocale(string $locale): void protected function ensureValidLocale(string $locale): void { // Mirrors the trust-boundary check in FileLoader::load(); keep both predicates identical. - if (Str::contains($locale, ['/', '\\']) || $locale === '.' || $locale === '..') { + if (Str::contains($locale, ['/', '\\', '..', "\0"]) || $locale === '.') { throw new InvalidArgumentException('Invalid characters present in locale.'); } } diff --git a/tests/Translation/TranslationFileLoaderTest.php b/tests/Translation/TranslationFileLoaderTest.php index 023ef2f32a..843cafa2ee 100644 --- a/tests/Translation/TranslationFileLoaderTest.php +++ b/tests/Translation/TranslationFileLoaderTest.php @@ -29,6 +29,36 @@ public function testLoadMethodLoadsTranslationsFromAddedPath(): void $this->assertEquals(['foo' => 'bar', 'baz' => 'backagesplash'], $loader->load('en', 'messages')); } + public function testLoadMethodIgnoresLocalesAndGroupsContainingPathTraversal(): void + { + $files = m::mock(Filesystem::class); + $files->shouldNotReceive('exists'); + $files->shouldNotReceive('getRequire'); + $loader = new FileLoader($files, __DIR__); + $loader->addNamespace('namespace', __DIR__ . '/namespace'); + + // Hypervel rejects unsafe nonempty locales with an exception; see testInvalidLocalesAreRejectedBeforeFilesystemAccess(). + $this->assertEquals([], $loader->load('', 'messages')); + $this->assertEquals([], $loader->load('', 'messages', 'namespace')); + $this->assertEquals([], $loader->load('', '*', '*')); + $this->assertEquals([], $loader->load('en', '../../secret/config')); + $this->assertEquals([], $loader->load('en', '..\secret\config')); + $this->assertEquals([], $loader->load('en', '')); + $this->assertEquals([], $loader->load('en', "messages\0")); + $this->assertEquals([], $loader->load('en', '../secret', 'namespace')); + } + + public function testLoadMethodAllowsGroupsInSubdirectories(): void + { + $files = m::mock(Filesystem::class); + $loader = new FileLoader($files, __DIR__); + + $files->expects('exists')->with(__DIR__ . '/en/admin/messages.php')->andReturn(true); + $files->expects('getRequire')->with(__DIR__ . '/en/admin/messages.php')->andReturn(['foo' => 'bar']); + + $this->assertEquals(['foo' => 'bar'], $loader->load('en', 'admin/messages')); + } + public function testLoadMethodHandlesMissingAddedPath(): void { $files = m::mock(Filesystem::class); @@ -168,17 +198,19 @@ public function testLoadMethodWithNamespacesProperlyCallsLoaderAndLoadsLocalOver } #[DataProvider('invalidLocaleProvider')] - public function testInvalidLocalesAreRejectedBeforeFilesystemAccess(string $locale): void + public function testInvalidLocalesAreRejectedBeforeFilesystemAccess(string $locale, string $group = 'messages', ?string $namespace = null): void { $files = m::mock(Filesystem::class); $files->shouldReceive('exists')->never(); $files->shouldReceive('get')->never(); $files->shouldReceive('getRequire')->never(); + $loader = new FileLoader($files, __DIR__); + $loader->addNamespace('namespace', __DIR__ . '/namespace'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessageIs('Invalid characters present in locale.'); - (new FileLoader($files, __DIR__))->load($locale, 'messages'); + $loader->load($locale, $group, $namespace); } /** @@ -191,6 +223,12 @@ public static function invalidLocaleProvider(): array 'backslash' => ['en\US'], 'current directory' => ['.'], 'parent directory' => ['..'], + 'parent directory prefix' => ['../secret', 'config'], + 'backslash parent directory prefix' => ['..\secret', 'config'], + 'parent directory with nested group' => ['..', 'secret/config'], + 'null byte' => ["en\0"], + 'namespaced group' => ['../secret', 'config', 'namespace'], + 'json translations' => ['../secret', '*', '*'], ]; } diff --git a/tests/Translation/TranslationTranslatorTest.php b/tests/Translation/TranslationTranslatorTest.php index 95986ea9e7..d40105f42a 100644 --- a/tests/Translation/TranslationTranslatorTest.php +++ b/tests/Translation/TranslationTranslatorTest.php @@ -26,6 +26,23 @@ class TranslationTranslatorTest extends TestCase { + public function testSetLocaleRejectsPathTraversal(): void + { + $t = new Translator($this->getLoader(), 'en'); + + foreach (['../secret', '..\secret', '..', "en\0"] as $locale) { + try { + $t->setLocale($locale); + + $this->fail("Locale [{$locale}] should have been rejected."); + } catch (InvalidArgumentException $e) { + $this->assertSame('Invalid characters present in locale.', $e->getMessage()); + } + } + + $this->assertSame('en', $t->getLocale()); + } + public function testHasMethodReturnsFalseWhenReturnedTranslationIsNull(): void { $translator = $this->getMockBuilder(Translator::class)->onlyMethods(['get'])->setConstructorArgs([$this->getLoader(), 'en'])->getMock(); From 0a48c87166c47b65904676e58031023ed7461035 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 11/30] Fix Number parsing above 32 bits and negative zero formatting Port laravel/framework#61691. Number::parseInt() parses with TYPE_INT64, so values above the 32-bit range no longer return false. Port #61716. format(), percentage() and currency() no longer return "-0" for values that round to zero, and fileSize() and forHumans() inherit this through format(). The zero check is written as (float) $number === 0.0 to keep upstream's loose comparison. Port the parseInt() and negative-zero cases into SupportNumberTest. Validation: the support suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/support/src/Number.php | 27 +++++++++++++++++++++++---- tests/Support/SupportNumberTest.php | 19 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/support/src/Number.php b/src/support/src/Number.php index 2f4bfb7a19..a1811d2620 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -61,7 +61,7 @@ public static function format(float|int $number, ?int $precision = null, ?int $m $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision); } - return $formatter->format($number); + return $formatter->format(static::withoutNegativeZero($number, $formatter->format(...))); } /** @@ -81,7 +81,7 @@ public static function parse(string $string, ?int $type = NumberFormatter::TYPE_ */ public static function parseInt(string $string, ?string $locale = null): int|false { - return self::parse($string, NumberFormatter::TYPE_INT32, $locale); + return self::parse($string, NumberFormatter::TYPE_INT64, $locale); } /** @@ -153,7 +153,7 @@ public static function percentage(float|int $number, int $precision = 0, ?int $m $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision); } - return $formatter->format($number / 100); + return $formatter->format(static::withoutNegativeZero($number / 100, $formatter->format(...))); } /** @@ -169,7 +169,12 @@ public static function currency(float|int $number, string $in = '', ?string $loc $formatter->setAttribute(NumberFormatter::FRACTION_DIGITS, $precision); } - return $formatter->formatCurrency($number, ! empty($in) ? $in : static::defaultCurrency()); + $currency = ! empty($in) ? $in : static::defaultCurrency(); + + return $formatter->formatCurrency( + static::withoutNegativeZero($number, fn ($number) => $formatter->formatCurrency($number, $currency)), + $currency, + ); } /** @@ -437,6 +442,20 @@ protected static function ensureIntlExtensionIsInstalled(): void } } + /** + * Replace a negative number that would be formatted as zero with zero, avoiding a "-0" result. + * + * @param callable(float|int): (false|string) $format + */ + protected static function withoutNegativeZero(float|int $number, callable $format): float|int + { + if ((float) $number === 0.0) { + return 0; + } + + return $number < 0 && $format(abs($number)) === $format(0) ? 0 : $number; + } + /** * Flush all static state. */ diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index c7d6c77bcc..7485b04ba7 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -51,6 +51,11 @@ public function testFormat(): void $this->assertSame('∞', Number::format(INF)); $this->assertSame('NaN', Number::format(NAN)); + + $this->assertSame('0', Number::format(-0.0)); + $this->assertSame('0', Number::format(-0.4, precision: 0)); + $this->assertSame('0.0', Number::format(-0.04, precision: 1)); + $this->assertSame('-0.1', Number::format(-0.06, precision: 1)); } #[RequiresPhpExtension('intl')] @@ -140,6 +145,11 @@ public function testToPercent(): void $this->assertSame('0.00%', Number::percentage(0, precision: 2)); $this->assertSame('0.12%', Number::percentage(0.12345, precision: 2)); $this->assertSame('0.1235%', Number::percentage(0.12345, precision: 4)); + + $this->assertSame('0%', Number::percentage(-0.4)); + $this->assertSame('0.0%', Number::percentage(-0.04, precision: 1)); + $this->assertSame('-0.4%', Number::percentage(-0.4, precision: 1)); + $this->assertSame('-5%', Number::percentage(-5)); } #[RequiresPhpExtension('intl')] @@ -160,6 +170,11 @@ public function testToCurrency(): void $this->assertSame('$0', Number::currency(0, precision: 0)); $this->assertSame('$5', Number::currency(5.00, precision: 0)); $this->assertSame('$10', Number::currency(10.252, precision: 0)); + + $this->assertSame('$0.00', Number::currency(-0.001)); + $this->assertSame('$0.00', Number::currency(0.1 + 0.2 - 0.3 - 0.0000000001)); + $this->assertSame('$0', Number::currency(-0.4, precision: 0)); + $this->assertSame('-$0.01', Number::currency(-0.006)); } #[RequiresPhpExtension('intl')] @@ -451,6 +466,10 @@ public function testParseInt(): void $this->assertSame(1234, Number::parseInt('1.234', locale: 'de')); $this->assertSame(1234, Number::parseInt('1 234', locale: 'fr')); + + $this->assertSame(3000000000, Number::parseInt('3,000,000,000')); + $this->assertSame(-3000000000, Number::parseInt('-3,000,000,000')); + $this->assertSame(PHP_INT_MAX, Number::parseInt((string) PHP_INT_MAX)); } #[RequiresPhpExtension('intl')] From 84002cfe43c3ef4e7bd5890a01c5c218e81f1af4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 12/30] Port Redis lock refresh tests for serialized connections laravel/framework#61690 fixes PhpRedisLock::refresh() when phpredis serialization is enabled. Hypervel's RedisLock::refresh() already packs only the owner and passes raw seconds, so port the PHP and JSON serialization tests into PhpRedisCacheLockTest with a shared assertion helper. Validation: the Redis cache integration suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- .../Cache/Redis/PhpRedisCacheLockTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php index 99d475c698..1a686584ce 100644 --- a/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php +++ b/tests/Integration/Cache/Redis/PhpRedisCacheLockTest.php @@ -47,6 +47,24 @@ public function testRedisLockCanBeAcquiredAndReleasedWithJsonSerialization(): vo $this->assertLockCanBeAcquiredAndReleased(); } + public function testRedisLockCanBeRefreshedWithPhpSerialization(): void + { + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_PHP, + ]); + + $this->assertLockCanBeRefreshedWithExplicitTtl(); + } + + public function testRedisLockCanBeRefreshedWithJsonSerialization(): void + { + $this->configureLockConnection([ + 'serializer' => Redis::SERIALIZER_JSON, + ]); + + $this->assertLockCanBeRefreshedWithExplicitTtl(); + } + public function testRedisLockCanBeAcquiredAndReleasedWithIgbinarySerialization(): void { if (! defined('Redis::SERIALIZER_IGBINARY')) { @@ -183,4 +201,21 @@ protected function assertLockCanBeAcquiredAndReleased(): void $this->assertTrue($lock->get()); $lock->forceRelease(); } + + /** + * Assert that refreshing a lock with an explicit TTL extends its expiry. + */ + protected function assertLockCanBeRefreshedWithExplicitTtl(): void + { + /** @var RedisStore $store */ + $store = Cache::store('redis'); + + $store->lock('foo')->forceRelease(); + $lock = $store->lock('foo', 10); + $this->assertTrue($lock->get()); + $this->assertTrue($lock->refresh(30)); + $this->assertGreaterThan(10, $store->lockConnection()->ttl($store->getPrefix() . 'foo')); + $lock->release(); + $this->assertNull($store->lockConnection()->get($store->getPrefix() . 'foo')); + } } From 383c0efec7321161152c619c9a94ef4b139d8697 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 13/30] Fix collection mode, lazy combine and shift edge cases Port laravel/framework#61686. Collection::mode() rejects nulls before checking for an empty collection, so it no longer returns a value that is not in the data. Port #62063. mode() counts floats under string keys and maps them back, so it no longer truncates them. Upstream maps back only string keys, but whole-number floats such as 2.0 get integer keys and came back as 2, so Hypervel looks up every key. Port #61696. LazyCollection::combine() throws ValueError for mismatched lengths, matching the eager path. Port #61723. shift(0) and shift() with a count above one return an empty collection on an empty collection; shift() still returns null. Port the tests for these and for #61699, whose sortBy() fix Hypervel already had (fb011c155). Add a whole-number float mode test, and keep testSortByManyWithNumericFractions for its distinct cases. Validation: the support suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/collections/src/Collection.php | 32 +++++++--- src/collections/src/LazyCollection.php | 7 +-- tests/Support/SupportCollectionTest.php | 81 +++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 081bbdcac9..efef5d3758 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -136,23 +136,35 @@ public function median(string|array|null $key = null): float|int|null */ public function mode(string|array|null $key = null): ?array { - if ($this->isEmpty()) { + $collection = (isset($key) ? $this->pluck($key) : $this) + ->reject(fn ($item) => is_null($item)); + + if ($collection->isEmpty()) { return null; } - $collection = isset($key) ? $this->pluck($key) : $this; - $counts = $this->newInstance(); - // @phpstan-ignore offsetAssign.valueType (PHPStan infers empty collection as Collection<*NEVER*, *NEVER*>) - $collection->each(fn ($value) => $counts[$value] = isset($counts[$value]) ? $counts[$value] + 1 : 1); + $floats = []; + + $collection->each(function ($value) use ($counts, &$floats) { + if (is_float($value)) { + $floats[$key = (string) $value] = $value; + } else { + $key = $value; + } + + // @phpstan-ignore offsetAssign.valueType (PHPStan infers empty collection as Collection<*NEVER*, *NEVER*>) + $counts[$key] = isset($counts[$key]) ? $counts[$key] + 1 : 1; + }); $sorted = $counts->sort(); $highestValue = $sorted->last(); + // Whole-number float keys such as "2" become integer keys, so look up every key. return $sorted->filter(fn ($value) => $value == $highestValue) - ->sort()->keys()->all(); + ->sort()->keys()->map(fn ($key) => $floats[$key] ?? $key)->all(); } /** @@ -1227,10 +1239,6 @@ public function shift(int $count = 1): mixed throw new InvalidArgumentException('Number of shifted items may not be less than zero.'); } - if ($this->isEmpty()) { - return null; - } - if ($count === 0) { return $this->newInstance(); } @@ -1239,6 +1247,10 @@ public function shift(int $count = 1): mixed return array_shift($this->items); } + if ($this->isEmpty()) { + return $this->newInstance(); + } + $results = []; $collectionCount = $this->count(); diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 9753a15c23..24d5615b94 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -26,6 +26,7 @@ use Stringable as BaseStringable; use Traversable; use UnitEnum; +use ValueError; /** * @template TKey of array-key @@ -917,9 +918,7 @@ public function combine(Arrayable|iterable|callable $values): static foreach ($this as $key) { if (! $values->valid()) { - trigger_error($errorMessage, E_USER_WARNING); - - break; + throw new ValueError($errorMessage); } yield $key => $values->current(); @@ -928,7 +927,7 @@ public function combine(Arrayable|iterable|callable $values): static } if ($values->valid()) { - trigger_error($errorMessage, E_USER_WARNING); + throw new ValueError($errorMessage); } }); } diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 5360871547..31243c38ac 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -42,6 +42,7 @@ use Throwable; use TypeError; use UnexpectedValueException; +use ValueError; use WeakMap; class SupportCollectionTest extends TestCase @@ -481,6 +482,12 @@ public function testShiftReturnsNullOnEmptyCollection(): void $this->assertNull($items->shift()); } + public function testShiftManyReturnsEmptyCollectionOnEmptyCollection(): void + { + $this->assertEquals(new Collection, (new Collection)->shift(0)); + $this->assertEquals(new Collection, (new Collection)->shift(2)); + } + #[DataProvider('collectionClassProvider')] public function testSliding($collection): void { @@ -2268,6 +2275,19 @@ public function testSortByAlwaysReturnsAssoc($collection): void $this->assertEquals([1 => ['sort' => 1], 0 => ['sort' => 2]], $data->all()); } + #[DataProvider('collectionClassProvider')] + public function testSortByManyWithNumericFlagComparesFractionalValues(string $collection): void + { + $data = new $collection([['price' => 1.5], ['price' => '10.5'], ['price' => 1.2], ['price' => '10.2'], ['price' => 1.9]]); + + $this->assertSame([1.2, 1.5, 1.9, '10.2', '10.5'], $data->sortBy([['price', 'asc']], SORT_NUMERIC)->pluck('price')->values()->all()); + $this->assertSame(['10.5', '10.2', 1.9, 1.5, 1.2], $data->sortBy([['price', 'desc']], SORT_NUMERIC)->pluck('price')->values()->all()); + $this->assertSame( + $data->sortBy('price', SORT_NUMERIC)->pluck('price')->values()->all(), + $data->sortBy([['price', 'asc']], SORT_NUMERIC)->pluck('price')->values()->all(), + ); + } + #[DataProvider('collectionClassProvider')] public function testSortByMany($collection): void { @@ -5215,6 +5235,22 @@ public function testCombineWithCollection($collection): void $this->assertSame($expected, $actual); } + #[DataProvider('collectionClassProvider')] + public function testCombineWithFewerValuesThanKeysThrows(string $collection): void + { + $this->expectException(ValueError::class); + + (new $collection([1, 2]))->combine([3])->all(); + } + + #[DataProvider('collectionClassProvider')] + public function testCombineWithMoreValuesThanKeysThrows(string $collection): void + { + $this->expectException(ValueError::class); + + (new $collection([1]))->combine([2, 3])->all(); + } + #[DataProvider('collectionClassProvider')] public function testConcatWithArray($collection): void { @@ -5517,6 +5553,51 @@ public function testWithMultipleModeValues($collection): void $this->assertEquals([1, 2], $data->mode()); } + #[DataProvider('collectionClassProvider')] + public function testModeOnCollectionWithNull(string $collection): void + { + $data = new $collection([ + (object) ['foo' => 5], + (object) ['foo' => null], + (object) ['foo' => null], + ]); + $this->assertEquals([5], $data->mode('foo')); + + $data = new $collection([null, 3]); + $this->assertEquals([3], $data->mode()); + } + + #[DataProvider('collectionClassProvider')] + public function testModeOnCollectionWithOnlyNullsReturnsNull(string $collection): void + { + $data = new $collection([null, null]); + $this->assertNull($data->mode()); + } + + #[DataProvider('collectionClassProvider')] + public function testModeOnCollectionWithFloats(string $collection): void + { + $data = new $collection([1.5, 1.5, 2.5]); + $this->assertSame([1.5], $data->mode()); + + $data = new $collection([ + (object) ['foo' => 0.5], + (object) ['foo' => 0.7], + (object) ['foo' => 0.7], + ]); + $this->assertSame([0.7], $data->mode('foo')); + } + + #[DataProvider('collectionClassProvider')] + public function testModeOnCollectionWithWholeNumberFloats(string $collection): void + { + $data = new $collection([2.0, 2.0, 3.5]); + $this->assertSame([2.0], $data->mode()); + + $data = new $collection([1, 1, 2]); + $this->assertSame([1], $data->mode()); + } + #[DataProvider('collectionClassProvider')] public function testSliceOffset($collection): void { From 78c55ea0803c4f09a21410864959997fa65b2565 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 14/30] Respect morph owner keys and fix array conditions in Eloquent whereNot Port laravel/framework#61712. whereMorphedTo() and whereNotMorphedTo() bind each model's owner key attribute, falling back to its primary key, so morphTo relations with a custom owner key match the right rows. Port #61721. Eloquent's whereNot() passes array conditions to the query builder's nested whereNot(), which removes the double negation the array form produced. Port both PRs' tests into DatabaseEloquentBuilderTest, with the morphWithOwnerKey() stub relation. The whereNot() test failed before the fix. Validation: the database suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/database/src/Eloquent/Builder.php | 6 ++ .../Concerns/QueriesRelationships.php | 20 ++--- .../Database/DatabaseEloquentBuilderTest.php | 76 +++++++++++++++++++ 3 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index e20a2669ed..9cb04ce1e7 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -395,6 +395,12 @@ public function orWhere(array|Closure|self|QueryBuilder|Relation|Expression|stri */ public function whereNot(array|Closure|self|QueryBuilder|Relation|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static { + if (is_array($column)) { + $this->query->whereNot($column, $operator, $value, $boolean); + + return $this; + } + return $this->where($column, $operator, $value, $boolean . ' not'); } diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 0d47fe167c..8c2f81a674 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -562,18 +562,18 @@ public function orWhereMorphDoesntHaveRelation(MorphTo|string $relation, string| public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static { if (is_string($relation)) { + /** @var MorphTo<*, *> $relation */ $relation = $this->getRelationWithoutConstraints($relation); } if (is_null($model)) { - // @phpstan-ignore method.notFound, return.type (getMorphType exists on MorphTo; mixin returns $this at runtime) + // @phpstan-ignore return.type (mixin returns $this at runtime) return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean); } if (is_string($model)) { $model = $this->getMorphTypeForQuery($model); - // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) return $this->where($relation->qualifyColumn($relation->getMorphType()), $model, null, $boolean); } @@ -586,10 +586,10 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b return $this->where(function ($query) use ($relation, $models) { $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { $query->orWhere(function ($query) use ($relation, $models) { - // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) $query->where($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) - // @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation) - ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map( + fn ($model) => $model->getAttribute($relation->getOwnerKeyName() ?? $model->getKeyName()) + )); }); }); }, null, null, $boolean); @@ -607,11 +607,12 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $boolean = 'and'): static { if (is_string($relation)) { + /** @var MorphTo<*, *> $relation */ $relation = $this->getRelationWithoutConstraints($relation); } if (is_null($model)) { - // @phpstan-ignore method.notFound, return.type (getMorphType exists on MorphTo; mixin returns $this at runtime) + // @phpstan-ignore return.type (mixin returns $this at runtime) return $this->whereNotNull($relation->qualifyColumn($relation->getMorphType()), $boolean); } @@ -619,7 +620,6 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $model = $this->getMorphTypeForQuery($model); return $this->whereNot(fn ($query) => $query->whereNullSafeEquals( - // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) $relation->qualifyColumn($relation->getMorphType()), $model ), null, null, $boolean); @@ -634,10 +634,10 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string return $this->whereNot(function ($query) use ($relation, $models) { $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { $query->orWhere(function ($query) use ($relation, $models) { - // @phpstan-ignore method.notFound (getMorphType exists on MorphTo, not base Relation) $query->whereNullSafeEquals($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) - // @phpstan-ignore method.notFound (getForeignKeyName exists on MorphTo, not base Relation) - ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map->getKey()); + ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map( + fn ($model) => $model->getAttribute($relation->getOwnerKeyName() ?? $model->getKeyName()) + )); }); }); }, null, null, $boolean); diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 73b1c2939d..c4ed5f8731 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -17,6 +17,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; use Hypervel\Database\Eloquent\RelationNotFoundException; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\PdoConnection; @@ -1528,6 +1529,24 @@ public function testSimpleOrWhereNot(): void $this->assertEquals(['foo', 'bar'], $query->getBindings()); } + public function testWhereNotWithArrayConditions(): void + { + $model = new Stub; + $this->mockConnectionForModel($model, 'SQLite'); + + $query = $model->newQuery()->whereNot(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "table" where not (("foo" = ? and "bar" = ?))', $query->toSql()); + $this->assertEquals([1, 2], $query->getBindings()); + + $query = $model->newQuery()->whereNot([['foo', 1], ['bar', '<', 2]]); + $this->assertSame('select * from "table" where not (("foo" = ? and "bar" < ?))', $query->toSql()); + $this->assertEquals([1, 2], $query->getBindings()); + + $query = $model->newQuery()->where('baz', 3)->orWhereNot(['foo' => 1, 'bar' => 2]); + $this->assertSame('select * from "table" where "baz" = ? or not (("foo" = ? or "bar" = ?))', $query->toSql()); + $this->assertEquals([3, 1, 2], $query->getBindings()); + } + public function testOrWhereNot(): void { $nestedQuery = m::mock(Builder::class); @@ -2450,6 +2469,40 @@ public function testWhereMorphedTo(): void $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); } + public function testWhereMorphedToWithCustomOwnerKey(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub; + $relatedModel->id = 1; + $relatedModel->uuid = 'related-uuid'; + + $builder = $model->whereMorphedTo('morphWithOwnerKey', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), 'related-uuid'], $builder->getBindings()); + } + + public function testWhereMorphedToCollectionWithCustomOwnerKey(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, ''); + + $firstRelatedModel = new ModelCloseRelatedStub; + $firstRelatedModel->id = 1; + $firstRelatedModel->uuid = 'first-uuid'; + + $secondRelatedModel = new ModelCloseRelatedStub; + $secondRelatedModel->id = 2; + $secondRelatedModel->uuid = 'second-uuid'; + + $builder = $model->whereMorphedTo('morphWithOwnerKey', new Collection([$firstRelatedModel, $secondRelatedModel])); + + $this->assertSame('select * from "model_parent_stubs" where (("model_parent_stubs"."morph_type" = ? and "model_parent_stubs"."morph_id" in (?, ?)))', $builder->toSql()); + $this->assertEquals([$firstRelatedModel->getMorphClass(), 'first-uuid', 'second-uuid'], $builder->getBindings()); + } + public function testWhereMorphedToCollection(): void { $model = new ModelParentStub; @@ -2521,6 +2574,21 @@ public function testWhereNotMorphedTo(): void $this->assertEquals([$relatedModel->getMorphClass(), $relatedModel->getKey()], $builder->getBindings()); } + public function testWhereNotMorphedToWithCustomOwnerKey(): void + { + $model = new ModelParentStub; + $this->mockConnectionForModel($model, ''); + + $relatedModel = new ModelCloseRelatedStub; + $relatedModel->id = 1; + $relatedModel->uuid = 'related-uuid'; + + $builder = $model->whereNotMorphedTo('morphWithOwnerKey', $relatedModel); + + $this->assertSame('select * from "model_parent_stubs" where not (("model_parent_stubs"."morph_type" is not distinct from ? and "model_parent_stubs"."morph_id" in (?)))', $builder->toSql()); + $this->assertEquals([$relatedModel->getMorphClass(), 'related-uuid'], $builder->getBindings()); + } + public function testWhereNotMorphedToCollection(): void { $model = new ModelParentStub; @@ -4165,6 +4233,14 @@ public function morph() { return $this->morphTo(); } + + /** + * Get the morph model related through a custom owner key. + */ + public function morphWithOwnerKey(): MorphTo + { + return $this->morphTo('morph', null, null, 'uuid'); + } } class ModelCloseRelatedStub extends Model From df795f6e591ef52a1c3d5a01ddd1a56c20dc519f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 15/30] Enforce the morph map when resolving morph types Port laravel/framework#61711. getActualClassNameForMorph() throws ClassMorphViolationException for an unmapped type while the morph map is required, so reading a polymorphic relation enforces the map as writing it already did. This covers morphInstanceTo(), MorphTo::createModelByType() and Sanctum's cached tokenable lookup. Port the four strict-morph cases into DatabaseEloquentStrictMorphsTest and the MorphTo case into DatabaseEloquentMorphToTest, in the PR's mock form. Relation::flushState() in the test subscriber covers upstream's tearDown resets. eloquent-relationships.md now states that the requirement applies when resolving types too. Validation: the database suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- .../Eloquent/Concerns/HasRelationships.php | 14 ++++++- src/docs/eloquent-relationships.md | 2 + .../Database/DatabaseEloquentMorphToTest.php | 17 ++++++++ .../DatabaseEloquentStrictMorphsTest.php | 41 +++++++++++++++++++ 4 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index edaf2e25b3..7858df913e 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -441,10 +441,22 @@ protected function newMorphTo(Builder $query, Model $parent, string $foreignKey, /** * Retrieve the actual class name for a given morph class. + * + * @throws ClassMorphViolationException */ public static function getActualClassNameForMorph(string|int $class): string { - return Arr::get(Relation::morphMap() ?: [], $class, (string) $class); + $morphMap = Relation::morphMap() ?: []; + + if (array_key_exists($class, $morphMap)) { + return $morphMap[$class]; + } + + if (Relation::requiresMorphMap()) { + throw new ClassMorphViolationException((string) $class); + } + + return (string) $class; } /** diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index 200f568925..a2f9d5b8a8 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -1536,6 +1536,8 @@ Relation::enforceMorphMap([ You may call the `enforceMorphMap` method in the `boot` method of your `App\Providers\AppServiceProvider` class or create a separate service provider if you wish. +Once the morph map is enforced, Hypervel throws a `Hypervel\Database\ClassMorphViolationException` when a model without an alias is used as a polymorphic type, or when a relationship is loaded from a stored type that is not in the map. + You may determine the morph alias of a given model at runtime using the model's `getMorphClass` method. Conversely, you may determine the fully-qualified class name associated with a morph alias using the `Relation::getMorphedModel` method: ```php diff --git a/tests/Database/DatabaseEloquentMorphToTest.php b/tests/Database/DatabaseEloquentMorphToTest.php index 25eefaaabc..3bb860aa31 100644 --- a/tests/Database/DatabaseEloquentMorphToTest.php +++ b/tests/Database/DatabaseEloquentMorphToTest.php @@ -406,6 +406,23 @@ public function __toString(): string $this->assertSame($result, $parent->getRelation('relation')); } + public function testCreateModelByTypeThrowsWhenTypeNotInMorphMapAndRequireMorphMapIsOn(): void + { + $this->expectException(ClassMorphViolationException::class); + + Relation::requireMorphMap(); + + $parent = new ModelStub; + $builder = m::mock(Builder::class); + $builder->shouldReceive('getModel')->andReturn(new RelatedStub); + + $relation = MorphTo::noConstraints( + fn () => new MorphTo($builder, $parent, 'foreign_key', 'id', 'morph_type', 'relation') + ); + + $relation->createModelByType('poisoned'); + } + /** * Create a morph relation with the existing parent key constrained. */ diff --git a/tests/Database/DatabaseEloquentStrictMorphsTest.php b/tests/Database/DatabaseEloquentStrictMorphsTest.php index 9a64df8294..da8ebb9f62 100644 --- a/tests/Database/DatabaseEloquentStrictMorphsTest.php +++ b/tests/Database/DatabaseEloquentStrictMorphsTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\ClassMorphViolationException; use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\MorphTo; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Tests\TestCase; @@ -69,10 +70,50 @@ public function testMapCanBeEnforcedToCustomPivotClass(): void $pivotModel->getMorphClass(); } + + public function testGetActualClassNameForMorphReturnsMappedClassWhenRequireMorphMapIsOn(): void + { + Relation::morphMap([ + 'test' => ModelStub::class, + ]); + + $this->assertSame(ModelStub::class, Model::getActualClassNameForMorph('test')); + } + + public function testGetActualClassNameForMorphThrowsWhenTypeNotInMorphMapAndRequireMorphMapIsOn(): void + { + $this->expectException(ClassMorphViolationException::class); + + Model::getActualClassNameForMorph('not-in-morph-map'); + } + + public function testGetActualClassNameForMorphFallsBackToRawStringWhenRequireMorphMapIsOff(): void + { + Relation::requireMorphMap(false); + + $this->assertSame(ModelStub::class, Model::getActualClassNameForMorph(ModelStub::class)); + } + + public function testMorphToLazyLoadingThrowsWhenTypeNotInMorphMapAndRequireMorphMapIsOn(): void + { + $this->expectException(ClassMorphViolationException::class); + + $model = new ModelStub; + $model->relation_type = 'poisoned'; + + $model->relation(); + } } class ModelStub extends Model { + /** + * Get the related morph model. + */ + public function relation(): MorphTo + { + return $this->morphTo(); + } } class PivotStub extends Pivot From cb7a9d9cbaf1b622daccded9bf12d28ba0286adc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 16/30] Report disabled restarts from queue:restart Port laravel/framework#61706. queue:restart fails with an error when Worker::$restartable is false, matching queue:pause, instead of signalling workers that ignore it. Port both tests into a new Integration/Queue/QueueRestartCommandTest, using Worker::RESTART_SIGNAL_CACHE_KEY. Validation: the queue integration suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/queue/src/Console/RestartCommand.php | 10 +++++- .../Queue/QueueRestartCommandTest.php | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Queue/QueueRestartCommandTest.php diff --git a/src/queue/src/Console/RestartCommand.php b/src/queue/src/Console/RestartCommand.php index 3cfd651263..99efa1cf7e 100644 --- a/src/queue/src/Console/RestartCommand.php +++ b/src/queue/src/Console/RestartCommand.php @@ -37,10 +37,18 @@ public function __construct( /** * Execute the console command. */ - public function handle(): void + public function handle(): int { + if (! Worker::$restartable) { + $this->components->error('Queue restarting is currently disabled.'); + + return self::FAILURE; + } + $this->cache->forever(Worker::RESTART_SIGNAL_CACHE_KEY, $this->currentTime()); $this->components->info('Broadcasting queue restart signal.'); + + return self::SUCCESS; } } diff --git a/tests/Integration/Queue/QueueRestartCommandTest.php b/tests/Integration/Queue/QueueRestartCommandTest.php new file mode 100644 index 0000000000..3551134fd3 --- /dev/null +++ b/tests/Integration/Queue/QueueRestartCommandTest.php @@ -0,0 +1,32 @@ +artisan('queue:restart') + ->expectsOutputToContain('Broadcasting queue restart signal.') + ->assertSuccessful(); + + $this->assertNotNull(Cache::get(Worker::RESTART_SIGNAL_CACHE_KEY)); + } + + public function testFailsWhenRestartingIsDisabled(): void + { + Worker::$restartable = false; + + $this->artisan('queue:restart') + ->expectsOutputToContain('Queue restarting is currently disabled.') + ->assertFailed(); + + $this->assertNull(Cache::get(Worker::RESTART_SIGNAL_CACHE_KEY)); + } +} From c2e10ea034f6798976771b32135564b27bcf633f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 17/30] Restore omitted about command JSON assertions laravel/framework#61697 updates the about command JSON test. The original port omitted most of its assertions, so restore the application name, URL, maintenance mode, cache and driver assertions with APP_URL set, using Hypervel Testbench's values. Upstream's root composer.json constraints do not apply. Validation: the foundation integration suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- .../Foundation/Console/AboutCommandTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Integration/Foundation/Console/AboutCommandTest.php b/tests/Integration/Foundation/Console/AboutCommandTest.php index edc3d86ede..a0eb0d8d9c 100644 --- a/tests/Integration/Foundation/Console/AboutCommandTest.php +++ b/tests/Integration/Foundation/Console/AboutCommandTest.php @@ -44,17 +44,37 @@ protected function tearDown(): void } } + #[WithEnv('APP_URL', 'http://localhost')] public function testItCanDisplayAboutCommandAsJson(): void { $process = remote('about --json', ['APP_ENV' => 'local', 'APP_DEBUG' => 'true'])->mustRun(); tap(json_decode($process->getOutput(), true), function (array $output): void { Assert::assertArraySubset([ + 'application_name' => 'Testbench', 'php_version' => PHP_VERSION, 'swoole_version' => swoole_version(), 'environment' => 'local', 'debug_mode' => true, + 'url' => 'localhost', + 'maintenance_mode' => false, ], $output['environment']); + + Assert::assertArraySubset([ + 'config' => false, + 'events' => false, + 'routes' => false, + ], $output['cache']); + + Assert::assertArraySubset([ + 'broadcasting' => 'log', + 'cache' => 'database', + 'database' => 'testing', + 'logs' => ['single'], + 'mail' => 'log', + 'queue' => 'sync', + 'session' => 'cookie', + ], $output['drivers']); }); } From f21e15c95db10d37949c9f0940a199c5ed74f1f8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 18/30] Choose plural forms by absolute value Port laravel/framework#61700. MessageSelector::getPluralIndex() uses the count's absolute value, so trans_choice() picks the same plural form for negative numbers as for positive ones. The native float|int type makes upstream's is_numeric() guard unnecessary. Port the English, Russian and explicit {-1} cases. The string and null count cases are excluded with a REMOVED comment, since choose() accepts only numbers. Validation: the translation suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/translation/src/MessageSelector.php | 2 ++ .../TranslationMessageSelectorTest.php | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/translation/src/MessageSelector.php b/src/translation/src/MessageSelector.php index 64aaffaf55..e65ec43f27 100644 --- a/src/translation/src/MessageSelector.php +++ b/src/translation/src/MessageSelector.php @@ -103,6 +103,8 @@ private function stripConditions(array $segments): array */ public function getPluralIndex(string $locale, float|int $number): int { + $number = abs($number); + switch ($locale) { case 'az': case 'az_AZ': diff --git a/tests/Translation/TranslationMessageSelectorTest.php b/tests/Translation/TranslationMessageSelectorTest.php index 4466fccd73..9ca2458e3e 100644 --- a/tests/Translation/TranslationMessageSelectorTest.php +++ b/tests/Translation/TranslationMessageSelectorTest.php @@ -130,4 +130,20 @@ public function testChoosePluralizesFloats(): void $this->assertSame('plural', $selector->choose('singular|plural', 0.5, 'en')); $this->assertSame('plural', $selector->choose('singular|plural', 1.5, 'en')); } + + public function testChooseUsesAbsoluteValueForPluralRules(): void + { + $selector = new MessageSelector; + + $this->assertSame('item', $selector->choose('item|items', -1, 'en')); + $this->assertSame('items', $selector->choose('item|items', -2, 'en')); + + $this->assertSame('файл', $selector->choose('файл|файла|файлов', -1, 'ru')); + $this->assertSame('файла', $selector->choose('файл|файла|файлов', -2, 'ru')); + $this->assertSame('файлов', $selector->choose('файл|файла|файлов', -5, 'ru')); + $this->assertSame('файл', $selector->choose('файл|файла|файлов', -21, 'ru')); + + $this->assertSame('minus one', $selector->choose('{-1} minus one|[*,*] other', -1, 'en')); + // REMOVED: string and null counts, which choose()'s native float|int type rejects. + } } From bc513476677d586b988f138a91ad477670a8eed8 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 19/30] Rethrow database lock concurrency errors inside transactions Port laravel/framework#61708. DatabaseLock::release() and pruneExpiredLocks() rethrow concurrency errors when a transaction is open, since the error may have aborted it. acquire() already lets every insert failure except a unique violation propagate (63be5832a), which covers upstream's change there. Port the tests into Integration/Database/DatabaseLockTest. The level-0 takeover case of the acquire test is excluded with a REMOVED comment pointing to the existing Hypervel test for that behavior. Validation: the database integration and cache suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/cache/src/DatabaseLock.php | 14 ++- .../Integration/Database/DatabaseLockTest.php | 88 +++++++++++++++++++ 2 files changed, 98 insertions(+), 4 deletions(-) diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 50fe3425e9..39d00acfb8 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -134,13 +134,16 @@ public function acquire(): bool */ public function release(): bool { + $connection = $this->connection(); + try { - return $this->connection()->table($this->table) + return $connection->table($this->table) ->where('key', $this->name) ->where('owner', $this->owner) ->delete() > 0; } catch (Throwable $e) { - if ($this->causedByConcurrencyError($e)) { + // Inside a transaction the error may have aborted the transaction, so the caller must see it. + if ($this->causedByConcurrencyError($e) && $connection->transactionLevel() === 0) { return true; } @@ -165,12 +168,15 @@ public function forceRelease(): void */ public function pruneExpiredLocks(): void { + $connection = $this->connection(); + try { - $this->connection()->table($this->table) + $connection->table($this->table) ->where('expiration', '<=', $this->currentTime()) ->delete(); } catch (Throwable $e) { - if (! $this->causedByConcurrencyError($e)) { + // Inside a transaction the error may have aborted the transaction, so the caller must see it. + if (! $this->causedByConcurrencyError($e) || $connection->transactionLevel() > 0) { throw $e; } } diff --git a/tests/Integration/Database/DatabaseLockTest.php b/tests/Integration/Database/DatabaseLockTest.php index ec80900c61..8978eea124 100644 --- a/tests/Integration/Database/DatabaseLockTest.php +++ b/tests/Integration/Database/DatabaseLockTest.php @@ -247,6 +247,7 @@ public function testIgnoresConcurrencyException(string $message, int $code, bool ); $connection->shouldReceive('getDriverName')->andReturn('mysql'); + $connection->allows('transactionLevel')->andReturn(0); $connection->expects('table')->times(2)->with('cache_locks')->andReturn($insertBuilder, $deleteBuilder); $resolver->shouldReceive('connection')->with(null)->andReturn($connection); @@ -260,6 +261,35 @@ public function testIgnoresConcurrencyException(string $message, int $code, bool } } + public function testAcquireThrowsConcurrencyExceptionInsideTransaction(): void + { + // REMOVED: upstream's case where a deadlock outside a transaction falls back to a takeover. Insert failures other + // than an existing lock always propagate; see CacheDatabaseLockTest::testInsertFailureOtherThanAnExistingLockPropagatesWithoutTakingOver(). + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + $insertBuilder = m::mock(Builder::class); + + $insertBuilder->expects('insert')->andThrow( + new QueryException( + 'mysql', + 'insert into cache_locks (key, owner, expiration) values (?, ?, ?)', + [], + new PDOException('Deadlock found when trying to get lock', 1213) + ) + ); + + $connection->shouldReceive('getDriverName')->andReturn('mysql'); + $connection->allows('transactionLevel')->andReturn(1); + $connection->expects('table')->with('cache_locks')->andReturn($insertBuilder); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + + $lock = new DatabaseLock($resolver, null, 'foo', 'cache_locks', 10, lottery: null); + + $this->expectException(QueryException::class); + $this->expectExceptionMessageIsOrContains('Deadlock found when trying to get lock'); + $lock->acquire(); + } + #[TestWith(['Serialization failure: 1213 Deadlock', 40001, true])] #[TestWith(['Table does not exist', 1146, false])] public function testReleaseIgnoresConcurrencyException(string $message, int $code, bool $hasConcurrencyError): void @@ -281,6 +311,7 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod ) ); + $connection->allows('transactionLevel')->andReturn(0); $connection->expects('table')->with('cache_locks')->andReturn($deleteBuilder); $resolver->shouldReceive('connection')->with(null)->andReturn($connection); @@ -294,4 +325,61 @@ public function testReleaseIgnoresConcurrencyException(string $message, int $cod $lock->release(); } } + + public function testPruneThrowsConcurrencyExceptionInsideTransaction(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + $deleteBuilder = m::mock(Builder::class); + + $deleteBuilder->expects('where')->with('expiration', '<=', m::any())->andReturnSelf(); + $deleteBuilder->expects('delete')->andThrow( + new QueryException( + 'mysql', + 'delete from cache_locks where expiration <= ?', + [], + new PDOException('Deadlock found when trying to get lock', 1213) + ) + ); + + $connection->allows('transactionLevel')->andReturn(1); + $connection->expects('table')->with('cache_locks')->andReturn($deleteBuilder); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + + $lock = new DatabaseLock($resolver, null, 'foo', 'cache_locks', 10); + + $this->expectException(QueryException::class); + $this->expectExceptionMessageIsOrContains('Deadlock found when trying to get lock'); + $lock->pruneExpiredLocks(); + } + + public function testReleaseThrowsConcurrencyExceptionInsideTransaction(): void + { + $resolver = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(Connection::class); + $deleteBuilder = m::mock(Builder::class); + + $owner = 'owner-123'; + + $deleteBuilder->expects('where')->with('key', 'foo')->andReturnSelf(); + $deleteBuilder->expects('where')->with('owner', $owner)->andReturnSelf(); + $deleteBuilder->expects('delete')->andThrow( + new QueryException( + 'mysql', + 'delete from cache_locks where key = ? and owner = ?', + ['foo', $owner], + new PDOException('Serialization failure: 1213 Deadlock', 40001) + ) + ); + + $connection->allows('transactionLevel')->andReturn(1); + $connection->expects('table')->with('cache_locks')->andReturn($deleteBuilder); + $resolver->shouldReceive('connection')->with(null)->andReturn($connection); + + $lock = new DatabaseLock($resolver, null, 'foo', 'cache_locks', 10, $owner); + + $this->expectException(QueryException::class); + $this->expectExceptionMessageIsOrContains('Serialization failure: 1213 Deadlock'); + $lock->release(); + } } From e43dfaadb17e3a167eff1c257c8fa83422e7a4af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 20/30] Accept arrays in assertDatabaseCount Port laravel/framework#61727. assertDatabaseCount() accepts an iterable of tables or models and their expected counts. Port the test into FoundationInteractsWithDatabaseTest and add an array example to database-testing.md, which upstream documentation does not cover. Validation: the foundation suite and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/docs/database-testing.md | 9 +++++++++ .../Testing/Concerns/InteractsWithDatabase.php | 12 ++++++++++-- .../FoundationInteractsWithDatabaseTest.php | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/docs/database-testing.md b/src/docs/database-testing.md index baa0457034..472ecadb5d 100644 --- a/src/docs/database-testing.md +++ b/src/docs/database-testing.md @@ -306,6 +306,15 @@ Assert that a table in the database contains the given number of records: $this->assertDatabaseCount('users', 5); ``` +You may also pass an array of tables or models and their expected counts: + +```php +$this->assertDatabaseCount([ + 'users' => 5, + Post::class => 10, +]); +``` + #### assertDatabaseEmpty diff --git a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php index 05acb910a1..f7c880ce14 100644 --- a/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php +++ b/src/foundation/src/Testing/Concerns/InteractsWithDatabase.php @@ -103,10 +103,18 @@ protected function assertDatabaseMissing(iterable|Model|string $table, array $da /** * Assert the count of table entries. * - * @param class-string|Model|string $table + * @param class-string|iterable|string, int>|Model|string $table */ - protected function assertDatabaseCount(Model|string $table, int $count, UnitEnum|string|null $connection = null): static + protected function assertDatabaseCount(iterable|Model|string $table, ?int $count = null, UnitEnum|string|null $connection = null): static { + if (is_iterable($table)) { + foreach ($table as $name => $expected) { + $this->assertDatabaseCount($name, $expected, $connection); + } + + return $this; + } + $this->assertThat( $this->getTable($table), new CountInDatabase($this->getConnection($connection, $table), $count) diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 8626d914ef..3c5a664479 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -188,6 +188,23 @@ public function testAssertDatabaseCountSupportModels(): void $this->assertDatabaseCount(new ProductStub, 1); } + public function testAssertDatabaseCountSupportsArraysOfTablesAndCounts(): void + { + $products = m::mock(Builder::class); + $products->expects('count')->andReturn(2); + + $orders = m::mock(Builder::class); + $orders->expects('count')->andReturn(5); + + $this->connection->shouldReceive('table')->with($this->table)->andReturn($products); + $this->connection->shouldReceive('table')->with('orders')->andReturn($orders); + + $this->assertDatabaseCount([ + ProductStub::class => 2, + OrderStub::class => 5, + ]); + } + public function testAssertDatabaseEmpty(): void { $this->mockCountBuilder(false); From ca65b86eb5368b5ea8c9d5c2b7e16d4b0a173f87 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 21/30] Resend multipart streams on retry and fix HTTP option layer merging Port laravel/framework#61728. Attached stream resources are wrapped in PSR-7 streams so retries can resend them, and seekable parts are rewound before each attempt. Upstream wraps only send()'s own options, so streams from withOptions(), connection presets or overrides, and global options were closed after the first attempt. Wrapping now happens on the merged options, reusing one stream per resource while it is still attached, so a reused request releases earlier uploads. Wrapped parts are fresh arrays, so a referenced contents slot in caller data is not replaced. The upstream wrapMultipartStreams() contract is kept. Request::hasFile() compares stream contents by value again, which the strict comparison had dropped. It reads contents only when the name and filename match, and restores a seekable stream's position. Telescope's client request watcher records stream parts by filename, size and headers; it recognized only resources, and upstream Telescope has the same gap. Fix related defects in the same option-layer path: - mergeOptionLayers() merged lists by index, mixing entries from two layers: header value lists and auth credentials kept stale entries, and multipart parts were dropped and duplicated. Nested and non-mergeable lists are now replaced whole, top-level payload lists accumulate, and merged maps are fresh arrays so a request never writes through caller references into global defaults. - parseHttpOptions() planted a null payload for every body format, which erased a withOptions() payload for calls such as delete(). It is now planted only for the raw body format; an explicit null still clears. - The logical request data seen by hasFile(), data() and Telescope is built from the merged payload, so withOptions() parts and fields are visible. http-client.md documents the list merge rule. Port the three upstream retry tests and add regression tests for each fix in HttpClientTest and HttpConnectionTest; each fails without its fix. Validation: composer lint:fix, composer analyse, and the HTTP, HTTP integration, Telescope and ApiClient suites pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/docs/http-client.md | 2 +- src/http/src/Client/PendingRequest.php | 145 +++++++++-- src/http/src/Client/Request.php | 27 +- .../src/Watchers/ClientRequestWatcher.php | 8 +- tests/Http/HttpClientTest.php | 245 ++++++++++++++++++ tests/Http/HttpConnectionTest.php | 85 ++++++ 6 files changed, 484 insertions(+), 28 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 11eac96d9e..bf6dccfe09 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -1052,7 +1052,7 @@ $response = Http::connection('github') ->get('/user'); ``` -Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged: +Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Options with named keys, such as `headers`, are merged key by key. Nested lists, such as a header's values, and other list options, such as `auth` credentials, replace the lower layer's list as a whole. Top-level payload lists, such as multipart parts or a JSON list, combine the entries from every layer. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged: ```php // Uses the registered GitHub preset... diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index d0a5fdc8d7..2b9fbbe24a 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -18,6 +18,7 @@ use GuzzleHttp\Middleware; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Utils as Psr7Utils; use GuzzleHttp\TransferStats; use GuzzleHttp\UriTemplate\UriTemplate; use GuzzleHttp\Utils; @@ -36,7 +37,6 @@ use Hypervel\Http\Client\Events\ResponseReceived; use Hypervel\Http\Client\Guzzle\ReleaseIdleDatabaseConnections; use Hypervel\Http\Client\RequestException as HttpRequestException; -use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Hypervel\Support\Str; use Hypervel\Support\Stringable; @@ -120,6 +120,13 @@ class PendingRequest implements Transient */ protected array $pendingFiles = []; + /** + * The streams wrapping attached multipart resources, keyed by resource ID. + * + * @var array + */ + protected array $multipartStreams = []; + /** * The request cookies. */ @@ -244,7 +251,7 @@ class PendingRequest implements Transient protected ?array $connectionConfig = null; /** - * The Guzzle request options that are mergeable via array_merge_recursive. + * The Guzzle request options whose entries accumulate across option layers. */ protected array $mergeableOptions = [ 'form_params', @@ -1163,7 +1170,8 @@ protected function parseHttpOptions(array $options): array $this->pendingFiles ); } - } else { + } elseif ($this->bodyFormat === 'body') { + // Other formats have no pending body; planting null would erase a payload from an earlier option layer. $options[$this->bodyFormat] = $this->pendingBody; } @@ -1198,6 +1206,51 @@ protected function parseMultipartBodyFormat(array $data): array ->all(); } + /** + * Wrap the multipart stream resources in PSR-7 streams so they are not closed between retry attempts. + */ + protected function wrapMultipartStreams(array $multipart): array + { + $streams = []; + + $multipart = array_map(function ($part) use (&$streams) { + if (is_array($part) && isset($part['contents']) && is_resource($part['contents'])) { + $id = get_resource_id($part['contents']); + + // Every attempt merges the option layers again, so reuse one stream per resource; + // a discarded stream would close the resource. A fresh part never writes through + // a reference in the caller's data. + $streams[$id] = $this->multipartStreams[$id] ?? Psr7Utils::streamFor($part['contents']); + $part = [...$part, 'contents' => $streams[$id]]; + } + + return $part; + }, $multipart); + + // Keep only the current parts' streams, so a reused request releases earlier uploads. + $this->multipartStreams = $streams; + + return $multipart; + } + + /** + * Rewind the seekable multipart streams so each attempt contains the full part contents. + */ + protected function rewindMultipartStreams(array $options): void + { + if (! is_array($options['multipart'] ?? null)) { + return; + } + + foreach ($options['multipart'] as $part) { + $contents = is_array($part) ? ($part['contents'] ?? null) : null; + + if ($contents instanceof StreamInterface && $contents->isSeekable()) { + $contents->rewind(); + } + } + } + /** * Send an asynchronous request to the given URL. * @@ -1358,16 +1411,31 @@ protected function sendRequest(string $method, string $url, array $options = []) $this->transferStats = $transferStats; }; - $requestOptions = [ + $mergedOptions = $this->mergeOptions([ 'on_stats' => $onStats, self::PRIOR_SENDS_OPTION => $this->sendCount, - ]; + ], $options); - if ($this->bodyFormat !== 'body' && ! array_key_exists('body', $options)) { - $requestOptions[self::DATA_OPTION] = $this->parseRequestData($method, $url, $options); + // Parts can come from any option layer, and every attempt must send them from the start. + // Guzzle's transports rewind the whole body anyway, so this also matches fakes to them. + if (is_array($mergedOptions['multipart'] ?? null)) { + $mergedOptions['multipart'] = $this->wrapMultipartStreams($mergedOptions['multipart']); + + $this->rewindMultipartStreams($mergedOptions); + } else { + $this->multipartStreams = []; } - $mergedOptions = $this->normalizeRequestOptions($this->mergeOptions($requestOptions, $options)); + // The logical data describes the payload from every layer, before it is normalized for Guzzle. + $data = $this->bodyFormat !== 'body' && ! array_key_exists('body', $mergedOptions) + ? $this->parseRequestData($method, $url, $mergedOptions) + : null; + + $mergedOptions = $this->normalizeRequestOptions($mergedOptions); + + if ($data !== null) { + $mergedOptions[self::DATA_OPTION] = $data; + } // Guzzle adds the multipart Content-Type, with the body's boundary, only when none is set. A content type // without a boundary, such as one from asJson() or a connection default, cannot describe the body. @@ -1438,8 +1506,8 @@ protected function normalizeRequestOptions(array $options): array } if ($key === 'form_params') { - // parseHttpOptions() plants null for a configured body format with no payload; - // a value that becomes null during normalization is caller-supplied and invalid. + // An explicit null clears form data from earlier option layers; a value that + // only becomes null during normalization is invalid. if ($value === null) { continue; } @@ -1469,11 +1537,6 @@ protected function normalizeRequestOptions(array $options): array continue; } - // parseRequestData() has already normalized the logical request data into fresh arrays. - if ($key === self::DATA_OPTION) { - continue; - } - $options[$key] = $this->normalizeRequestOptionValue($value); } @@ -2573,12 +2636,52 @@ protected function mergeOptionLayers(array ...$layers): array $merged = []; foreach ($layers as $layer) { - // Apply Laravel's merge rules to each layer: distinct mergeable entries - // accumulate, while later same-key values replace instead of nesting. - $merged = array_replace_recursive( - array_merge_recursive($merged, Arr::only($layer, $this->mergeableOptions)), - $layer, - ); + foreach ($layer as $key => $value) { + $merged[$key] = $this->mergeOptionValue( + $merged[$key] ?? null, + $value, + in_array($key, $this->mergeableOptions, true), + ); + } + } + + return $merged; + } + + /** + * Merge an option value from a later layer over the current value. + * + * Maps merge by key, with later values winning. Lists such as header values, auth + * credentials and redirect protocols are replaced whole, since merging them by index + * would mix entries from both layers. The lists of a mergeable option, such as + * multipart parts, accumulate instead. + */ + protected function mergeOptionValue(mixed $current, mixed $value, bool $mergeable = false): mixed + { + if (! is_array($current) || ! is_array($value)) { + return $value; + } + + // An empty payload default, such as post() without data, keeps the earlier payload. + if ($mergeable && $value === []) { + return $current; + } + + if (array_is_list($current) || array_is_list($value)) { + return $mergeable && array_is_list($current) && array_is_list($value) + ? [...$current, ...$value] + : $value; + } + + $merged = []; + + // A fresh array never writes through references in the caller's data. + foreach ($current as $key => $item) { + $merged[$key] = $item; + } + + foreach ($value as $key => $item) { + $merged[$key] = $this->mergeOptionValue($merged[$key] ?? null, $item); } return $merged; diff --git a/src/http/src/Client/Request.php b/src/http/src/Client/Request.php index 50d8206cb4..b245090fd1 100644 --- a/src/http/src/Client/Request.php +++ b/src/http/src/Client/Request.php @@ -13,6 +13,8 @@ use JsonException; use LogicException; use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\StreamInterface; +use Stringable; class Request implements ArrayAccess { @@ -128,9 +130,28 @@ public function hasFile(string $name, ?string $value = null, ?string $filename = } return (new Collection($this->data))->contains(function ($file) use ($name, $value, $filename) { - return ($file['name'] ?? null) === $name - && ($value === null || ($file['contents'] ?? null) === $value) - && ($filename === null || ($file['filename'] ?? null) === $filename); + if (($file['name'] ?? null) !== $name + || ($filename !== null && ($file['filename'] ?? null) !== $filename)) { + return false; + } + + $contents = $file['contents'] ?? null; + + if ($value === null || ! $contents instanceof Stringable) { + return $value === null || $contents === $value; + } + + // Attached streams compare by their contents. Reading moves the cursor + // the request body reads from, so restore it when the stream allows. + if ($contents instanceof StreamInterface && $contents->isSeekable()) { + $position = $contents->tell(); + $matches = (string) $contents === $value; + $contents->seek($position); + + return $matches; + } + + return (string) $contents === $value; }); } diff --git a/src/telescope/src/Watchers/ClientRequestWatcher.php b/src/telescope/src/Watchers/ClientRequestWatcher.php index c2500396a0..8f3b7854af 100644 --- a/src/telescope/src/Watchers/ClientRequestWatcher.php +++ b/src/telescope/src/Watchers/ClientRequestWatcher.php @@ -17,6 +17,7 @@ use Hypervel\Telescope\Telescope; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\StreamInterface; use Symfony\Component\HttpFoundation\File\UploadedFile; use Throwable; use UnitEnum; @@ -170,12 +171,13 @@ protected function formatMultipartData(array $data): array 'size' => ($part['contents']->getSize() / 1000) . 'KB', 'headers' => $part['headers'] ?? [], ]; - } elseif (is_resource($part['contents'])) { - $filesize = @filesize(stream_get_meta_data($part['contents'])['uri']); + } elseif ($part['contents'] instanceof StreamInterface) { + // The HTTP client wraps attached resources in streams so retries can resend them. + $size = $part['contents']->getSize(); $value = [ 'name' => $part['filename'] ?? null, - 'size' => $filesize ? ($filesize / 1000) . 'KB' : null, + 'size' => $size ? ($size / 1000) . 'KB' : null, 'headers' => $part['headers'] ?? [], ]; } elseif (json_encode($part['contents']) === false) { diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 5e1e8976d4..ad891f3431 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -1413,6 +1413,27 @@ public function testStructuredDataNormalizationLeavesReferencedCallerDataUnchang }); } + public function testRequestDataIncludesThePayloadFromEveryOptionLayer(): void + { + $requests = []; + + $this->factory->fake(function (Request $request) use (&$requests): PromiseInterface { + $requests[] = $request; + + return Factory::response(); + }); + + $this->factory->asMultipart() + ->withOptions(['multipart' => [['name' => 'file', 'filename' => 'file.txt', 'contents' => 'attachment-contents']]]) + ->post('http://foo.com/upload'); + + $this->factory->withOptions(['json' => ['a' => 1]])->post('http://foo.com/json', ['b' => 2]); + + $this->assertTrue($requests[0]->hasFile('file', 'attachment-contents', 'file.txt')); + $this->assertSame(['a' => 1, 'b' => 2], $requests[1]->data()); + $this->assertSame('{"a":1,"b":2}', $requests[1]->body()); + } + public function testCanSendJsonDataWithStringable(): void { $this->factory->fake(); @@ -1973,6 +1994,36 @@ public function testHasFileComparesZeroValuesAndMissingFilenamesExactly(): void $this->assertFalse($request->hasFile('without-filename', '0', '0')); } + public function testHasFileInspectsAttachedStreamsWithoutConsumingTheRequestBody(): void + { + $inspections = []; + $body = null; + + $this->factory->fake(function (Request $request) use (&$inspections, &$body): PromiseInterface { + $inspections = [ + $request->hasFile('seekable'), + $request->hasFile('unseekable'), + $request->hasFile('missing', 'seekable-contents'), + $request->hasFile('unseekable', null, 'other.txt'), + $request->hasFile('seekable', 'other-contents'), + $request->hasFile('seekable', 'seekable-contents', 'seekable.txt'), + ]; + + $body = $request->toPsrRequest()->getBody()->getContents(); + + return Factory::response(); + }); + + $this->factory + ->attach('seekable', Utils::streamFor('seekable-contents'), 'seekable.txt') + ->attach('unseekable', new NoSeekStream(Utils::streamFor('unseekable-contents')), 'unseekable.txt') + ->post('http://foo.com/file'); + + $this->assertSame([true, true, false, false, false, true], $inspections); + $this->assertStringContainsString('seekable-contents', $body); + $this->assertStringContainsString('unseekable-contents', $body); + } + public function testAttachHeaderValuesAreSerialized(): void { $this->factory->fake(); @@ -4830,6 +4881,168 @@ public function testRequestCanBeModifiedInRetryCallbackWithBackoffArray(): void }); } + public function testRetriedMultipartRequestsResendAttachedStreamContents(): void + { + $bodies = []; + + $this->factory->fake(function (Request $request) use (&$bodies) { + $bodies[] = $request->toPsrRequest()->getBody()->getContents(); + + return count($bodies) < 3 + ? ($this->factory::failedConnection())($request) + : $this->factory::response(['ok' => true]); + }); + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'attached-file-contents'); + rewind($stream); + + $response = $this->factory + ->retry(3, 0) + ->attach('file', $stream, 'file.txt') + ->post('http://foo.com/upload'); + + $this->assertTrue($response->successful()); + $this->assertCount(3, $bodies); + + foreach ($bodies as $body) { + $this->assertStringContainsString('attached-file-contents', $body); + } + + $this->factory->assertSent(function (Request $request) { + return $request->hasFile('file', 'attached-file-contents', 'file.txt'); + }); + } + + public function testAsyncRetriedMultipartRequestsResendAttachedStreamContents(): void + { + $bodies = []; + + $this->factory->fake(function (Request $request) use (&$bodies) { + $bodies[] = $request->toPsrRequest()->getBody()->getContents(); + + return count($bodies) < 3 + ? ($this->factory::failedConnection())($request) + : $this->factory::response(['ok' => true]); + }); + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'attached-file-contents'); + rewind($stream); + + $response = $this->factory + ->async() + ->retry(3, 0) + ->attach('file', $stream, 'file.txt') + ->post('http://foo.com/upload') + ->wait(); + + $this->assertTrue($response->successful()); + $this->assertCount(3, $bodies); + + foreach ($bodies as $body) { + $this->assertStringContainsString('attached-file-contents', $body); + } + } + + public function testExhaustedRetriesWithAttachedStreamThrowConnectionException(): void + { + $bodies = []; + + $this->factory->fake(function (Request $request) use (&$bodies) { + $bodies[] = $request->toPsrRequest()->getBody()->getContents(); + + return ($this->factory::failedConnection())($request); + }); + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'attached-file-contents'); + rewind($stream); + + $exception = null; + + try { + $this->factory + ->retry(3, 0) + ->attach('file', $stream, 'file.txt') + ->post('http://foo.com/upload'); + } catch (Exception $e) { + $exception = $e; + } + + $this->assertInstanceOf(ConnectionException::class, $exception); + $this->assertCount(3, $bodies); + + foreach ($bodies as $body) { + $this->assertStringContainsString('attached-file-contents', $body); + } + } + + /** + * @param Closure(Factory, array): PendingRequest $withParts + */ + #[DataProvider('multipartOptionLayers')] + public function testRetriedMultipartRequestsResendStreamContentsFromOptionLayers(Closure $withParts, bool $async): void + { + $bodies = []; + + $this->factory->fake(function (Request $request) use (&$bodies) { + $bodies[] = $request->toPsrRequest()->getBody()->getContents(); + + return count($bodies) < 2 + ? ($this->factory::failedConnection())($request) + : $this->factory::response(['ok' => true]); + }); + + $stream = fopen('php://temp', 'r+'); + fwrite($stream, 'attached-file-contents'); + rewind($stream); + + $pendingRequest = $withParts( + $this->factory, + [['name' => 'file', 'contents' => $stream, 'filename' => 'file.txt']], + ); + + $response = $pendingRequest->async($async)->retry(2, 0)->asMultipart()->post('http://foo.com/upload'); + + if ($async) { + $response = $response->wait(); + } + + $this->assertTrue($response->successful()); + $this->assertCount(2, $bodies); + + foreach ($bodies as $body) { + $this->assertStringContainsString('attached-file-contents', $body); + } + } + + /** + * Get the option layers that can supply multipart parts, sent synchronously and asynchronously. + * + * @return iterable + */ + public static function multipartOptionLayers(): iterable + { + $layers = [ + 'global options' => fn (Factory $factory, array $parts): PendingRequest => $factory + ->globalOptions(fn (): array => ['multipart' => $parts]) + ->createPendingRequest(), + 'registered connection preset' => fn (Factory $factory, array $parts): PendingRequest => $factory + ->registerConnection('uploads', ['multipart' => $parts]) + ->connection('uploads'), + 'per-call connection options' => fn (Factory $factory, array $parts): PendingRequest => $factory + ->registerConnection('uploads') + ->connection('uploads', ['multipart' => $parts]), + 'fluent options' => fn (Factory $factory, array $parts): PendingRequest => $factory->withOptions(['multipart' => $parts]), + ]; + + foreach ($layers as $name => $withParts) { + yield "{$name}, sync" => [$withParts, false]; + yield "{$name}, async" => [$withParts, true]; + } + } + public function testExceptionThrownInRetryCallbackWithoutRetrying(): void { $this->factory->fake([ @@ -5474,6 +5687,38 @@ public function testPriorSendsOptionResetsWhenPendingRequestIsReused(): void $this->assertSame([0, 0], $observedPriorSends); } + public function testReusedPendingRequestReleasesEarlierUploadStreams(): void + { + $pendingRequest = $this->factory->setHandler(fn (): PromiseInterface => Create::promiseFor(new Psr7Response(200))); + + $first = fopen('php://temp', 'r+'); + $pendingRequest->attach('file', $first, 'first.txt')->post('https://example.test/upload'); + + $second = fopen('php://temp', 'r+'); + $pendingRequest->attach('file', $second, 'second.txt')->post('https://example.test/upload'); + + $this->assertFalse(is_resource($first)); + $this->assertTrue(is_resource($second)); + + $pendingRequest->asJson()->post('https://example.test/json', ['ok' => true]); + + $this->assertFalse(is_resource($second)); + } + + public function testWrappingAttachedResourcesLeavesReferencedCallerPartsUnchanged(): void + { + $this->factory->fake(); + + $resource = fopen('php://temp', 'r+'); + fwrite($resource, 'attachment-contents'); + $parts = [['name' => 'file', 'contents' => &$resource, 'filename' => 'file.txt']]; + + $this->factory->asMultipart()->post('http://foo.com/upload', $parts); + + $this->assertTrue(is_resource($resource)); + $this->factory->assertSent(fn (Request $request): bool => $request->hasFile('file', 'attachment-contents', 'file.txt')); + } + public function testGlobalRequestMiddlewareBodyReplacementInvalidatesLogicalRequestData(): void { $stubData = null; diff --git a/tests/Http/HttpConnectionTest.php b/tests/Http/HttpConnectionTest.php index b4b0e56e9a..390226dcc9 100644 --- a/tests/Http/HttpConnectionTest.php +++ b/tests/Http/HttpConnectionTest.php @@ -222,6 +222,91 @@ public function testRegisteredPresetOverridesGlobalsAndEmptyPerCallConfigClearsP $this->assertSame('yes', $factory->invocations[1]['request']->getHeaderLine('X-Global')); } + public function testLaterOptionLayersReplaceListsWhole(): void + { + $factory = new RecordingHttpConnectionFactory; + $factory->globalOptions([ + 'headers' => ['X-Tags' => ['global-a', 'global-b'], 'X-Global' => 'yes'], + 'auth' => ['user', 'secret', 'digest'], + 'allow_redirects' => ['max' => 3, 'protocols' => ['http', 'https']], + 'proxy' => ['http' => 'http://proxy.test', 'no' => ['example.test']], + ]); + + $options = $factory + ->withHeaders(['X-Tags' => ['local']]) + ->withBasicAuth('other', 'pass') + ->withOptions(['allow_redirects' => ['protocols' => ['https']]]) + ->withOptions(['proxy' => ['no' => []]]) + ->getOptions(); + + $this->assertSame(['X-Tags' => ['local'], 'X-Global' => 'yes'], $options['headers']); + $this->assertSame(['other', 'pass'], $options['auth']); + $this->assertSame(['max' => 3, 'protocols' => ['https']], $options['allow_redirects']); + $this->assertSame(['http' => 'http://proxy.test', 'no' => []], $options['proxy']); + + $options = $factory + ->withOptions(['auth' => ['first', 'secret', 'digest']]) + ->withOptions(['auth' => ['second', 'secret']]) + ->getOptions(); + + $this->assertSame(['second', 'secret'], $options['auth']); + } + + public function testMergeableListsAccumulateAcrossOptionLayers(): void + { + $factory = new RecordingHttpConnectionFactory; + $factory->registerConnection('api'); + + $factory->connection('api') + ->withOptions(['multipart' => [['name' => 'meta', 'contents' => 'meta-contents']]]) + ->attach('file', 'file-contents', 'file.txt') + ->post('https://example.com/upload'); + + $factory->connection('api') + ->withOptions(['json' => [1, 2]]) + ->post('https://example.com/list', [3]); + + $body = (string) $factory->invocations[0]['request']->getBody(); + + $this->assertSame(1, substr_count($body, 'name="meta"')); + $this->assertSame(1, substr_count($body, 'name="file"')); + $this->assertStringContainsString('meta-contents', $body); + $this->assertStringContainsString('file-contents', $body); + $this->assertSame('[1,2,3]', (string) $factory->invocations[1]['request']->getBody()); + } + + public function testMergingOptionLayersLeavesReferencedCallerDataUnchanged(): void + { + $name = 'default'; + + $factory = new RecordingHttpConnectionFactory; + $factory->globalOptions(['json' => ['user' => ['name' => &$name]]]); + $factory->registerConnection('api'); + + $factory->connection('api')->post('https://example.com/first', ['user' => ['name' => 'request-one']]); + $factory->connection('api')->post('https://example.com/second', ['user' => ['other' => 'request-two']]); + + $this->assertSame('default', $name); + $this->assertSame('{"user":{"name":"request-one"}}', (string) $factory->invocations[0]['request']->getBody()); + $this->assertSame('{"user":{"name":"default","other":"request-two"}}', (string) $factory->invocations[1]['request']->getBody()); + } + + public function testOmittedPayloadsKeepAndExplicitNullsClearPayloadsFromEarlierLayers(): void + { + $factory = new RecordingHttpConnectionFactory; + $factory->registerConnection('api'); + + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->post('https://example.com'); + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->delete('https://example.com'); + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->send('POST', 'https://example.com'); + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->send('POST', 'https://example.com', ['json' => null]); + + $this->assertSame('{"a":1}', (string) $factory->invocations[0]['request']->getBody()); + $this->assertSame('{"a":1}', (string) $factory->invocations[1]['request']->getBody()); + $this->assertSame('{"a":1}', (string) $factory->invocations[2]['request']->getBody()); + $this->assertSame('', (string) $factory->invocations[3]['request']->getBody()); + } + public function testSetConnectionConfigRegistersEvenAnEmptyPreset(): void { $factory = new RecordingHttpConnectionFactory; From 4f0376415be4b89af3e4b09a8e59d31b40833feb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:38:12 +0000 Subject: [PATCH 22/30] Accept enums in environment checks Port laravel/framework#61730 and its facade update (02fb893eed). Application::environment() and its contract accept enum patterns, and scheduled events' environments() stores enum values. The widened parameter updates the environment() overrides in the migration command test stubs. Regenerate the App and Schedule facades. Port FoundationApplicationTest::testEnvironmentWithEnums and EventTest::testEnvironmentsWithEnums. Validation: composer lint:fix, composer analyse, FacadeDocblocksTest, the foundation, console and database suites and the full suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- .../src/Scheduling/ManagesAttributes.php | 10 +++++-- src/contracts/src/Foundation/Application.php | 3 ++- src/foundation/src/Application.php | 6 +++-- src/support/src/Facades/App.php | 2 +- src/support/src/Facades/Schedule.php | 2 +- tests/Console/Scheduling/EventTest.php | 22 ++++++++++++++++ .../DatabaseMigrationRefreshCommandTest.php | 3 ++- .../DatabaseMigrationResetCommandTest.php | 3 ++- .../DatabaseMigrationRollbackCommandTest.php | 3 ++- .../Foundation/FoundationApplicationTest.php | 26 +++++++++++++++++++ 10 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/console/src/Scheduling/ManagesAttributes.php b/src/console/src/Scheduling/ManagesAttributes.php index 633ec6cc85..a5a6f3f9fc 100644 --- a/src/console/src/Scheduling/ManagesAttributes.php +++ b/src/console/src/Scheduling/ManagesAttributes.php @@ -6,6 +6,9 @@ use DateTimeZone; use Hypervel\Support\Reflector; +use UnitEnum; + +use function Hypervel\Support\enum_value; trait ManagesAttributes { @@ -89,9 +92,12 @@ trait ManagesAttributes /** * Limit the environments the command should run in. */ - public function environments(mixed $environments): static + public function environments(UnitEnum|array|string $environments): static { - $this->environments = is_array($environments) ? $environments : func_get_args(); + $this->environments = array_map( + enum_value(...), + is_array($environments) ? $environments : func_get_args() + ); return $this; } diff --git a/src/contracts/src/Foundation/Application.php b/src/contracts/src/Foundation/Application.php index 002c37e227..83a494f7c4 100644 --- a/src/contracts/src/Foundation/Application.php +++ b/src/contracts/src/Foundation/Application.php @@ -10,6 +10,7 @@ use RuntimeException; use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use UnitEnum; interface Application extends Container { @@ -105,7 +106,7 @@ public function joinPaths(?string $basePath, string $path = ''): string; /** * Get or check the current application environment. */ - public function environment(array|string ...$environments): bool|string; + public function environment(UnitEnum|array|string ...$environments): bool|string; /** * Determine if the application is in the local environment. diff --git a/src/foundation/src/Application.php b/src/foundation/src/Application.php index 692d40c2c5..e577397b0e 100644 --- a/src/foundation/src/Application.php +++ b/src/foundation/src/Application.php @@ -40,8 +40,10 @@ use Symfony\Component\HttpKernel\Exception\HttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Throwable; +use UnitEnum; use function Hypervel\Filesystem\join_paths; +use function Hypervel\Support\enum_value; class Application extends Container implements ApplicationContract, CachesConfiguration, CachesRoutes { @@ -786,12 +788,12 @@ public function joinPaths(?string $basePath, string $path = ''): string /** * Get or check the current application environment. */ - public function environment(array|string ...$environments): bool|string + public function environment(UnitEnum|array|string ...$environments): bool|string { if ($environments !== []) { $patterns = is_array($environments[0]) ? $environments[0] : $environments; - return Str::is($patterns, $this->make('env')); + return Str::is(array_map(enum_value(...), $patterns), $this->make('env')); } return $this->make('env'); diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 3c45c14d58..860d3cbfd9 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -38,7 +38,7 @@ * @method static string databasePath(string $path = '') * @method static string detectEnvironment(\Closure $callback) * @method static \Hypervel\Foundation\Application dontMergeFrameworkConfiguration() - * @method static string|bool environment(array|string ...$environments) + * @method static string|bool environment(\UnitEnum|array|string ...$environments) * @method static string environmentFile() * @method static string environmentFilePath() * @method static string|null environmentPath() diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php index 3bd9b75cbd..c89850b2e6 100644 --- a/src/support/src/Facades/Schedule.php +++ b/src/support/src/Facades/Schedule.php @@ -34,7 +34,7 @@ * @method static \Hypervel\Console\Scheduling\PendingEventAttributes days(mixed $days) * @method static \Hypervel\Console\Scheduling\PendingEventAttributes daysOfMonth(array|int ...$days) * @method static \Hypervel\Console\Scheduling\PendingEventAttributes description(string $description) - * @method static \Hypervel\Console\Scheduling\PendingEventAttributes environments(mixed $environments) + * @method static \Hypervel\Console\Scheduling\PendingEventAttributes environments(\UnitEnum|array|string $environments) * @method static \Hypervel\Console\Scheduling\PendingEventAttributes evenInMaintenanceMode() * @method static \Hypervel\Console\Scheduling\PendingEventAttributes evenWhenPaused() * @method static \Hypervel\Console\Scheduling\PendingEventAttributes everyFifteenMinutes() diff --git a/tests/Console/Scheduling/EventTest.php b/tests/Console/Scheduling/EventTest.php index 9669452d38..08c5162141 100644 --- a/tests/Console/Scheduling/EventTest.php +++ b/tests/Console/Scheduling/EventTest.php @@ -46,6 +46,12 @@ enum EventTestTimezoneUnitEnum case EST; } +enum ScheduledEventTestEnvironment: string +{ + case Local = 'local'; + case Production = 'production'; +} + class EventTest extends TestCase { protected ?Container $container = null; @@ -935,6 +941,22 @@ public function testTimeUnlessBetweenChecks(): void $event = new Event(m::mock(EventMutex::class), 'php foo', 'UTC'); $this->assertTrue($event->unlessBetween('10:00', '8:00')->filtersPass($app)); } + + public function testEnvironmentsWithEnums(): void + { + $event = new Event(m::mock(EventMutex::class), 'php foo'); + + $event->environments(ScheduledEventTestEnvironment::Production); + $this->assertSame(['production'], $event->environments); + $this->assertTrue($event->runsInEnvironment('production')); + $this->assertFalse($event->runsInEnvironment('local')); + + $event->environments([ScheduledEventTestEnvironment::Local, 'staging']); + $this->assertSame(['local', 'staging'], $event->environments); + + $event->environments(ScheduledEventTestEnvironment::Local, ScheduledEventTestEnvironment::Production); + $this->assertSame(['local', 'production'], $event->environments); + } } class EventTestInvokableFilter diff --git a/tests/Database/DatabaseMigrationRefreshCommandTest.php b/tests/Database/DatabaseMigrationRefreshCommandTest.php index 1fe86d6ec0..0b691a0fe6 100755 --- a/tests/Database/DatabaseMigrationRefreshCommandTest.php +++ b/tests/Database/DatabaseMigrationRefreshCommandTest.php @@ -20,6 +20,7 @@ use Symfony\Component\Console\Application as ConsoleApplication; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; +use UnitEnum; class DatabaseMigrationRefreshCommandTest extends TestCase { @@ -222,7 +223,7 @@ public function __construct(array $data = []) /** * Get the application environment. */ - public function environment(array|string ...$environments): bool|string + public function environment(UnitEnum|array|string ...$environments): bool|string { return 'development'; } diff --git a/tests/Database/DatabaseMigrationResetCommandTest.php b/tests/Database/DatabaseMigrationResetCommandTest.php index 7c17a13695..da72c1873b 100755 --- a/tests/Database/DatabaseMigrationResetCommandTest.php +++ b/tests/Database/DatabaseMigrationResetCommandTest.php @@ -13,6 +13,7 @@ use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; +use UnitEnum; class DatabaseMigrationResetCommandTest extends TestCase { @@ -102,7 +103,7 @@ public function __construct(array $data = []) /** * Get the application environment. */ - public function environment(array|string ...$environments): bool|string + public function environment(UnitEnum|array|string ...$environments): bool|string { return 'development'; } diff --git a/tests/Database/DatabaseMigrationRollbackCommandTest.php b/tests/Database/DatabaseMigrationRollbackCommandTest.php index a2697c10a3..a6bcfa184e 100755 --- a/tests/Database/DatabaseMigrationRollbackCommandTest.php +++ b/tests/Database/DatabaseMigrationRollbackCommandTest.php @@ -12,6 +12,7 @@ use Mockery as m; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; +use UnitEnum; class DatabaseMigrationRollbackCommandTest extends TestCase { @@ -132,7 +133,7 @@ public function __construct(array $data = []) /** * Get the application environment. */ - public function environment(array|string ...$environments): bool|string + public function environment(UnitEnum|array|string ...$environments): bool|string { return 'development'; } diff --git a/tests/Foundation/FoundationApplicationTest.php b/tests/Foundation/FoundationApplicationTest.php index 6b77b7f4a0..e870b5969e 100644 --- a/tests/Foundation/FoundationApplicationTest.php +++ b/tests/Foundation/FoundationApplicationTest.php @@ -307,6 +307,21 @@ public function testEnvironment(): void $this->assertFalse($app->environment(['qux', 'bar'])); } + public function testEnvironmentWithEnums(): void + { + $app = new Application; + $app->instance('env', 'staging'); + + $this->assertTrue($app->environment(ApplicationTestEnvironment::Staging)); + $this->assertTrue($app->environment(ApplicationTestEnvironment::Local, ApplicationTestEnvironment::Staging)); + $this->assertTrue($app->environment([ApplicationTestEnvironment::Local, ApplicationTestEnvironment::Staging])); + $this->assertTrue($app->environment(['local', ApplicationTestEnvironment::Staging])); + $this->assertTrue($app->environment(ApplicationTestUnitEnvironment::staging)); + + $this->assertFalse($app->environment(ApplicationTestEnvironment::Local)); + $this->assertFalse($app->environment([ApplicationTestEnvironment::Local, 'production'])); + } + public function testEnvironmentHelpers(): void { $local = new Application; @@ -1161,3 +1176,14 @@ public function terminate(): int return self::$counter++; } } + +enum ApplicationTestEnvironment: string +{ + case Local = 'local'; + case Staging = 'staging'; +} + +enum ApplicationTestUnitEnvironment +{ + case staging; +} From 2f1bc70a7133ea05e2d6e0bfad8b93688538ebc4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:49:50 +0000 Subject: [PATCH 23/30] Count collection mode floats by their exact value laravel/framework#62063 keys floats in mode() by a string cast, which rounds to the precision setting. Unequal floats such as 0.1 + 0.2 and 0.3 were counted together, and results changed with the setting. Key floats with sprintf('%.17h'), which is exact and locale-independent. The key also adds zero, so -0.0, for example from round(-0.4), counts with 0.0 instead of separately. The original floats are still returned. Extend the Hypervel mode test with rounding-collision, negative-zero and reduced-precision cases for both collection classes; it fails without the fix. Validation: composer lint:fix, composer analyse and the support suite pass. Upstream reference: laravel/framework 13.x at 5d7797528723107cd4f495326838a7d7d67fbb20. --- src/collections/src/Collection.php | 4 +++- tests/Support/SupportCollectionTest.php | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index efef5d3758..4be2997bf7 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -149,7 +149,9 @@ public function mode(string|array|null $key = null): ?array $collection->each(function ($value) use ($counts, &$floats) { if (is_float($value)) { - $floats[$key = (string) $value] = $value; + // A string cast rounds to the precision setting, so key floats by their exact value. + // Adding zero turns -0.0, which equals 0.0, into 0.0. + $floats[$key = sprintf('%.17h', $value + 0.0)] = $value; } else { $key = $value; } diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php index 31243c38ac..c95c4ebee0 100644 --- a/tests/Support/SupportCollectionTest.php +++ b/tests/Support/SupportCollectionTest.php @@ -5589,13 +5589,33 @@ public function testModeOnCollectionWithFloats(string $collection): void } #[DataProvider('collectionClassProvider')] - public function testModeOnCollectionWithWholeNumberFloats(string $collection): void + public function testModeCountsFloatsByTheirExactValue(string $collection): void { $data = new $collection([2.0, 2.0, 3.5]); $this->assertSame([2.0], $data->mode()); $data = new $collection([1, 1, 2]); $this->assertSame([1], $data->mode()); + + $data = new $collection([0.1 + 0.2, 0.3, 0.3]); + $this->assertSame([0.3], $data->mode()); + + $data = new $collection([0.1 + 0.2, 0.1 + 0.2, 0.3]); + $this->assertSame([0.1 + 0.2], $data->mode()); + + $data = new $collection([round(-0.4), 0.0, 1.5]); + $this->assertSame([0.0], $data->mode()); + + $precision = ini_get('precision'); + + try { + ini_set('precision', '3'); + + $data = new $collection([1.234, 1.2349, 9.9, 9.9]); + $this->assertSame([9.9], $data->mode()); + } finally { + ini_set('precision', $precision); + } } #[DataProvider('collectionClassProvider')] From f0aaf22cf71251fb79bfea634be63e84b3ea7285 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:49:50 +0000 Subject: [PATCH 24/30] Skip recursive merging for scalar HTTP options mergeOptionLayers() called the recursive merge helper, and checked whether the option is mergeable, for every key of every layer. Most options, such as timeout or on_stats, are scalars or closures, and the later value simply replaces the earlier one. Assign them directly and use the helper only when both values are arrays. Behavior is unchanged. Validation: composer lint:fix, composer analyse, and the HTTP, HTTP integration, Telescope and ApiClient suites pass. --- src/http/src/Client/PendingRequest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 2b9fbbe24a..51e8591cd5 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -2637,11 +2637,11 @@ protected function mergeOptionLayers(array ...$layers): array foreach ($layers as $layer) { foreach ($layer as $key => $value) { - $merged[$key] = $this->mergeOptionValue( - $merged[$key] ?? null, - $value, - in_array($key, $this->mergeableOptions, true), - ); + $current = $merged[$key] ?? null; + + $merged[$key] = is_array($current) && is_array($value) + ? $this->mergeOptionValue($current, $value, in_array($key, $this->mergeableOptions, true)) + : $value; } } From 4b29a909a4ba540fed5dd646f3c3c53220e71d7c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:17:58 +0000 Subject: [PATCH 25/30] Keep option maps on empty arrays and report raw request bodies The option-layer merge let any empty array replace the earlier value. That was right for lists, where 'auth' => [] removes credentials set by global options or a connection preset, but it also wiped maps. A later 'curl' => [] dropped every curl option configured on a lower layer, even though an empty map adds no keys. An empty array now leaves an earlier map, or a mergeable payload, unchanged and still replaces an earlier list. The HTTP client docs describe both cases. Request::data() read the payload through the request's body format and fell back to the query string. Raw form_params or multipart options passed through withOptions() or a lower layer were described as the query, or as nothing at all, and a GET with an empty raw json body reported the URL query. parseRequestData() now describes the body Guzzle actually sends, checking json, then multipart, then form_params, which mirrors Guzzle's order where the last prepared body wins. It falls back to the query and URL only when no structured body is present. Follow-up to the option-layer merge in laravel/framework#61728. Validation: composer lint:fix, composer analyse, and the HTTP, HTTP integration, Telescope and ApiClient suites pass. The new cases fail without the fix. --- src/docs/http-client.md | 2 +- src/http/src/Client/PendingRequest.php | 22 +++++++++++++++------- tests/Http/HttpClientTest.php | 25 ++++++++++++++++++++++++- tests/Http/HttpConnectionTest.php | 9 +++++++++ 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index bf6dccfe09..13ae241b6f 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -1052,7 +1052,7 @@ $response = Http::connection('github') ->get('/user'); ``` -Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Options with named keys, such as `headers`, are merged key by key. Nested lists, such as a header's values, and other list options, such as `auth` credentials, replace the lower layer's list as a whole. Top-level payload lists, such as multipart parts or a JSON list, combine the entries from every layer. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged: +Option precedence is deterministic and does not depend on chaining order: factory global options are the lowest layer, followed by the registered connection preset, an optional per-call connection override, and finally fluent request options. Options with named keys, such as `headers`, are merged key by key. Nested lists, such as a header's values, and other list options, such as `auth` credentials, replace the lower layer's list as a whole. Top-level payload lists, such as multipart parts or a JSON list, combine the entries from every layer. An empty array leaves an option with named keys, such as `curl`, unchanged, but replaces a list, so `'auth' => []` removes earlier credentials. Passing an empty per-call array intentionally clears the registered request preset. The connection's handler settings remain unchanged: ```php // Uses the registered GitHub preset... diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 51e8591cd5..99144d2e61 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -1460,17 +1460,24 @@ protected function parseRequestData(string $method, string $url, array $options) return []; } - $data = $options[$this->bodyFormat] ?? $options['query'] ?? []; + // Describe the body Guzzle sends, even when raw options bypass the body format. Guzzle prepares + // form_params, multipart and then json, and the last one wins. + $format = match (true) { + isset($options['json']) => 'json', + isset($options['multipart']) => 'multipart', + isset($options['form_params']) => 'form_params', + default => null, + }; - if ($this->bodyFormat === 'multipart' && isset($options['multipart'])) { - return is_array($data) ? $this->normalizeMultipartOption($data) : []; + if ($format === 'multipart') { + return is_array($options['multipart']) ? $this->normalizeMultipartOption($options['multipart']) : []; } - $data = $this->normalizeStructuredDataValue($data); + $data = $this->normalizeStructuredDataValue($format === null ? $options['query'] ?? [] : $options[$format]); $urlString = (new Stringable($url)); - if (empty($data) && in_array($method, ['GET', 'HEAD'], true) && $urlString->contains('?')) { + if ($format === null && empty($data) && in_array($method, ['GET', 'HEAD'], true) && $urlString->contains('?')) { $data = (string) $urlString->after('?'); } @@ -2662,8 +2669,9 @@ protected function mergeOptionValue(mixed $current, mixed $value, bool $mergeabl return $value; } - // An empty payload default, such as post() without data, keeps the earlier payload. - if ($mergeable && $value === []) { + // An empty array adds no keys to a map such as curl, and an empty payload default, such as post() + // without data, keeps the earlier payload. An empty list still clears an earlier list such as auth. + if ($value === [] && ($mergeable || ! array_is_list($current))) { return $current; } diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index ad891f3431..a0b261ac9b 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -1413,7 +1413,7 @@ public function testStructuredDataNormalizationLeavesReferencedCallerDataUnchang }); } - public function testRequestDataIncludesThePayloadFromEveryOptionLayer(): void + public function testRequestDataIncludesThePayloadFromWithOptions(): void { $requests = []; @@ -1434,6 +1434,29 @@ public function testRequestDataIncludesThePayloadFromEveryOptionLayer(): void $this->assertSame('{"a":1,"b":2}', $requests[1]->body()); } + public function testRequestDataDescribesRawBodyOptionsRatherThanTheQuery(): void + { + $requests = []; + + $this->factory->fake(function (Request $request) use (&$requests): PromiseInterface { + $requests[] = $request; + + return Factory::response(); + }); + + $this->factory->withOptions(['query' => ['page' => 1]]) + ->send('POST', 'http://foo.com/form', ['form_params' => ['name' => 'Taylor', 'active' => true]]); + + $this->factory->withOptions(['query' => ['page' => 1]]) + ->send('POST', 'http://foo.com/upload', ['multipart' => [['name' => 'file', 'contents' => 'file-contents', 'filename' => 'file.txt']]]); + + $this->factory->withOptions(['json' => []])->get('http://foo.com/get?page=1'); + + $this->assertSame(['name' => 'Taylor', 'active' => true], $requests[0]->data()); + $this->assertTrue($requests[1]->hasFile('file', 'file-contents', 'file.txt')); + $this->assertSame([], $requests[2]->data()); + } + public function testCanSendJsonDataWithStringable(): void { $this->factory->fake(); diff --git a/tests/Http/HttpConnectionTest.php b/tests/Http/HttpConnectionTest.php index 390226dcc9..5693f5a761 100644 --- a/tests/Http/HttpConnectionTest.php +++ b/tests/Http/HttpConnectionTest.php @@ -230,6 +230,7 @@ public function testLaterOptionLayersReplaceListsWhole(): void 'auth' => ['user', 'secret', 'digest'], 'allow_redirects' => ['max' => 3, 'protocols' => ['http', 'https']], 'proxy' => ['http' => 'http://proxy.test', 'no' => ['example.test']], + 'curl' => [CURLOPT_TCP_KEEPALIVE => 1], ]); $options = $factory @@ -237,12 +238,14 @@ public function testLaterOptionLayersReplaceListsWhole(): void ->withBasicAuth('other', 'pass') ->withOptions(['allow_redirects' => ['protocols' => ['https']]]) ->withOptions(['proxy' => ['no' => []]]) + ->withOptions(['curl' => []]) ->getOptions(); $this->assertSame(['X-Tags' => ['local'], 'X-Global' => 'yes'], $options['headers']); $this->assertSame(['other', 'pass'], $options['auth']); $this->assertSame(['max' => 3, 'protocols' => ['https']], $options['allow_redirects']); $this->assertSame(['http' => 'http://proxy.test', 'no' => []], $options['proxy']); + $this->assertSame([CURLOPT_TCP_KEEPALIVE => 1], $options['curl']); $options = $factory ->withOptions(['auth' => ['first', 'secret', 'digest']]) @@ -250,6 +253,8 @@ public function testLaterOptionLayersReplaceListsWhole(): void ->getOptions(); $this->assertSame(['second', 'secret'], $options['auth']); + + $this->assertSame([], $factory->withOptions(['auth' => []])->getOptions()['auth']); } public function testMergeableListsAccumulateAcrossOptionLayers(): void @@ -300,11 +305,15 @@ public function testOmittedPayloadsKeepAndExplicitNullsClearPayloadsFromEarlierL $factory->connection('api')->withOptions(['json' => ['a' => 1]])->delete('https://example.com'); $factory->connection('api')->withOptions(['json' => ['a' => 1]])->send('POST', 'https://example.com'); $factory->connection('api')->withOptions(['json' => ['a' => 1]])->send('POST', 'https://example.com', ['json' => null]); + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->get('https://example.com'); + $factory->connection('api')->withOptions(['json' => ['a' => 1]])->head('https://example.com'); $this->assertSame('{"a":1}', (string) $factory->invocations[0]['request']->getBody()); $this->assertSame('{"a":1}', (string) $factory->invocations[1]['request']->getBody()); $this->assertSame('{"a":1}', (string) $factory->invocations[2]['request']->getBody()); $this->assertSame('', (string) $factory->invocations[3]['request']->getBody()); + $this->assertSame('{"a":1}', (string) $factory->invocations[4]['request']->getBody()); + $this->assertSame('{"a":1}', (string) $factory->invocations[5]['request']->getBody()); } public function testSetConnectionConfigRegistersEvenAnEmptyPreset(): void From 482efd8a73a81d746b2f50f0081929669082408a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:18:20 +0000 Subject: [PATCH 26/30] Use each target's own key when a MorphTo owner key is inferred When a model's morph type is already set, or comes from a default attribute, morphTo() builds the relation from that type and stores the type's primary key name as the owner key. That name only fits the loaded type. whereMorphedTo(), whereNotMorphedTo(), associate(), eager loading and factory for() treated it as a declared owner key, so passing a target whose primary key has a different name read the wrong attribute. A comment whose default type is an id-keyed post could not be associated with, queried for, or eager loaded alongside a uuid-keyed video. MorphTo now records when its owner key was inferred this way. getDeclaredOwnerKeyName() returns the declared key, or null when each target should use its own primary key, and those consumers use it. getOwnerKeyName(), constructors and newMorphTo() are unchanged. whereMorphedTo() and whereNotMorphedTo() also qualified the morph type and foreign key through the relation's query. For a relation built from a loaded instance that query targets the target's table, producing columns such as posts.commentable_type and an SQL error. Both now qualify those columns through the declaring model. For relations built without a loaded type, the generated SQL is unchanged. Follow-up to laravel/framework#61712. Laravel has the same inference and qualification problems. Validation: composer lint:fix, composer analyse, and the Database, Integration/Database (SQLite), Sanctum, Passport and Notifications suites pass. Every new case fails without the fix. --- .../Eloquent/Concerns/HasRelationships.php | 5 +- .../Concerns/QueriesRelationships.php | 43 +++-- .../Factories/BelongsToRelationship.php | 2 +- .../src/Eloquent/Relations/MorphTo.php | 35 +++- .../EloquentMorphToTargetKeysTest.php | 152 ++++++++++++++++++ 5 files changed, 215 insertions(+), 22 deletions(-) create mode 100644 tests/Integration/Database/EloquentMorphToTargetKeysTest.php diff --git a/src/database/src/Eloquent/Concerns/HasRelationships.php b/src/database/src/Eloquent/Concerns/HasRelationships.php index 7858df913e..1d0d59b45f 100644 --- a/src/database/src/Eloquent/Concerns/HasRelationships.php +++ b/src/database/src/Eloquent/Concerns/HasRelationships.php @@ -414,7 +414,7 @@ protected function morphInstanceTo(string|int $target, string $name, string $typ static::getActualClassNameForMorph($target) ); - return $this->newMorphTo( + $relation = $this->newMorphTo( $instance->newQuery(), $this, $id, @@ -422,6 +422,9 @@ protected function morphInstanceTo(string|int $target, string $name, string $typ $type, $name ); + + // The loaded type's key serves this relation, but other target types may use a different one. + return $ownerKey === null ? $relation->withInferredOwnerKey() : $relation; } /** diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 8c2f81a674..7ad960d817 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -295,7 +295,7 @@ protected function getBelongsToRelation(MorphTo $relation, string $type): Belong return $this->model->belongsTo( $type, $relation->getForeignKeyName(), - $relation->getOwnerKeyName() + $relation->getDeclaredOwnerKeyName() ); }); @@ -566,15 +566,20 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b $relation = $this->getRelationWithoutConstraints($relation); } + // The morph columns belong to the declaring model. A relation built for a loaded morph type queries + // the target's table instead, so qualifying through the relation would name the wrong table. + $morphType = $relation->getParent()->qualifyColumn($relation->getMorphType()); + $foreignKey = $relation->getQualifiedForeignKeyName(); + if (is_null($model)) { // @phpstan-ignore return.type (mixin returns $this at runtime) - return $this->whereNull($relation->qualifyColumn($relation->getMorphType()), $boolean); + return $this->whereNull($morphType, $boolean); } if (is_string($model)) { $model = $this->getMorphTypeForQuery($model); - return $this->where($relation->qualifyColumn($relation->getMorphType()), $model, null, $boolean); + return $this->where($morphType, $model, null, $boolean); } $models = BaseCollection::wrap($model); @@ -583,12 +588,12 @@ public function whereMorphedTo(MorphTo|string $relation, mixed $model, string $b throw new InvalidArgumentException('Collection given to whereMorphedTo method may not be empty.'); } - return $this->where(function ($query) use ($relation, $models) { - $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { - $query->orWhere(function ($query) use ($relation, $models) { - $query->where($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) - ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map( - fn ($model) => $model->getAttribute($relation->getOwnerKeyName() ?? $model->getKeyName()) + return $this->where(function ($query) use ($relation, $models, $morphType, $foreignKey) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation, $morphType, $foreignKey) { + $query->orWhere(function ($query) use ($relation, $models, $morphType, $foreignKey) { + $query->where($morphType, $models->first()->getMorphClass()) + ->whereIn($foreignKey, $models->map( + fn ($model) => $model->getAttribute($relation->getDeclaredOwnerKeyName() ?? $model->getKeyName()) )); }); }); @@ -611,16 +616,20 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string $relation = $this->getRelationWithoutConstraints($relation); } + // Qualify through the declaring model, as whereMorphedTo() does. + $morphType = $relation->getParent()->qualifyColumn($relation->getMorphType()); + $foreignKey = $relation->getQualifiedForeignKeyName(); + if (is_null($model)) { // @phpstan-ignore return.type (mixin returns $this at runtime) - return $this->whereNotNull($relation->qualifyColumn($relation->getMorphType()), $boolean); + return $this->whereNotNull($morphType, $boolean); } if (is_string($model)) { $model = $this->getMorphTypeForQuery($model); return $this->whereNot(fn ($query) => $query->whereNullSafeEquals( - $relation->qualifyColumn($relation->getMorphType()), + $morphType, $model ), null, null, $boolean); } @@ -631,12 +640,12 @@ public function whereNotMorphedTo(MorphTo|string $relation, mixed $model, string throw new InvalidArgumentException('Collection given to whereNotMorphedTo method may not be empty.'); } - return $this->whereNot(function ($query) use ($relation, $models) { - $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation) { - $query->orWhere(function ($query) use ($relation, $models) { - $query->whereNullSafeEquals($relation->qualifyColumn($relation->getMorphType()), $models->first()->getMorphClass()) - ->whereIn($relation->qualifyColumn($relation->getForeignKeyName()), $models->map( - fn ($model) => $model->getAttribute($relation->getOwnerKeyName() ?? $model->getKeyName()) + return $this->whereNot(function ($query) use ($relation, $models, $morphType, $foreignKey) { + $models->groupBy(fn ($model) => $model->getMorphClass())->each(function ($models) use ($query, $relation, $morphType, $foreignKey) { + $query->orWhere(function ($query) use ($relation, $models, $morphType, $foreignKey) { + $query->whereNullSafeEquals($morphType, $models->first()->getMorphClass()) + ->whereIn($foreignKey, $models->map( + fn ($model) => $model->getAttribute($relation->getDeclaredOwnerKeyName() ?? $model->getKeyName()) )); }); }); diff --git a/src/database/src/Eloquent/Factories/BelongsToRelationship.php b/src/database/src/Eloquent/Factories/BelongsToRelationship.php index 01fc8cc510..b6cde04da7 100644 --- a/src/database/src/Eloquent/Factories/BelongsToRelationship.php +++ b/src/database/src/Eloquent/Factories/BelongsToRelationship.php @@ -44,7 +44,7 @@ public function attributesFor(Model $model): array return $relationship instanceof MorphTo ? [ $relationship->getMorphType() => $this->factory instanceof Factory ? $this->factory->newModel()->getMorphClass() : $this->factory->getMorphClass(), - $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), + $relationship->getForeignKeyName() => $this->resolver($relationship->getDeclaredOwnerKeyName()), ] : [ $relationship->getForeignKeyName() => $this->resolver($relationship->getOwnerKeyName()), ]; diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php index ea81d5d933..357b75c107 100644 --- a/src/database/src/Eloquent/Relations/MorphTo.php +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -33,6 +33,11 @@ class MorphTo extends BelongsTo */ protected ?string $ownerKey; + /** + * Indicates if the owner key was taken from the loaded target's type instead of being declared. + */ + protected bool $ownerKeyInferred = false; + /** * The models whose relations are being eager loaded. * @@ -146,7 +151,7 @@ protected function getResultsByType(int|string $type): EloquentCollection { $instance = $this->createModelByType($type); - $ownerKey = $this->ownerKey ?? $instance->getKeyName(); + $ownerKey = $this->getDeclaredOwnerKeyName() ?? $instance->getKeyName(); $query = $this->replayMacros($instance->newQuery()) ->mergeConstraintsFrom($this->getQuery()) @@ -211,8 +216,10 @@ public function match(array $models, EloquentCollection $results, string $relati */ protected function matchToMorphParents(int|string $type, EloquentCollection $results): void { + $declaredOwnerKey = $this->getDeclaredOwnerKeyName(); + foreach ($results as $result) { - $ownerKey = $this->getDictionaryKey(! is_null($this->ownerKey) ? $result->{$this->ownerKey} : $result->getKey()); + $ownerKey = $this->getDictionaryKey(! is_null($declaredOwnerKey) ? $result->{$declaredOwnerKey} : $result->getKey()); if ($ownerKey !== null && isset($this->dictionary[$type][$ownerKey])) { foreach ($this->dictionary[$type][$ownerKey] as $model) { @@ -241,7 +248,7 @@ public function associate(Model|string|int|null $model): Model $morphClass = null; if ($model instanceof Model) { - $foreignKeyValue = $model->{$this->ownerKey ?? $model->getKeyName()}; + $foreignKeyValue = $model->{$this->getDeclaredOwnerKeyName() ?? $model->getKeyName()}; $morphClass = $model->getMorphClass(); } @@ -297,6 +304,28 @@ public function getDictionary(): array return $this->dictionary; } + /** + * Get the declared owner key, or null when each target uses its own primary key. + */ + public function getDeclaredOwnerKeyName(): ?string + { + return $this->ownerKeyInferred ? null : $this->ownerKey; + } + + /** + * Indicate that the owner key was taken from the loaded target's type. + * + * Other target types may use a different primary key, so they keep using their own. + * + * @return $this + */ + public function withInferredOwnerKey(): static + { + $this->ownerKeyInferred = true; + + return $this; + } + /** * Specify which relations to load for a given morph type. * diff --git a/tests/Integration/Database/EloquentMorphToTargetKeysTest.php b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php new file mode 100644 index 0000000000..6d3c138210 --- /dev/null +++ b/tests/Integration/Database/EloquentMorphToTargetKeysTest.php @@ -0,0 +1,152 @@ +increments('id'); + }); + + // A non-null id makes a lookup through the wrong key miss the video's comment. With a null id, SQL's + // NULL comparison would also drop that comment from whereNotMorphedTo() and hide the mistake. + Schema::create('videos', function (Blueprint $table): void { + $table->string('uuid')->primary(); + $table->string('id'); + }); + + Schema::create('comments', function (Blueprint $table): void { + $table->increments('id'); + $table->string('commentable_type'); + $table->string('commentable_id'); + }); + + Post::create(); + Video::create(['uuid' => 'video-key', 'id' => 'other-key']); + + Comment::create(['commentable_id' => '1']); + Comment::create(['commentable_type' => Video::class, 'commentable_id' => 'video-key']); + } + + /** + * @param Closure(): (MorphTo|string) $relation + */ + #[DataProvider('morphPredicates')] + public function testMorphPredicatesUseTheTargetsPrimaryKey(Closure $relation, string $method, int $expectedCommentId): void + { + $comments = Comment::query()->{$method}($relation(), Video::find('video-key'))->pluck('id')->all(); + + $this->assertSame([$expectedCommentId], $comments); + } + + /** + * Provide both predicates with the relation given by name and as an object. + */ + public static function morphPredicates(): iterable + { + $name = fn (): string => 'commentable'; + $object = fn (): MorphTo => (new Comment)->commentable(); + + yield 'whereMorphedTo by name' => [$name, 'whereMorphedTo', 2]; + yield 'whereMorphedTo by object' => [$object, 'whereMorphedTo', 2]; + yield 'whereNotMorphedTo by name' => [$name, 'whereNotMorphedTo', 1]; + yield 'whereNotMorphedTo by object' => [$object, 'whereNotMorphedTo', 1]; + } + + public function testWhereHasMorphUsesTheTargetsPrimaryKey(): void + { + $this->assertSame([2], Comment::whereHasMorph('commentable', Video::class)->pluck('id')->all()); + } + + public function testEagerLoadingMatchesTargetsWithAnotherPrimaryKey(): void + { + $comments = Comment::with('commentable')->orderBy('id')->get(); + + $this->assertInstanceOf(Post::class, $comments[0]->commentable); + $this->assertSame('video-key', $comments[1]->commentable->getKey()); + } + + public function testAssociatingALoadedRelationUsesTheTargetsPrimaryKey(): void + { + $comment = Comment::findOrFail(1); + + $comment->commentable()->associate(Video::find('video-key'))->save(); + + $this->assertSame('video-key', $comment->fresh()->commentable_id); + } + + public function testFactoryRelationshipsUseTheTargetsPrimaryKey(): void + { + $comment = CommentFactory::new()->for(Video::find('video-key'), 'commentable')->create(); + + $this->assertSame('video-key', $comment->commentable_id); + $this->assertSame(Video::class, $comment->commentable_type); + } +} + +class Post extends Model +{ + public bool $timestamps = false; +} + +class Video extends Model +{ + public bool $timestamps = false; + + protected string $primaryKey = 'uuid'; + + protected string $keyType = 'string'; + + public bool $incrementing = false; + + protected array $fillable = ['uuid', 'id']; +} + +class Comment extends Model +{ + public bool $timestamps = false; + + protected array $attributes = ['commentable_type' => Post::class]; + + protected array $fillable = ['commentable_type', 'commentable_id']; + + /** + * Get the commented model. + */ + public function commentable(): MorphTo + { + return $this->morphTo(); + } +} + +/** + * @extends Factory + */ +class CommentFactory extends Factory +{ + protected ?string $model = Comment::class; + + /** + * Define the model's default state. + */ + public function definition(): array + { + return []; + } +} From 9a92f90dca1cef5ca7aaaf4f6c001fb44605e9fc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:18:39 +0000 Subject: [PATCH 27/30] Stop link and cursor pagination when a page and its continuation repeat Link and cursor paginators include the next URL or cursor in each page checksum, but loop detection still waited for five identical checksums in a row. An API that alternates between two continuations with the same contents, such as cursor 2 then 3 then 2, never produced five in a row and paginated until maxPages, or forever without it. For these paginators a repeated checksum means the next request repeats an earlier one, so there is nothing to gain by continuing. They now throw as soon as the current checksum matches one of the previous four pages. A retried load still replaces its own checksum rather than counting as a repeat. Page and offset paginators compare bodies only, so they keep the five-identical rule. The pagination guide describes both rules. The cursor checksum test no longer feeds trailing repeated cursors, which the new rule correctly rejects, and the repeated-continuation test now stops after the second request. Validation: composer lint:fix, composer analyse and the Saloon suite pass. The alternating-continuation test fails without the fix. --- src/docs/saloon.md | 2 +- src/saloon/src/Pagination/CursorPaginator.php | 5 +++ src/saloon/src/Pagination/LinkPaginator.php | 5 +++ src/saloon/src/Pagination/Paginator.php | 18 ++++++++++- tests/Saloon/Pagination/PaginatorTest.php | 32 +++++++++++++++++-- 5 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/docs/saloon.md b/src/docs/saloon.md index d9c0308535..0241c094f7 100644 --- a/src/docs/saloon.md +++ b/src/docs/saloon.md @@ -2217,7 +2217,7 @@ protected string $perPageName = 'pageSize'; `PagedPaginator` defaults to `page` and `per_page`, and sends a page size only when a per-page limit is set. To send one by default, declare `protected ?int $perPageLimit = 100;` on your paginator. `OffsetPaginator` provides `$limitName` and `$offsetName`, defaulting to `limit` and `offset`; `CursorPaginator` provides `$cursorName` and `$perPageName`, defaulting to `cursor` and `per_page`. Override `applyPagination(Request $request): Request` for a protocol that needs a different request structure. -During sequential pagination, Saloon throws a `PaginationException` if five consecutive pages are identical. Page and offset paginators compare response bodies. Link and cursor paginators also compare the next URL or cursor, so empty pages can continue when the API supplies an advancing continuation. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator. +During sequential pagination, Saloon throws a `PaginationException` when it detects a loop. Page and offset paginators compare response bodies and stop after five consecutive identical pages. Link and cursor paginators also compare the next URL or cursor, so empty pages can continue when the API supplies an advancing continuation. They stop as soon as a page and its continuation repeat one of the previous four pages, since the next request would repeat an earlier one. Retrying the current page does not count as another page. If your API legitimately returns identical pages, you may disable this check by declaring `protected bool $detectInfiniteLoop = false;` on your paginator. The check runs after response middleware and item mapping. If your API adds a value that changes on every request, such as a request ID, override `getBodyChecksum` to compare only the page contents. Link and cursor paginators still include continuation information when using this override: diff --git a/src/saloon/src/Pagination/CursorPaginator.php b/src/saloon/src/Pagination/CursorPaginator.php index 659cb5dde2..cf5c1c01f5 100644 --- a/src/saloon/src/Pagination/CursorPaginator.php +++ b/src/saloon/src/Pagination/CursorPaginator.php @@ -25,6 +25,11 @@ abstract class CursorPaginator extends Paginator */ protected string $perPageName = 'per_page'; + /** + * Whether page checksums include the provider's continuation, so a repeated checksum repeats an earlier request. + */ + protected bool $checksumIncludesContinuation = true; + /** * Apply cursor pagination to the request. * diff --git a/src/saloon/src/Pagination/LinkPaginator.php b/src/saloon/src/Pagination/LinkPaginator.php index 77675f7c41..0c186b1ad8 100644 --- a/src/saloon/src/Pagination/LinkPaginator.php +++ b/src/saloon/src/Pagination/LinkPaginator.php @@ -18,6 +18,11 @@ */ abstract class LinkPaginator extends PagedPaginator { + /** + * Whether page checksums include the provider's continuation, so a repeated checksum repeats an earlier request. + */ + protected bool $checksumIncludesContinuation = true; + /** * The next page's resolved URI. */ diff --git a/src/saloon/src/Pagination/Paginator.php b/src/saloon/src/Pagination/Paginator.php index 5ad5aeec6f..2832cdd8e0 100644 --- a/src/saloon/src/Pagination/Paginator.php +++ b/src/saloon/src/Pagination/Paginator.php @@ -79,6 +79,11 @@ abstract class Paginator implements Countable, Iterator */ protected bool $detectInfiniteLoop = true; + /** + * Whether page checksums include the provider's continuation, so a repeated checksum repeats an earlier request. + */ + protected bool $checksumIncludesContinuation = false; + /** * Whether the paginator is currently preparing pooled pages. */ @@ -134,8 +139,19 @@ public function current(): Response // Checked here instead of in request middleware, where the callback would keep the paginator alive through // its own request and every response. if ($this->detectInfiniteLoop && ! $this->pooling) { + $checksum = $this->getPageChecksum($response); + $earlierChecksums = $this->lastFiveBodyChecksums; + // Retrying a failed load replaces its checksum instead of adding another page. - $this->lastFiveBodyChecksums[$this->currentPage] = $this->getPageChecksum($response); + unset($earlierChecksums[$this->currentPage]); + + if ($this->checksumIncludesContinuation && in_array($checksum, $earlierChecksums, true)) { + throw new PaginationException( + 'Potential infinite loop detected! A page repeated an earlier page and its continuation. You can use the $detectInfiniteLoop property on your paginator to disable this check.', + ); + } + + $this->lastFiveBodyChecksums[$this->currentPage] = $checksum; if (count($this->lastFiveBodyChecksums) >= 5) { if (count(array_unique($this->lastFiveBodyChecksums)) === 1) { diff --git a/tests/Saloon/Pagination/PaginatorTest.php b/tests/Saloon/Pagination/PaginatorTest.php index 5b5cd8b474..7185100363 100644 --- a/tests/Saloon/Pagination/PaginatorTest.php +++ b/tests/Saloon/Pagination/PaginatorTest.php @@ -1057,7 +1057,33 @@ public function testRepeatedContentsAndContinuationStillDetectLoopsWithCustomBod } $this->assertInstanceOf(PaginationException::class, $caught); - $this->assertSame(5, $requested); + $this->assertSame(2, $requested); + } + + #[DataProvider('continuationPaginators')] + public function testAlternatingContinuationsWithRepeatedContentsDetectLoops(string $class): void + { + $manager = $this->manager(); + $requested = 0; + $manager->fake([PagedRequestStub::class => static function () use (&$requested): MockResponse { + $next = ++$requested % 2 === 1 ? 2 : 3; + + return MockResponse::make(['data' => [], 'request_id' => $requested], headers: [ + 'Link' => "; rel=next", + 'X-Next-Cursor' => (string) $next, + ]); + }]); + $paginator = (new $class(new PaginationConnectorStub($manager), new PagedRequestStub))->maxPages(6); + $caught = null; + + try { + iterator_to_array($paginator); + } catch (PaginationException $exception) { + $caught = $exception; + } + + $this->assertInstanceOf(PaginationException::class, $caught); + $this->assertSame(3, $requested); } /** @@ -1090,8 +1116,8 @@ public function testCursorChecksumsDistinguishScalarKindsAndTerminalPages(array */ public static function cursorChecksumValues(): iterable { - yield 'scalar kinds' => [[0, false, 0.0, '0', 0]]; - yield 'nonterminal null' => [[null, null, null, null]]; + yield 'scalar kinds' => [[0, false, 0.0, '0']]; + yield 'nonterminal null' => [[null]]; } public function testLoopDetectionChecksTheResponseReturnedByRequestMiddleware(): void From e572929b5400d850a5df0deffb62d9167ab889ab Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:19:01 +0000 Subject: [PATCH 28/30] Skip outbox restart polling when interruption polling is disabled Queue::withoutInterruptionPolling() sets Worker::$restartable to false. Queue workers then stop reading the restart signal from the cache, and queue:restart refuses to run (laravel/framework#61706). The outbox relay still read the signal on every loop, costing a cache round trip per iteration and restarting on a signal the queue workers ignore. restartTimestamp() now returns null when restarts are disabled, matching Worker::getTimestampOfLastQueueRestart(). Validation: composer lint:fix, composer analyse and the Queue suite pass. The new test fails without the fix. --- src/queue/src/Outbox/OutboxWorker.php | 5 +++++ tests/Queue/Outbox/OutboxWorkerTest.php | 11 +++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/queue/src/Outbox/OutboxWorker.php b/src/queue/src/Outbox/OutboxWorker.php index 57e8732875..fc910f7299 100644 --- a/src/queue/src/Outbox/OutboxWorker.php +++ b/src/queue/src/Outbox/OutboxWorker.php @@ -188,6 +188,11 @@ protected function claim(string $database, int $limit): ?array */ protected function restartTimestamp(): ?int { + // Queue::withoutInterruptionPolling() keeps every worker, including this relay, off the cache. + if (! Worker::$restartable) { + return null; + } + return (new Waiter(-1))->wait(function (): ?int { $timestamp = $this->cache->store()->get(Worker::RESTART_SIGNAL_CACHE_KEY); diff --git a/tests/Queue/Outbox/OutboxWorkerTest.php b/tests/Queue/Outbox/OutboxWorkerTest.php index 5f7d6132f2..5e25ca9c6e 100644 --- a/tests/Queue/Outbox/OutboxWorkerTest.php +++ b/tests/Queue/Outbox/OutboxWorkerTest.php @@ -188,6 +188,17 @@ public function testRestartReadsEndTheirCoroutineBeforeClaimingAndRestartStopsAd $this->assertFalse($borrowed); } + public function testRestartSignalIsNotReadWithoutInterruptionPolling(): void + { + Worker::$restartable = false; + $worker = $this->worker(); + $this->cache->shouldNotReceive('get'); + $this->relay->expects('claim')->andReturn([$this->message(1)]); + $this->relay->expects('deliver')->andReturn(true); + + $this->assertSame(0, $worker->run(null, new OutboxWorkerOptions(maxJobs: 1, memory: 1024))); + } + public function testEscapingTaskFailureStopsAdmissionAndPropagates(): void { $worker = $this->worker(); From 325c968a14240c6bb2be3660b4ecebbc1cb7ea9a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:19:09 +0000 Subject: [PATCH 29/30] Document null job durations and correct the locale validation note JobProcessed::$duration is nullable since laravel/framework#61672, but the queue docs only described it as a number of milliseconds. It is null when the worker skips a job that was already deleted, such as by a JobProcessing listener, so the docs now say so. The translation README said locales passed directly to get() are always validated. Validation happens in setLocale(), setBaseLocale(), setFallback() and FileLoader::load(), so get() rejects an unsafe locale only when it loads translations from files. The README now names those methods. --- src/docs/queues.md | 2 +- src/translation/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index 332cb8a2bb..30417dc27b 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -4079,7 +4079,7 @@ class AppServiceProvider extends ServiceProvider } ``` -The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. +The `duration` property of the `JobProcessed` event contains the number of milliseconds the job took to run. It is `null` when the worker skips the job because it was already deleted, such as by a `JobProcessing` listener. Hypervel dispatches a `JobQueueing` event immediately before a job is sent to its queue and a `JobQueued` event after the queue accepts it. If the enqueue attempt throws an exception, a `JobQueueingFailed` event is dispatched with the original exception instead. Jobs deferred until a database transaction commits do not dispatch these events unless the enqueue attempt actually begins. diff --git a/src/translation/README.md b/src/translation/README.md index c7340e40c7..1da75ddfcd 100644 --- a/src/translation/README.md +++ b/src/translation/README.md @@ -9,6 +9,6 @@ Documentation: https://hypervel.org/docs/localization - `Translator::setLocale()` changes the locale only for the current coroutine and does not affect other concurrent requests in the worker. Use the boot-only `setBaseLocale()` method to change the worker's default locale. The `setFallback()` method likewise changes the fallback shared by the worker. - `Translator` rejects JSON translation files whose top-level values are not strings or arrays, naming the file and key. A `null` value is allowed and means the key is untranslated. -- Locales containing path separators, `..`, null bytes, or equal to `.` throw `InvalidArgumentException`, including locales passed directly to `get()` or `FileLoader::load()`. Laravel's file loader returns no lines for them instead. Validate user-supplied locales before using them. +- `setLocale()`, `setBaseLocale()`, `setFallback()` and `FileLoader::load()` throw `InvalidArgumentException` for locales containing path separators, `..` or null bytes, or equal to `.`. A locale passed directly to `get()` is therefore rejected when it is loaded from files. Laravel's file loader returns no lines for them instead. Validate user-supplied locales before using them. Ported from: https://github.com/laravel/framework From e880a758d990990007ff4cc1fecf0715432f3997 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 25 Oct 2026 16:38:24 +0000 Subject: [PATCH 30/30] Document inherited request payloads A request sent without data keeps a json or form_params payload configured on global options, a connection preset or withOptions(), including get(), head() and delete(). Laravel drops the payload for those calls, so the HTTP client docs now say the body is inherited and show how to remove a payload option with null. --- src/docs/http-client.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/docs/http-client.md b/src/docs/http-client.md index 13ae241b6f..c0eea49d60 100644 --- a/src/docs/http-client.md +++ b/src/docs/http-client.md @@ -1062,6 +1062,8 @@ Http::connection('github')->get('/user'); Http::connection('github', [])->get('https://example.com'); ``` +Requests sent without data, including `get($url)`, `head($url)` and `delete($url)`, still inherit a configured body. Set a payload option to `null` to remove it; for example, `withOptions(['json' => null])` removes the configured JSON payload. + `setHandler()` replaces only the low-level handler beneath that request's fresh middleware stack; its global, connection, and fluent options still apply. `setClient()` gives the caller complete ownership of the client and bypasses the connection preset. Global and fluent request options still apply to the request sent through that client. Each pending request constructs one cookie jar and reuses it only across that request's redirects and retry attempts. Concurrent requests never share cookies, including requests made without `connection()`.