From f5492718cef016062717cc5e72c564dbae26d95d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 01/34] Add Markdown responses and preserve streaming
cancellation
Port Laravel framework #61800 (3e42fd31ec) and its documentation from laravel/docs 226b0649c7. Add response()->markdown() with status, headers and content negotiation coverage, and declare the required method on the response factory contract.
Preserve the supported null callback and late setCallback streaming path in the contract. Streaming callbacks must propagate cancellation even when producer cleanup throws and carries cancellation in its exception chain. Use the existing shared classifier instead of wrapping or reporting it as an ordinary response failure.
Validated the complete response factory and Markdown response tests, affected routing and facade suites, formatting and full static analysis.
---
src/contracts/src/Routing/ResponseFactory.php | 7 ++-
src/docs/responses.md | 16 +++++
src/routing/composer.json | 1 +
src/routing/src/ResponseFactory.php | 24 ++++++--
src/support/src/Facades/Response.php | 1 +
.../Integration/Http/MarkdownResponseTest.php | 60 +++++++++++++++++++
tests/Routing/RoutingResponseFactoryTest.php | 55 +++++++++++++----
7 files changed, 147 insertions(+), 17 deletions(-)
create mode 100644 tests/Integration/Http/MarkdownResponseTest.php
diff --git a/src/contracts/src/Routing/ResponseFactory.php b/src/contracts/src/Routing/ResponseFactory.php
index 78253f4f74..090252b874 100644
--- a/src/contracts/src/Routing/ResponseFactory.php
+++ b/src/contracts/src/Routing/ResponseFactory.php
@@ -43,6 +43,11 @@ public function json(mixed $data = [], int $status = 200, array $headers = [], i
*/
public function jsonp(string $callback, mixed $data = [], int $status = 200, array $headers = [], int $options = 0): JsonResponse;
+ /**
+ * Create a new Markdown response instance.
+ */
+ public function markdown(string $content = '', int $status = 200, array $headers = []): Response;
+
/**
* Create a new event stream response.
*
@@ -53,7 +58,7 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven
/**
* Create a new streamed response instance.
*/
- public function stream(callable $callback, int $status = 200, array $headers = []): StreamedResponse;
+ public function stream(?callable $callback, int $status = 200, array $headers = []): StreamedResponse;
/**
* Create a new streamed JSON response instance.
diff --git a/src/docs/responses.md b/src/docs/responses.md
index 96e3391b44..4d110bd9dd 100644
--- a/src/docs/responses.md
+++ b/src/docs/responses.md
@@ -13,6 +13,7 @@
- [No Content Responses](#no-content-responses)
- [View Responses](#view-responses)
- [JSON Responses](#json-responses)
+ - [Markdown Responses](#markdown-responses)
- [File Downloads](#file-downloads)
- [File Responses](#file-responses)
- [Streamed Responses](#streamed-responses)
@@ -409,6 +410,21 @@ return response()->jsonp($callback, [
]);
```
+
+### Markdown Responses
+
+The `markdown` method may be used to return Markdown content with the `Content-Type` header set to `text/markdown`:
+
+```php
+return response()->markdown("# Hello\n\nWorld");
+```
+
+You may pass a custom HTTP status code and an array of additional headers as the second and third arguments:
+
+```php
+return response()->markdown('# Not Found', 404, ['X-Custom-Header' => 'Value']);
+```
+
### File Downloads
diff --git a/src/routing/composer.json b/src/routing/composer.json
index c88bab1102..4fabb1848c 100644
--- a/src/routing/composer.json
+++ b/src/routing/composer.json
@@ -33,6 +33,7 @@
"hypervel/container": "^0.4",
"hypervel/context": "^0.4",
"hypervel/contracts": "^0.4",
+ "hypervel/coroutine": "^0.4",
"hypervel/database": "^0.4",
"hypervel/foundation": "^0.4",
"hypervel/http": "^0.4",
diff --git a/src/routing/src/ResponseFactory.php b/src/routing/src/ResponseFactory.php
index 02f09bbb6a..066bb12f99 100644
--- a/src/routing/src/ResponseFactory.php
+++ b/src/routing/src/ResponseFactory.php
@@ -8,6 +8,7 @@
use Closure;
use Hypervel\Contracts\Routing\ResponseFactory as FactoryContract;
use Hypervel\Contracts\View\Factory as ViewFactory;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Http\IterableStreamedResponse;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\RedirectResponse;
@@ -19,7 +20,6 @@
use Hypervel\Support\Traits\Macroable;
use ReflectionFunction;
use SplFileInfo;
-use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedJsonResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -92,6 +92,16 @@ public function jsonp(string $callback, mixed $data = [], int $status = 200, arr
return $this->json($data, $status, $headers, $options)->setCallback($callback);
}
+ /**
+ * Create a new Markdown response instance.
+ */
+ public function markdown(string $content = '', int $status = 200, array $headers = []): Response
+ {
+ return $this->make($content, $status, array_merge($headers, [
+ 'Content-Type' => 'text/markdown',
+ ]));
+ }
+
/**
* Create a new event stream response.
*
@@ -109,9 +119,11 @@ public function eventStream(Closure $callback, array $headers = [], StreamedEven
if (filled($endStreamWith)) {
yield $this->formatStreamedEvent($endStreamWith);
}
- } catch (CanceledException $e) {
- throw $e;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
report($e);
}
}, 200, array_merge($headers, [
@@ -191,9 +203,11 @@ public function streamDownload(callable $callback, ?string $name = null, array $
$withWrappedException = function () use ($callback) {
try {
$callback();
- } catch (CanceledException $e) {
- throw $e;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
throw new StreamedResponseException($e);
}
};
diff --git a/src/support/src/Facades/Response.php b/src/support/src/Facades/Response.php
index 0d9659cdba..26560446f1 100644
--- a/src/support/src/Facades/Response.php
+++ b/src/support/src/Facades/Response.php
@@ -17,6 +17,7 @@
* @method static \Hypervel\Http\JsonResponse jsonp(string $callback, mixed $data = [], int $status = 200, array $headers = [], int $options = 0)
* @method static void macro(string $name, callable|object $macro)
* @method static \Hypervel\Http\Response make(mixed $content = '', int $status = 200, array $headers = [])
+ * @method static \Hypervel\Http\Response markdown(string $content = '', int $status = 200, array $headers = [])
* @method static void mixin(object $mixin, bool $replace = true)
* @method static \Hypervel\Http\Response noContent(int $status = 204, array $headers = [])
* @method static \Hypervel\Http\RedirectResponse redirectGuest(string $path, int $status = 302, array $headers = [], bool|null $secure = null)
diff --git a/tests/Integration/Http/MarkdownResponseTest.php b/tests/Integration/Http/MarkdownResponseTest.php
new file mode 100644
index 0000000000..9fc3efcf12
--- /dev/null
+++ b/tests/Integration/Http/MarkdownResponseTest.php
@@ -0,0 +1,60 @@
+markdown("# Hello\n\nWorld");
+ });
+
+ $response = $this->get('/markdown');
+
+ $response->assertOk();
+ $response->assertHeader('Content-Type', 'text/markdown; charset=utf-8');
+ $response->assertContent("# Hello\n\nWorld");
+ }
+
+ public function testMarkdownResponseWithStatusAndHeaders(): void
+ {
+ Route::get('/markdown', function (): Response {
+ return response()->markdown('# Not Found', 404, [
+ 'Content-Type' => 'text/html',
+ 'X-Foo' => 'bar',
+ ]);
+ });
+
+ $response = $this->get('/markdown');
+
+ $response->assertNotFound();
+ $response->assertHeader('Content-Type', 'text/markdown; charset=utf-8');
+ $response->assertHeader('X-Foo', 'bar');
+ $response->assertContent('# Not Found');
+ }
+
+ public function testMarkdownResponseCanBeNegotiated(): void
+ {
+ Route::get('/markdown', function (Request $request): Response {
+ return $request->wantsMarkdown()
+ ? response()->markdown('# Hello')
+ : response('
Hello
');
+ });
+
+ $this->get('/markdown', ['Accept' => 'text/markdown'])
+ ->assertHeader('Content-Type', 'text/markdown; charset=utf-8')
+ ->assertContent('# Hello');
+
+ $this->get('/markdown', ['Accept' => 'text/html'])
+ ->assertHeader('Content-Type', 'text/html; charset=utf-8')
+ ->assertContent('Hello
');
+ }
+}
diff --git a/tests/Routing/RoutingResponseFactoryTest.php b/tests/Routing/RoutingResponseFactoryTest.php
index a83f4a2cfa..3e11b657ea 100644
--- a/tests/Routing/RoutingResponseFactoryTest.php
+++ b/tests/Routing/RoutingResponseFactoryTest.php
@@ -15,8 +15,11 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
+use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\StreamedResponse;
+use Throwable;
class RoutingResponseFactoryTest extends TestCase
{
@@ -83,14 +86,23 @@ public function testStreamSupportsInvokableGeneratorCallables(): void
$this->assertSame(['invokable'], $chunks);
}
- public function testStreamKeepsOrdinaryCallbacksOnSymfonyResponse(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testStreamKeepsOrdinaryCallbacksOnSymfonyResponse(bool $setCallbackLater): void
{
- $response = $this->factory()->stream(static function (): void {
+ $callback = static function (): void {
echo 'callback';
- });
+ };
+ $response = $this->factory()->stream($setCallbackLater ? null : $callback);
+
+ if ($setCallbackLater) {
+ $response->setCallback($callback);
+ }
$this->assertInstanceOf(StreamedResponse::class, $response);
$this->assertNotInstanceOf(IterableStreamedResponse::class, $response);
+ $this->expectOutputString('callback');
+ $response->sendContent();
}
public function testEventStreamStopsProducingAfterAFailedWrite(): void
@@ -158,26 +170,42 @@ static function () {
}
#[DataProvider('streamingFactoryMethods')]
- public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrWrapping(string $method): void
+ public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrWrapping(string $method, bool $cleanupThrows): void
{
$failure = new CanceledException('response canceled');
+ $cleanup = new RuntimeException('cleanup failed');
$handler = m::mock(ExceptionHandler::class);
$handler->shouldNotReceive('report');
Container::getInstance()->instance(ExceptionHandler::class, $handler);
+ $operation = static function () use ($failure, $cleanup, $cleanupThrows): never {
+ try {
+ throw $failure;
+ } finally {
+ if ($cleanupThrows) {
+ throw $cleanup;
+ }
+ }
+ };
$callback = $method === 'eventStream'
- ? static function () use ($failure): iterable {
+ ? static function () use ($operation): iterable {
yield 'first';
- throw $failure;
+ $operation();
}
- : static fn () => throw $failure;
+ : $operation;
$response = $this->factory()->{$method}($callback);
$this->expectOutputString($method === 'eventStream' ? "event: update\ndata: first\n\n" : '');
+ $caught = null;
try {
$response->sendContent();
- $this->fail('Expected response production to propagate cancellation.');
- } catch (CanceledException $exception) {
- $this->assertSame($failure, $exception);
+ } catch (Throwable $exception) {
+ $caught = $exception;
+ }
+
+ $this->assertSame($cleanupThrows ? $cleanup : $failure, $caught);
+
+ if ($cleanupThrows) {
+ $this->assertSame($failure, $cleanup->getPrevious());
}
}
@@ -186,7 +214,12 @@ public function testStreamingFactoriesPassCancellationThroughWithoutReportingOrW
*/
public static function streamingFactoryMethods(): array
{
- return [['eventStream'], ['streamDownload']];
+ return [
+ 'event stream' => ['eventStream', false],
+ 'event stream cleanup' => ['eventStream', true],
+ 'download' => ['streamDownload', false],
+ 'download cleanup' => ['streamDownload', true],
+ ];
}
private function factory(): ResponseFactory
From b5b5246444216ede5516f7e6fff497a997cacc08 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 02/34] Preserve chained cancellation through HTTP and AI
streaming
A cleanup exception can carry coroutine cancellation as its previous exception. Treating it as an ordinary failure resumed HTTP finalization or emitted an AI error frame after the request had been canceled.
Use the existing cancellation classifier at the HTTP request and finalization boundaries and in AI stream protocols. Preserve the original exception and existing cleanup ownership without adding a new cancellation mechanism.
Extended existing regressions with throwing-cleanup cases. The cases reproduce the failures before the correction; complete affected suites, formatting and full static analysis pass.
---
.../Streaming/Protocols/StreamProtocol.php | 8 +--
src/http-server/src/Server.php | 50 ++++++++++++-------
tests/Ai/StreamCancellationTest.php | 37 +++++++++++---
tests/HttpServer/ServerTest.php | 42 ++++++++++------
4 files changed, 95 insertions(+), 42 deletions(-)
diff --git a/src/ai/src/Streaming/Protocols/StreamProtocol.php b/src/ai/src/Streaming/Protocols/StreamProtocol.php
index d66f45e792..764e00dc2e 100644
--- a/src/ai/src/Streaming/Protocols/StreamProtocol.php
+++ b/src/ai/src/Streaming/Protocols/StreamProtocol.php
@@ -7,8 +7,8 @@
use Generator;
use Hypervel\Ai\Exceptions\StreamErrorException;
use Hypervel\Ai\Responses\StreamableAgentResponse;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Http\IterableStreamedResponse;
-use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
@@ -54,9 +54,11 @@ public function response(StreamableAgentResponse $response): Response
foreach ($this->parts($response) as $part) {
yield $this->encode($part);
}
- } catch (CanceledException $e) {
- throw $e;
} catch (Throwable $e) {
+ if (Coroutine::causedByCancellation($e)) {
+ throw $e;
+ }
+
$this->exception = $e;
// A stream error exception carries a provider error the stream already surfaced, so only report anything else...
diff --git a/src/http-server/src/Server.php b/src/http-server/src/Server.php
index 45869f25c9..a0645ec476 100644
--- a/src/http-server/src/Server.php
+++ b/src/http-server/src/Server.php
@@ -110,11 +110,13 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
// Dispatch through the Kernel (global middleware → Router → response)
$response = $this->kernel->handle($request);
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception = $throwable;
- $response = new SymfonyResponse('Internal Server Error', 500);
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception = $throwable;
+ $response = new SymfonyResponse('Internal Server Error', 500);
+ }
} finally {
if (isset($request) && $cancellation === null) {
try {
@@ -126,10 +128,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
server: $this->serverName
));
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -145,10 +149,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
protocol: is_string($protocol) ? $protocol : 'HTTP/1.1',
request: $request ?? null,
);
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -162,10 +168,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
server: $this->serverName
));
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -173,10 +181,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
if (isset($request) && $response !== null && $cancellation === null) {
try {
$this->kernel->terminate($request, $response);
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
@@ -190,10 +200,12 @@ public function onRequest(SwooleRequest $swooleRequest, SwooleResponse $swooleRe
server: $this->serverName
)));
}
- } catch (CanceledException $throwable) {
- $cancellation = $throwable;
} catch (Throwable $throwable) {
- $exception ??= $throwable;
+ if (Coroutine::causedByCancellation($throwable)) {
+ $cancellation = $throwable;
+ } else {
+ $exception ??= $throwable;
+ }
}
}
diff --git a/tests/Ai/StreamCancellationTest.php b/tests/Ai/StreamCancellationTest.php
index 50738050eb..9924ee259c 100644
--- a/tests/Ai/StreamCancellationTest.php
+++ b/tests/Ai/StreamCancellationTest.php
@@ -12,26 +12,32 @@
use Hypervel\Support\Facades\Exceptions;
use Hypervel\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
+use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Throwable;
class StreamCancellationTest extends TestCase
{
- #[DataProvider('protocols')]
- public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $protocol): void
+ #[DataProvider('cancellationProtocols')]
+ public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $protocol, bool $cleanupThrows): void
{
Exceptions::fake();
$cancellation = new CanceledException('The client disconnected.');
+ $cleanup = new RuntimeException('Producer cleanup failed.');
$cleanedUp = false;
$completed = false;
$caught = null;
- $stream = (new StreamableAgentResponse('invocation-1', function () use ($cancellation, &$cleanedUp): Generator {
+ $stream = (new StreamableAgentResponse('invocation-1', function () use ($cancellation, $cleanup, $cleanupThrows, &$cleanedUp): Generator {
try {
yield new StreamStart('message-1', 'openai', 'gpt-5', time());
throw $cancellation;
} finally {
$cleanedUp = true;
+
+ if ($cleanupThrows) {
+ throw $cleanup;
+ }
}
}))->catch(function (Throwable $exception) use (&$caught): void {
$caught = $exception;
@@ -55,12 +61,16 @@ public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $
return true;
});
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$thrown = $exception;
}
- $this->assertSame($cancellation, $thrown);
- $this->assertSame($cancellation, $caught);
+ $this->assertSame($cleanupThrows ? $cleanup : $cancellation, $thrown);
+ $this->assertSame($cleanupThrows ? $cleanup : $cancellation, $caught);
+
+ if ($cleanupThrows) {
+ $this->assertSame($cancellation, $cleanup->getPrevious());
+ }
$this->assertTrue($cleanedUp);
$this->assertFalse($completed);
$this->assertNotEmpty($chunks);
@@ -68,6 +78,21 @@ public function testCancellationEscapesWithoutAnErrorOrCompletionFrame(?string $
Exceptions::assertNothingReported();
}
+ /**
+ * Provide cancellation with and without a producer cleanup failure for each protocol.
+ */
+ public static function cancellationProtocols(): array
+ {
+ $cases = [];
+
+ foreach (self::protocols() as $name => [$protocol]) {
+ $cases[$name] = [$protocol, false];
+ $cases[$name . ' cleanup'] = [$protocol, true];
+ }
+
+ return $cases;
+ }
+
#[DataProvider('protocols')]
public function testAFailedWriteDisposesTheProducerWithoutResumingIt(?string $protocol): void
{
diff --git a/tests/HttpServer/ServerTest.php b/tests/HttpServer/ServerTest.php
index 02853467cd..254bdbb47b 100644
--- a/tests/HttpServer/ServerTest.php
+++ b/tests/HttpServer/ServerTest.php
@@ -25,6 +25,7 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionProperty;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
@@ -571,11 +572,17 @@ public function testOnRequestDispatchesLifecycleEventsWhenListenersAreRegistered
], $order);
}
- public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellation(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellation(bool $chained): void
{
CoordinatorManager::until(Constants::WORKER_START)->resume();
$cancellation = new CanceledException;
+ if ($chained) {
+ $cancellation = new RuntimeException('cleanup failed', previous: $cancellation);
+ }
+
$kernel = m::mock(KernelContract::class);
$kernel->shouldReceive('handle')->once()->andThrow($cancellation);
$kernel->shouldNotReceive('terminate');
@@ -600,21 +607,17 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio
$swooleResponse->shouldReceive('status', 'header', 'cookie', 'rawcookie', 'write', 'sendfile', 'end')->never();
$caughtCancellation = null;
- $failure = null;
$completed = new Channel(1);
$requestCoroutine = EngineCoroutine::create(function () use (
$completed,
$server,
$swooleResponse,
&$caughtCancellation,
- &$failure,
): void {
try {
$server->onRequest($this->createSwooleRequest(), $swooleResponse);
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$caughtCancellation = $exception;
- } catch (Throwable $throwable) {
- $failure = $throwable;
} finally {
$completed->push(true);
}
@@ -622,7 +625,6 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio
try {
$this->assertTrue($completed->pop(1));
- $this->assertNull($failure);
$this->assertSame($cancellation, $caughtCancellation);
$this->assertSame([], $dispatchedEvents);
$this->assertFalse(RequestContext::has());
@@ -637,9 +639,15 @@ public function testOnRequestSkipsFallbackEmissionAndTerminationAfterCancellatio
public function testOnRequestStopsAtCancellationDuringFinalization(
string $stage,
array $expectedOrder,
+ bool $chained,
): void {
CoordinatorManager::until(Constants::WORKER_START)->resume();
$cancellation = new CanceledException;
+
+ if ($chained) {
+ $cancellation = new RuntimeException('cleanup failed', previous: $cancellation);
+ }
+
$order = [];
$kernel = m::mock(KernelContract::class);
@@ -700,23 +708,29 @@ public function testOnRequestStopsAtCancellationDuringFinalization(
});
}
+ $caught = null;
+
try {
$server->onRequest($this->createSwooleRequest(), $swooleResponse);
- $this->fail('Expected cancellation to propagate.');
- } catch (CanceledException $exception) {
- $this->assertSame($cancellation, $exception);
+ } catch (Throwable $exception) {
+ $caught = $exception;
}
+ $this->assertSame($cancellation, $caught);
$this->assertSame($expectedOrder, $order);
}
public static function lifecycleCancellationStages(): array
{
return [
- 'handled listener' => ['handled', ['handled']],
- 'response send' => ['send', ['handled']],
- 'sent listener' => ['sent', ['handled', 'sent']],
- 'termination' => ['terminate', ['handled', 'sent', 'terminate']],
+ 'handled listener' => ['handled', ['handled'], false],
+ 'response send' => ['send', ['handled'], false],
+ 'sent listener' => ['sent', ['handled', 'sent'], false],
+ 'termination' => ['terminate', ['handled', 'sent', 'terminate'], false],
+ 'handled listener cleanup' => ['handled', ['handled'], true],
+ 'response send cleanup' => ['send', ['handled'], true],
+ 'sent listener cleanup' => ['sent', ['handled', 'sent'], true],
+ 'termination cleanup' => ['terminate', ['handled', 'sent', 'terminate'], true],
];
}
From c7108a67a85a40cc0fdde828e0746e917cc097c6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 03/34] Support lazy creation of local filesystem roots
Port Laravel #61799 (bf219de81b). Forward the optional lazy_root_creation setting to Flysystem so read-only disk resolution can avoid creating its root directory. Preserve eager creation as the default.
Port both upstream directory-creation cases using the existing isolated temporary directory and document the option alongside local disks.
Validated the complete filesystem manager file, affected filesystem tests, formatting and full static analysis.
---
src/docs/filesystem.md | 6 ++++
src/filesystem/src/FilesystemManager.php | 3 +-
tests/Filesystem/FilesystemManagerTest.php | 32 ++++++++++++++++++++++
3 files changed, 40 insertions(+), 1 deletion(-)
diff --git a/src/docs/filesystem.md b/src/docs/filesystem.md
index 8988423a3b..f4bf2e193e 100644
--- a/src/docs/filesystem.md
+++ b/src/docs/filesystem.md
@@ -56,6 +56,12 @@ use Hypervel\Support\Facades\Storage;
Storage::disk('local')->put('example.txt', 'Contents');
```
+By default, the local driver creates its root directory when the disk is resolved. To defer creation until a write operation needs the directory, set the `lazy_root_creation` option to `true` in the disk's configuration:
+
+```php
+'lazy_root_creation' => true,
+```
+
### The Public Disk
diff --git a/src/filesystem/src/FilesystemManager.php b/src/filesystem/src/FilesystemManager.php
index 1be29b8246..ee245b0dbb 100644
--- a/src/filesystem/src/FilesystemManager.php
+++ b/src/filesystem/src/FilesystemManager.php
@@ -387,7 +387,8 @@ public function createLocalDriver(array $config, string $name = 'local'): Filesy
$config['root'],
$visibility,
$config['lock'] ?? LOCK_EX,
- $links
+ $links,
+ lazyRootCreation: $config['lazy_root_creation'] ?? false,
);
return (new LocalFilesystemAdapter(
diff --git a/tests/Filesystem/FilesystemManagerTest.php b/tests/Filesystem/FilesystemManagerTest.php
index 92ba69b64f..3191023a61 100644
--- a/tests/Filesystem/FilesystemManagerTest.php
+++ b/tests/Filesystem/FilesystemManagerTest.php
@@ -321,6 +321,38 @@ public function testCanBuildOnDemandDisk(): void
]));
}
+ public function testCanBuildLocalDisksThatLazilyCreateTheirRootDirectory(): void
+ {
+ $filesystem = new FilesystemManager($this->getContainer());
+ $root = $this->tempDir . '/lazy-root';
+
+ $disk = $filesystem->build([
+ 'driver' => 'local',
+ 'root' => $root,
+ 'lazy_root_creation' => true,
+ ]);
+
+ $this->assertDirectoryDoesNotExist($root);
+
+ $disk->put('filename.txt', 'file content');
+
+ $this->assertDirectoryExists($root);
+ $this->assertSame('file content', $disk->get('filename.txt'));
+ }
+
+ public function testCanBuildLocalDisksThatEagerlyCreateTheirRootDirectory(): void
+ {
+ $filesystem = new FilesystemManager($this->getContainer());
+ $root = $this->tempDir . '/eager-root';
+
+ $filesystem->build([
+ 'driver' => 'local',
+ 'root' => $root,
+ ]);
+
+ $this->assertDirectoryExists($root);
+ }
+
public function testConfiguredOndemandDiskNameIsReserved(): void
{
$filesystem = new FilesystemManager($this->getContainer([
From f921b87669c96f5e61484acedc57ffedb9bbcd9d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 04/34] Allow all named scheduled tasks to run on one server
Port Laravel #61789 (d30a8ab2a1), facade update 9095997b35 and documentation from laravel/docs 226b0649c7.
Apply alwaysOnOneServer through events() and Hypervel's dueEventsAt path so both scheduler modes honor it. Unnamed callbacks remain excluded. Document boot-time ownership and reset the flag through the existing framework state cleanup.
Port the upstream default and enabled cases and extend the existing due-time and reset coverage. Scheduler and facade suites, formatting and full static analysis pass.
---
src/console/src/Scheduling/Schedule.php | 29 +++++++++++++++-
src/docs/scheduling.md | 13 +++++++
src/support/src/Facades/Schedule.php | 1 +
tests/Console/Scheduling/ScheduleTest.php | 42 +++++++++++++++++++++--
4 files changed, 81 insertions(+), 4 deletions(-)
diff --git a/src/console/src/Scheduling/Schedule.php b/src/console/src/Scheduling/Schedule.php
index 36b3ce15b1..740ba00759 100644
--- a/src/console/src/Scheduling/Schedule.php
+++ b/src/console/src/Scheduling/Schedule.php
@@ -107,6 +107,11 @@ class Schedule
*/
public static bool $interruptible = true;
+ /**
+ * Indicates if every event should only run on one server for each cron expression.
+ */
+ public static bool $alwaysOnOneServer = false;
+
/**
* Create a new schedule instance.
*
@@ -394,7 +399,7 @@ public function dueEvents(Application $app): Collection
*/
public function dueEventsAt(Application $app, DateTimeInterface $time): Collection
{
- return (new Collection($this->events))
+ return (new Collection($this->events()))
->filter(fn (Event $event) => $event->isDueAt($app, $time));
}
@@ -405,6 +410,16 @@ public function dueEventsAt(Application $app, DateTimeInterface $time): Collecti
*/
public function events(): array
{
+ if (static::$alwaysOnOneServer) {
+ foreach ($this->events as $event) {
+ if ($event instanceof CallbackEvent && $event->description === null) {
+ continue;
+ }
+
+ $event->onOneServer();
+ }
+ }
+
return $this->events;
}
@@ -497,6 +512,17 @@ public function hasBeenInterruptedSince(DateTimeInterface $time): bool
return is_numeric($interruptedAt) && (int) $interruptedAt >= Date::instance($time)->getTimestampMs();
}
+ /**
+ * Indicate that every event on the schedule should only run on one server for each cron expression.
+ *
+ * Boot-only. The flag persists for the worker lifetime and affects every
+ * schedule evaluated in the process.
+ */
+ public static function alwaysOnOneServer(bool $value = true): void
+ {
+ static::$alwaysOnOneServer = $value;
+ }
+
/**
* Flush all static state.
*/
@@ -504,6 +530,7 @@ public static function flushState(): void
{
static::$pausable = true;
static::$interruptible = true;
+ static::$alwaysOnOneServer = false;
static::flushMacros();
}
diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md
index e3b22f8d04..71eefc578a 100644
--- a/src/docs/scheduling.md
+++ b/src/docs/scheduling.md
@@ -392,6 +392,19 @@ Schedule::command('report:generate')
->onOneServer();
```
+To apply `onOneServer` to all scheduled tasks, you may call the `alwaysOnOneServer` method in the `boot` method of your application's `AppServiceProvider`:
+
+```php
+use Hypervel\Support\Facades\Schedule;
+
+public function boot(): void
+{
+ Schedule::alwaysOnOneServer();
+}
+```
+
+Scheduled closures without a [name](#naming-unique-jobs) are excluded and will continue to run on every server.
+
You may use the `useCache` method to override the configured cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks:
```php
diff --git a/src/support/src/Facades/Schedule.php b/src/support/src/Facades/Schedule.php
index 3b9e420c58..ca058b41f4 100644
--- a/src/support/src/Facades/Schedule.php
+++ b/src/support/src/Facades/Schedule.php
@@ -7,6 +7,7 @@
use Hypervel\Console\Scheduling\Schedule as ConsoleSchedule;
/**
+ * @method static void alwaysOnOneServer(bool $value = true)
* @method static \Hypervel\Console\Scheduling\CallbackEvent call(callable|array|string $callback, array $parameters = [])
* @method static \Hypervel\Console\Scheduling\Event command(\Symfony\Component\Console\Command\Command|string $command, array $parameters = [])
* @method static string compileArrayInput(string|int $key, array $value)
diff --git a/tests/Console/Scheduling/ScheduleTest.php b/tests/Console/Scheduling/ScheduleTest.php
index 9099640a0b..7f1097d0b8 100644
--- a/tests/Console/Scheduling/ScheduleTest.php
+++ b/tests/Console/Scheduling/ScheduleTest.php
@@ -28,6 +28,7 @@
use Mockery as m;
use Mockery\MockInterface;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
enum ScheduleTestQueueStringEnum: string
{
@@ -231,8 +232,11 @@ public function testItCanFilterEventsByEnvironments(): void
$this->assertSame('0 * * * *', $filteredEvents[2]->expression);
}
- public function testDueEventsAtUsesGivenTime(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDueEventsAtUsesGivenTime(bool $alwaysOnOneServer): void
{
+ Schedule::alwaysOnOneServer($alwaysOnOneServer);
$app = m::mock(ApplicationContract::class);
$app->shouldReceive('isDownForMaintenance')->andReturn(false);
$app->shouldReceive('environment')->andReturn('production');
@@ -244,7 +248,9 @@ public function testDueEventsAtUsesGivenTime(): void
$schedule->command('reports:generate')->dailyAt('13:00');
self::assertCount(0, $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 12:59:59')));
- self::assertCount(1, $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 13:00:00')));
+ $dueEvents = $schedule->dueEventsAt($app, CarbonImmutable::parse('2026-05-29 13:00:00'));
+ self::assertCount(1, $dueEvents);
+ self::assertSame($alwaysOnOneServer, $dueEvents->first()->onOneServer);
} finally {
CarbonImmutable::setTestNow();
}
@@ -544,6 +550,34 @@ public function testItCanAddAttributesToPendingEvents(): void
$this->assertSame([], $events[1]->attributes);
}
+ public function testEventsDoNotRunOnOneServerByDefault(): void
+ {
+ $schedule = new Schedule;
+
+ $schedule->command('inspire')->everyMinute();
+
+ $this->assertFalse($schedule->events()[0]->onOneServer);
+ }
+
+ public function testEveryEventMayRunOnOneServer(): void
+ {
+ Schedule::alwaysOnOneServer();
+
+ $schedule = new Schedule;
+
+ $schedule->command('inspire')->everyMinute();
+ $schedule->job(JobToTestWithSchedule::class)->daily();
+ $schedule->call(fn () => null)->name('prune')->hourly();
+ $schedule->call(fn () => null)->hourly();
+
+ [$command, $job, $closure, $unnamedClosure] = $schedule->events();
+
+ $this->assertTrue($command->onOneServer);
+ $this->assertTrue($job->onOneServer);
+ $this->assertTrue($closure->onOneServer);
+ $this->assertFalse($unnamedClosure->onOneServer);
+ }
+
public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks(): void
{
Schedule::withoutInterruptionPolling();
@@ -552,14 +586,16 @@ public function testWithoutInterruptionPollingDisablesPauseAndInterruptChecks():
$this->assertFalse(Schedule::$interruptible);
}
- public function testFlushStateResetsPauseAndInterruptChecks(): void
+ public function testFlushStateResetsSchedulerDefaults(): void
{
Schedule::withoutInterruptionPolling();
+ Schedule::alwaysOnOneServer();
Schedule::flushState();
$this->assertTrue(Schedule::$pausable);
$this->assertTrue(Schedule::$interruptible);
+ $this->assertFalse(Schedule::$alwaysOnOneServer);
}
}
From 9452b9250c0a1e70e8dcd51ce6f0f3d96ee77566 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 05/34] Verify HTTP pending requests release their default
throw callback
Port Laravel #61802 (fef7b05bc0). Hypervel already uses a static default throw callback, so the source needs no change.
Merge the upstream disabled-GC WeakReference regression with the existing standalone throwUnless case under the upstream test name. Keep the response alive during the release check and restore the prior garbage-collection state.
The complete HTTP client test file and formatting pass.
---
tests/Http/HttpClientTest.php | 55 +++++++++++++++++++----------------
1 file changed, 30 insertions(+), 25 deletions(-)
diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php
index c8673b6df0..3a145dcd36 100644
--- a/tests/Http/HttpClientTest.php
+++ b/tests/Http/HttpClientTest.php
@@ -4472,6 +4472,36 @@ public function testTransferStatsArePresentWhenFakingTheRequestUsingAPromiseResp
$this->assertSame('https://example.com', (string) $effectiveUri);
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testPendingRequestIsReleasedWithoutGarbageCollectionAfterThrow(bool $withoutFactory): void
+ {
+ $body = str_repeat('x', 1024);
+ $this->factory->fake(['https://example.com' => $this->factory::response($body)]);
+ $garbageCollectionEnabled = gc_enabled();
+ gc_disable();
+
+ try {
+ $pendingRequest = $withoutFactory
+ ? (new PendingRequest)
+ ->throwUnless(static fn (Response $response): bool => false)
+ ->stub(static fn (): PromiseInterface => Factory::response($body))
+ : $this->factory->throw();
+ $response = $pendingRequest->get('https://example.com');
+
+ $this->assertSame($body, $response->body());
+
+ $reference = WeakReference::create($pendingRequest);
+ unset($pendingRequest);
+
+ $this->assertNull($reference->get());
+ } finally {
+ if ($garbageCollectionEnabled) {
+ gc_enable();
+ }
+ }
+ }
+
public function testTransferStatsArePresentWhenFakingAnAsyncRequestUsingAPromiseResponse(): void
{
$this->factory->fake(['https://example.com' => $this->factory::response()]);
@@ -6012,31 +6042,6 @@ public function testTooManyRedirectsWithFakedRedirectChain(): void
$this->factory->maxRedirects(1)->get('https://1.example.com');
}
- public function testPendingRequestsAreFreedOnceUnset(): void
- {
- $garbageCollectionEnabled = gc_enabled();
- gc_disable();
-
- try {
- $request = (new PendingRequest)
- ->throwUnless(static fn (Response $response): bool => false)
- ->stub(static fn (): PromiseInterface => Factory::response('ok'));
-
- $reference = WeakReference::create($request);
- $response = $request->post('http://localhost/memory-test');
-
- $this->assertSame('ok', $response->body());
-
- unset($request, $response);
-
- $this->assertNull($reference->get());
- } finally {
- if ($garbageCollectionEnabled) {
- gc_enable();
- }
- }
- }
-
public function testRequestExceptionIsNotThrownIfThePendingRequestIsSetToThrowOnFailureButTheResponseIsSuccessful(): void
{
$this->factory->fake([
From 4d0f3f35139e3f414fe7357c478f2cd9b6aca0c8 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:31 +0000
Subject: [PATCH 06/34] Reconcile QUERY route coverage
Reconcile Laravel #61797 (3bb5d60845). QUERY registration, routing, facade, command display, request-forgery protection and cached routes are already supported.
Port the controller registrar case and explicit dispatch, JSON-body and any-route cases, retaining same-path GET and POST coexistence coverage. Remove local ResourceRegistrar resets already owned by the framework test subscriber.
Both changed files and affected routing, request-forgery and cached-route suites pass. The existing native HTTP-server QUERY test remains gated on the required Swoole support.
---
tests/Routing/RouteRegistrarTest.php | 9 +++++
tests/Routing/RoutingRouteTest.php | 53 ++++++++++++++++++++--------
2 files changed, 48 insertions(+), 14 deletions(-)
diff --git a/tests/Routing/RouteRegistrarTest.php b/tests/Routing/RouteRegistrarTest.php
index 883972bc48..c95eb64724 100644
--- a/tests/Routing/RouteRegistrarTest.php
+++ b/tests/Routing/RouteRegistrarTest.php
@@ -230,6 +230,15 @@ public function testCanRegisterQueryRouteWithClosureAction(): void
$this->seeMiddleware('query-middleware');
}
+ public function testCanRegisterQueryRouteWithControllerAction(): void
+ {
+ $this->router->middleware('query-middleware')
+ ->query('users', RouteRegistrarControllerStub::class . '@index');
+
+ $this->seeResponse('controller', Request::create('users', 'QUERY'));
+ $this->seeMiddleware('query-middleware');
+ }
+
public function testCanRegisterAnyRouteWithClosureAction(): void
{
$this->router->middleware('test-middleware')->any('users', function () {
diff --git a/tests/Routing/RoutingRouteTest.php b/tests/Routing/RoutingRouteTest.php
index ef9d40bb05..a11ad345f0 100644
--- a/tests/Routing/RoutingRouteTest.php
+++ b/tests/Routing/RoutingRouteTest.php
@@ -53,15 +53,6 @@
class RoutingRouteTest extends TestCase
{
- protected function tearDown(): void
- {
- ResourceRegistrar::verbs(['create' => 'create', 'edit' => 'edit']);
- ResourceRegistrar::setParameters();
- ResourceRegistrar::singularParameters();
-
- parent::tearDown();
- }
-
public function testBasicDispatchingOfRoutes(): void
{
$router = $this->getRouter();
@@ -99,12 +90,8 @@ public function testBasicDispatchingOfRoutes(): void
$router->post('foo/bar', function () {
return 'post hello';
});
- $router->query('foo/bar', function (): string {
- return 'query hello';
- });
$this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'GET'))->getContent());
$this->assertSame('post hello', $router->dispatch(Request::create('foo/bar', 'POST'))->getContent());
- $this->assertSame('query hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent());
$router = $this->getRouter();
$router->get('foo/{bar}', function ($name) {
@@ -174,7 +161,6 @@ public function testBasicDispatchingOfRoutes(): void
return 'hello';
});
$this->assertEmpty($router->dispatch(Request::create('foo/bar', 'HEAD'))->getContent());
- $this->assertSame('hello', $router->dispatch(Request::create('foo/bar', 'QUERY'))->getContent());
$router = $this->getRouter();
$router->get('foo/bar', function () {
@@ -559,6 +545,45 @@ public function testNullValuesCanBeInjectedIntoRoutes(): void
$router->dispatch(Request::create('foo/bar/baz', 'GET'))->getContent();
}
+ public function testQueryRoutesAreDispatched(): void
+ {
+ $router = $this->getRouter();
+ $router->query('products/search', function () use ($router): mixed {
+ return $router->getCurrentRequest()->input('name');
+ });
+ $router->get('products/search', fn (): string => 'get');
+ $router->post('products/search', fn (): string => 'post');
+
+ $request = Request::create('products/search', 'QUERY', ['name' => 'taylor']);
+
+ $this->assertSame('taylor', $router->dispatch($request)->getContent());
+ $this->assertSame(['QUERY'], $router->getRoutes()->getRoutes()[0]->methods());
+ $this->assertSame('get', $router->dispatch(Request::create('products/search', 'GET'))->getContent());
+ $this->assertSame('post', $router->dispatch(Request::create('products/search', 'POST'))->getContent());
+ }
+
+ public function testQueryRoutesReceiveJsonBody(): void
+ {
+ $router = $this->getRouter();
+ $router->query('products/search', function () use ($router): mixed {
+ return $router->getCurrentRequest()->input('filters.name');
+ });
+
+ $request = Request::create('products/search', 'QUERY', [], [], [], ['CONTENT_TYPE' => 'application/json'], json_encode(['filters' => ['name' => 'taylor']]));
+
+ $this->assertSame('taylor', $router->dispatch($request)->getContent());
+ }
+
+ public function testAnyRoutesRespondToQueryRequests(): void
+ {
+ $router = $this->getRouter();
+ $router->any('foo', function (): string {
+ return 'hello';
+ });
+
+ $this->assertSame('hello', $router->dispatch(Request::create('foo', 'QUERY'))->getContent());
+ }
+
public function testOptionsResponsesAreGeneratedByDefault(): void
{
$router = $this->getRouter();
From 4751ab5ab4795cce71a7385dd1790f51c6858d3f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 07/34] Reconcile fractional duration helper tests
Reconcile Laravel #61825 (2b44ae85de) under the upstream test filename. Port the integer and fractional-unit assertions while retaining tiny, negative, rounded and calendar-day coverage.
Keep Hypervel's numeric construction: CarbonInterval::make() parses text and rejects values PHP renders in scientific notation. Explain that reason at the helper rather than losing it in commit history.
The complete duration suite and formatting pass; runtime behavior is unchanged.
---
src/support/src/functions.php | 1 +
...st.php => SupportDurationFunctionsTest.php} | 18 +++++++++++++++++-
2 files changed, 18 insertions(+), 1 deletion(-)
rename tests/Support/{SupportIntervalFunctionsTest.php => SupportDurationFunctionsTest.php} (83%)
diff --git a/src/support/src/functions.php b/src/support/src/functions.php
index 81c8dceb22..97db3fd051 100644
--- a/src/support/src/functions.php
+++ b/src/support/src/functions.php
@@ -88,6 +88,7 @@ function milliseconds(int|float $milliseconds): CarbonInterval
*/
function seconds(int|float $seconds): CarbonInterval
{
+ // Build durations numerically: CarbonInterval::make() rejects floats printed in scientific notation.
if (is_int($seconds)) {
return CarbonInterval::seconds($seconds);
}
diff --git a/tests/Support/SupportIntervalFunctionsTest.php b/tests/Support/SupportDurationFunctionsTest.php
similarity index 83%
rename from tests/Support/SupportIntervalFunctionsTest.php
rename to tests/Support/SupportDurationFunctionsTest.php
index 058a6584cd..2d3da43cd4 100644
--- a/tests/Support/SupportIntervalFunctionsTest.php
+++ b/tests/Support/SupportDurationFunctionsTest.php
@@ -13,8 +13,24 @@
use function Hypervel\Support\minutes;
use function Hypervel\Support\seconds;
-class SupportIntervalFunctionsTest extends TestCase
+class SupportDurationFunctionsTest extends TestCase
{
+ public function testDurationFunctionsAcceptIntegers(): void
+ {
+ $this->assertEquals(90, seconds(90)->totalSeconds);
+ $this->assertEquals(300, minutes(5)->totalSeconds);
+ $this->assertEquals(7200, hours(2)->totalSeconds);
+ $this->assertEquals(172800, days(2)->totalSeconds);
+ }
+
+ public function testDurationFunctionsKeepFractionalValues(): void
+ {
+ $this->assertEquals(1400, seconds(1.4)->totalMilliseconds);
+ $this->assertEquals(90, minutes(1.5)->totalSeconds);
+ $this->assertEquals(1800, hours(0.5)->totalSeconds);
+ $this->assertEquals(36, days(1.5)->totalHours);
+ }
+
#[DataProvider('intervalProvider')]
public function testIntervalsPreserveWholeAndFractionalUnits(
callable $helper,
From 8dbd7c7b36796661673b44f62109348c043c9ac4 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 08/34] Release unique job and listener locks after scoped
queue fakes
Port Laravel #61824 (e9d1518634) and #62025 (957afcdc93). Scoped queue fakes release the unique locks they acquire, and fake enqueueing also records unique queued listeners. Skip cache resolution when no unique jobs were recorded.
Restore the original queue manager in a nested finally even if lock release fails. The cleanup exception still propagates; code catching it must not continue dispatching into a fake outside its intended scope.
Port all upstream cases and listener fixtures, plus the cleanup-failure restoration regression for both scoped helpers. Complete affected queue-fake and event suites, formatting and full static analysis pass.
---
src/support/src/Facades/Queue.php | 16 ++-
src/support/src/Testing/Fakes/QueueFake.php | 7 +-
tests/Integration/Queue/UniqueJobTest.php | 103 ++++++++++++++++++++
3 files changed, 121 insertions(+), 5 deletions(-)
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 42e1437bb9..db520ad686 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -153,12 +153,16 @@ public static function fakeFor(callable $callable, array $jobsToFake = []): mixe
{
$originalQueueManager = static::getFacadeRoot();
- static::fake($jobsToFake);
+ $fake = static::fake($jobsToFake);
try {
return $callable();
} finally {
- static::swap($originalQueueManager);
+ try {
+ $fake->releaseUniqueJobLocks();
+ } finally {
+ static::swap($originalQueueManager);
+ }
}
}
@@ -169,12 +173,16 @@ public static function fakeExceptFor(callable $callable, array $jobsToAllow = []
{
$originalQueueManager = static::getFacadeRoot();
- static::fakeExcept($jobsToAllow);
+ $fake = static::fakeExcept($jobsToAllow);
try {
return $callable();
} finally {
- static::swap($originalQueueManager);
+ try {
+ $fake->releaseUniqueJobLocks();
+ } finally {
+ static::swap($originalQueueManager);
+ }
}
}
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index 00b42b714b..4254dbf277 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -621,7 +621,8 @@ protected function enqueueUsing(
'delay' => $delay,
];
- if ($job instanceof ShouldBeUnique) {
+ if ($job instanceof ShouldBeUnique
+ || ($job instanceof CallQueuedListener && $job->shouldBeUnique())) {
$this->uniqueJobs[] = $job;
}
@@ -812,6 +813,10 @@ protected function serializeAndRestoreJob(mixed $job): mixed
*/
public function releaseUniqueJobLocks(): void
{
+ if (empty($this->uniqueJobs)) {
+ return;
+ }
+
$lock = new UniqueLock($this->app->make(Cache::class));
foreach ($this->uniqueJobs as $job) {
diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php
index 7529f6a152..bca3cf79ca 100644
--- a/tests/Integration/Queue/UniqueJobTest.php
+++ b/tests/Integration/Queue/UniqueJobTest.php
@@ -28,7 +28,10 @@
use Hypervel\Testbench\Attributes\WithMigration;
use Hypervel\Testbench\Factories\UserFactory;
use Hypervel\Tests\Integration\Queue\QueueTestCase;
+use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
+use RuntimeException;
#[WithMigration]
#[WithMigration('cache')]
@@ -317,6 +320,84 @@ public function testQueueFakeReleasesUniqueJobLocksBetweenFakes(): void
Queue::assertPushed(UniqueTestJob::class);
}
+ public function testQueueFakeForReleasesUniqueJobLocks(): void
+ {
+ Queue::fakeFor(function (): void {
+ UniqueTestJob::dispatch();
+ Queue::assertPushed(UniqueTestJob::class);
+ });
+
+ $this->assertTrue(
+ $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()
+ );
+ }
+
+ public function testQueueFakeReleasesUniqueListenerLocksBetweenFakes(): void
+ {
+ Event::listen(UniqueTestEvent::class, UniqueTestListener::class);
+
+ Queue::fake();
+
+ event(new UniqueTestEvent);
+ $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class));
+
+ Queue::fake();
+
+ event(new UniqueTestEvent);
+ $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class));
+ }
+
+ public function testQueueFakeForReleasesUniqueListenerLocks(): void
+ {
+ Event::listen(UniqueTestEvent::class, UniqueTestListener::class);
+
+ Queue::fakeFor(function (): void {
+ event(new UniqueTestEvent);
+ $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class));
+ });
+
+ Queue::fake();
+
+ event(new UniqueTestEvent);
+ $this->assertCount(1, Queue::listenersPushed(UniqueTestListener::class));
+ }
+
+ public function testQueueFakeExceptForReleasesUniqueJobLocks(): void
+ {
+ Queue::fakeExceptFor(function (): void {
+ UniqueTestJob::dispatch();
+ Queue::assertPushed(UniqueTestJob::class);
+ });
+
+ $this->assertTrue(
+ $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()
+ );
+ }
+
+ #[TestWith(['fakeFor'])]
+ #[TestWith(['fakeExceptFor'])]
+ public function testScopedQueueFakeRestoresManagerWhenLockCleanupThrows(string $method): void
+ {
+ $manager = Queue::getFacadeRoot();
+ $failure = new RuntimeException('Cache unavailable during lock cleanup.');
+ $caught = null;
+
+ try {
+ Queue::{$method}(function () use ($failure): void {
+ UniqueTestJob::dispatch();
+
+ $cache = m::mock(Cache::class);
+ $cache->shouldReceive('getStore')->once()->andThrow($failure);
+ $this->app->instance(Cache::class, $cache);
+ });
+ } catch (RuntimeException $exception) {
+ $caught = $exception;
+ }
+
+ $this->assertSame($failure, $caught);
+ $this->assertSame($manager, Queue::getFacadeRoot());
+ }
+
public function testQueueFakePreservesUniqueJobLockWithinTest(): void
{
Queue::fake();
@@ -598,6 +679,28 @@ public function handle(): void
}
}
+class UniqueTestEvent
+{
+}
+
+class UniqueTestListener implements ShouldQueue, ShouldBeUnique
+{
+ /**
+ * Get the listener's unique ID.
+ */
+ public function uniqueId(): string
+ {
+ return 'unique-id-1';
+ }
+
+ /**
+ * Handle the event.
+ */
+ public function handle(UniqueTestEvent $event): void
+ {
+ }
+}
+
class NestedUniqueParentJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable;
From 7c9bcb60054dcb8bca2048bee1c633088685f550 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 09/34] Preserve pending attributes when force-creating related
models
Port Laravel #61823 (6fbda89877). Merge withAttributes values into forceCreate before explicit attributes, while retaining relation-owned foreign keys and morph types.
Port the persisted model, has-many and morph-many cases with their schema fixture and complete assertions. All reproduce missing pending attributes before the correction.
Complete changed files, the database suite, formatting and full static analysis pass.
---
src/database/src/Eloquent/Builder.php | 2 +-
.../src/Eloquent/Relations/HasOneOrMany.php | 2 +
.../src/Eloquent/Relations/MorphOneOrMany.php | 2 +
...EloquentHasOneOrManyWithAttributesTest.php | 48 +++++++++++++++++++
.../DatabaseEloquentWithAttributesTest.php | 17 +++++++
5 files changed, 70 insertions(+), 1 deletion(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 42925cdab6..17324d11e4 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -1216,7 +1216,7 @@ public function createQuietly(array $attributes = []): Model
public function forceCreate(array $attributes): Model
{
return $this->model::unguarded(function () use ($attributes) {
- return $this->newModelInstance()->create($attributes);
+ return $this->newModelInstance()->create(array_merge($this->pendingAttributes, $attributes));
});
}
diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php
index 10da867cdc..bc5820b74c 100755
--- a/src/database/src/Eloquent/Relations/HasOneOrMany.php
+++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php
@@ -393,6 +393,8 @@ public function createQuietly(array $attributes = []): Model
*/
public function forceCreate(array $attributes = []): Model
{
+ $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes);
+
$attributes[$this->getForeignKeyName()] = $this->getParentKey();
return $this->applyInverseRelationToModel($this->related->forceCreate($attributes));
diff --git a/src/database/src/Eloquent/Relations/MorphOneOrMany.php b/src/database/src/Eloquent/Relations/MorphOneOrMany.php
index 3065f76a6a..49c4caa32e 100644
--- a/src/database/src/Eloquent/Relations/MorphOneOrMany.php
+++ b/src/database/src/Eloquent/Relations/MorphOneOrMany.php
@@ -70,6 +70,8 @@ public function addEagerConstraints(array $models): void
*/
public function forceCreate(array $attributes = []): Model
{
+ $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes);
+
$attributes[$this->getForeignKeyName()] = $this->getParentKey();
$attributes[$this->getMorphType()] = $this->morphClass;
diff --git a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
index d29495ae46..1e83159ea8 100755
--- a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
+++ b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
@@ -6,6 +6,7 @@
use Hypervel\Database\Capsule\Manager as DB;
use Hypervel\Database\Eloquent\Model;
+use Hypervel\Database\Schema\Blueprint;
use Hypervel\Tests\TestCase;
class DatabaseEloquentHasOneOrManyWithAttributesTest extends TestCase
@@ -288,6 +289,53 @@ public function testHasManyAddsCastedAttributes(): void
$this->assertSame($parentId, $relatedModel->parent_id);
$this->assertTrue($relatedModel->is_admin);
}
+
+ public function testHasManyForceCreateAddsAttributes(): void
+ {
+ $this->createTable();
+
+ $parent = new RelatedWithAttributesModel;
+ $parent->id = 123;
+
+ $relatedModel = $parent
+ ->hasMany(RelatedWithAttributesModel::class, 'parent_id')
+ ->withAttributes(['is_admin' => true])
+ ->forceCreate();
+
+ $this->assertSame(123, $relatedModel->parent_id);
+ $this->assertTrue($relatedModel->fresh()->is_admin);
+ }
+
+ public function testMorphManyForceCreateAddsAttributes(): void
+ {
+ $this->createTable();
+
+ $parent = new RelatedWithAttributesModel;
+ $parent->id = 123;
+
+ $relatedModel = $parent
+ ->morphMany(RelatedWithAttributesModel::class, 'relatable')
+ ->withAttributes(['is_admin' => true])
+ ->forceCreate();
+
+ $this->assertSame(123, $relatedModel->relatable_id);
+ $this->assertSame($parent::class, $relatedModel->relatable_type);
+ $this->assertTrue($relatedModel->fresh()->is_admin);
+ }
+
+ /**
+ * Create the table used to verify persisted relationship attributes.
+ */
+ protected function createTable(): void
+ {
+ DB::schema()->create((new RelatedWithAttributesModel)->getTable(), function (Blueprint $table): void {
+ $table->id();
+ $table->unsignedBigInteger('parent_id')->nullable();
+ $table->nullableMorphs('relatable');
+ $table->boolean('is_admin')->default(false);
+ $table->timestamps();
+ });
+ }
}
class RelatedWithAttributesModel extends Model
diff --git a/tests/Database/DatabaseEloquentWithAttributesTest.php b/tests/Database/DatabaseEloquentWithAttributesTest.php
index 9f1e52fc4c..d6080dca8b 100755
--- a/tests/Database/DatabaseEloquentWithAttributesTest.php
+++ b/tests/Database/DatabaseEloquentWithAttributesTest.php
@@ -112,6 +112,23 @@ public function testAddsWithCastsViaDb(): void
$this->assertSame(WithAttributesEnum::Internal, $model->type);
}
+ public function testForceCreateAddsAttributesViaDb(): void
+ {
+ $this->bootTable();
+
+ WithAttributesModel::query()
+ ->withAttributes([
+ 'is_admin' => 1,
+ 'type' => WithAttributesEnum::Internal,
+ ])
+ ->forceCreate(['first_name' => 'FIRST', 'last_name' => 'LAST']);
+
+ $model = WithAttributesModel::first();
+
+ $this->assertTrue($model->is_admin);
+ $this->assertSame(WithAttributesEnum::Internal, $model->type);
+ }
+
protected function bootTable(): void
{
$this->schema()->create((new WithAttributesModel)->getTable(), function ($table) {
From 7e37ce25151bb862f95522623083a15009cc83b9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 10/34] Correct one-of-many and polymorphic relationship
comparisons
Port Laravel #61818 (d6ba3b0e77), #61827 (bdd17ec8f3) and #62040 (840b2a96a6). Build intermediate joins before one-of-many aggregates, preserve the intermediate alias for self-referencing queries and compare through models through the actual relationship query.
Preserve Hypervel's write-connection identity normalization. Clone one-of-many comparison queries so repeated is() calls do not retain previous key constraints. MorphOne comparisons also require the matching parent type, preventing equal keys belonging to different parent classes from matching.
Port all upstream SQL, result and count assertions. Extend existing comparison coverage for repeated calls and wrong morph types; seed the matching morph type in the shared comparison fixture without changing its expected results.
Affected relationship suites pass, including polymorphic through queries and connection-role coverage. Formatting and full static analysis pass.
---
.../Concerns/ComparesRelatedModels.php | 2 +-
.../src/Eloquent/Relations/HasOneThrough.php | 43 ++++++++++++++++++-
.../src/Eloquent/Relations/MorphOne.php | 13 +++++-
...abaseEloquentComparesRelatedModelsTest.php | 4 ++
.../DatabaseEloquentHasOneOfManyTest.php | 7 ++-
...seEloquentHasOneThroughIntegrationTest.php | 15 +++++++
...atabaseEloquentHasOneThroughOfManyTest.php | 19 ++++++++
.../Database/EloquentHasManyThroughTest.php | 24 +++++++++++
.../Database/EloquentMorphOneIsTest.php | 10 +++++
9 files changed, 132 insertions(+), 5 deletions(-)
diff --git a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
index 091dae214d..720604f429 100644
--- a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
+++ b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
@@ -21,7 +21,7 @@ public function is(?Model $model): bool
&& ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName());
if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) {
- return $this->query
+ return (clone $this->query)
->whereKey($model->getKey())
->exists();
}
diff --git a/src/database/src/Eloquent/Relations/HasOneThrough.php b/src/database/src/Eloquent/Relations/HasOneThrough.php
index df6a809185..30fd44476a 100644
--- a/src/database/src/Eloquent/Relations/HasOneThrough.php
+++ b/src/database/src/Eloquent/Relations/HasOneThrough.php
@@ -5,6 +5,7 @@
namespace Hypervel\Database\Eloquent\Relations;
use Hypervel\Contracts\Database\Eloquent\SupportsPartialRelations;
+use Hypervel\Database\ConnectionName;
use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Collection as EloquentCollection;
use Hypervel\Database\Eloquent\Model;
@@ -13,6 +14,7 @@
use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
use Hypervel\Database\Eloquent\Relations\Concerns\SupportsDefaultModels;
use Hypervel\Database\Query\JoinClause;
+use Hypervel\Support\Str;
/**
* @template TRelatedModel of Model
@@ -71,11 +73,38 @@ public function match(array $models, EloquentCollection $results, string $relati
public function getRelationExistenceQuery(Builder $query, Builder $parentQuery, mixed $columns = ['*']): Builder
{
+ $query = parent::getRelationExistenceQuery($query, $parentQuery, $columns);
+
if ($this->isOneOfMany()) {
$this->mergeOneOfManyJoinsTo($query);
+
+ if ($parentQuery->getQuery()->from !== $query->getQuery()->from
+ && $parentQuery->getQuery()->from === $this->throughParent->getTable()) {
+ $this->qualifyOneOfManyJoinWithThroughParentAlias($query);
+ }
}
- return parent::getRelationExistenceQuery($query, $parentQuery, $columns);
+ return $query;
+ }
+
+ /**
+ * Point the one-of-many join at the aliased intermediate table of a self-referencing existence query.
+ *
+ * @param Builder $query
+ */
+ protected function qualifyOneOfManyJoinWithThroughParentAlias(Builder $query): void
+ {
+ $joins = $query->getQuery()->joins;
+
+ $alias = Str::afterLast($joins[0]->table, ' as ');
+
+ $join = last($joins);
+
+ foreach ($join->wheres as $key => $where) {
+ if (($where['second'] ?? null) === $this->getQualifiedFirstKeyName()) {
+ $join->wheres[$key]['second'] = $alias . '.' . $this->firstKey;
+ }
+ }
}
public function addOneOfManySubQueryConstraints(Builder $query, ?string $column = null, ?string $aggregate = null): void
@@ -114,6 +143,18 @@ public function newRelatedInstanceFor(Model $parent): Model
return $this->related->newInstance();
}
+ /**
+ * Determine if the model is the related instance of the relationship.
+ */
+ public function is(?Model $model): bool
+ {
+ return ! is_null($model)
+ && ! is_null($this->getParentKey())
+ && $this->related->getTable() === $model->getTable()
+ && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName())
+ && (clone $this->query)->whereKey($model->getKey())->exists();
+ }
+
protected function getRelatedKeyFrom(Model $model): mixed
{
return $model->getAttribute($this->getForeignKeyName());
diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php
index 351cf76ff0..24eec4e8d7 100644
--- a/src/database/src/Eloquent/Relations/MorphOne.php
+++ b/src/database/src/Eloquent/Relations/MorphOne.php
@@ -22,7 +22,9 @@
class MorphOne extends MorphOneOrMany implements SupportsPartialRelations
{
use CanBeOneOfMany;
- use ComparesRelatedModels;
+ use ComparesRelatedModels {
+ is as protected isRelatedModel;
+ }
use SupportsDefaultModels;
public function getResults()
@@ -101,6 +103,15 @@ public function newRelatedInstanceFor(Model $parent): Model
});
}
+ /**
+ * Determine if the model is the related instance of the relationship.
+ */
+ public function is(?Model $model): bool
+ {
+ return $model?->getAttribute($this->getMorphType()) === $this->morphClass
+ && $this->isRelatedModel($model);
+ }
+
/**
* Get the value of the model's foreign key.
*
diff --git a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
index a3e45ef991..81060d9eb3 100644
--- a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
+++ b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
@@ -48,6 +48,10 @@ public function testIsComparesKeysTableAndConnection(string $type, mixed $parent
->setConnection($connection)
->forceFill([$relatedKeyAttribute => $relatedKey]);
+ if ($relation instanceof MorphOne) {
+ $model->setAttribute($relation->getMorphType(), $relation->getMorphClass());
+ }
+
$this->assertSame($expected, $relation->is($model));
$this->assertSame(! $expected, $relation->isNot($model));
}
diff --git a/tests/Database/DatabaseEloquentHasOneOfManyTest.php b/tests/Database/DatabaseEloquentHasOneOfManyTest.php
index fbb0c9d284..a8ef664b6a 100755
--- a/tests/Database/DatabaseEloquentHasOneOfManyTest.php
+++ b/tests/Database/DatabaseEloquentHasOneOfManyTest.php
@@ -324,8 +324,11 @@ public function testIsMethod(): void
$login1 = $user->latest_login()->create();
$login2 = $user->latest_login()->create();
- $this->assertFalse($user->latest_login()->is($login1));
- $this->assertTrue($user->latest_login()->is($login2));
+ $relation = $user->latest_login();
+
+ $this->assertFalse($relation->is($login1));
+ $this->assertTrue($relation->is($login2));
+ $this->assertSame($login2->id, $relation->first()->id);
}
public function testIsNotMethod(): void
diff --git a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php
index c97fdaadec..0f193f2a88 100644
--- a/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentHasOneThroughIntegrationTest.php
@@ -82,6 +82,21 @@ public function testItLoadsAHasOneThroughRelationWithCustomKeys(): void
$this->assertSame('A title', $contract->title);
}
+ public function testIsMethod(): void
+ {
+ $otherPosition = Position::create(['id' => 1, 'name' => 'Engineer', 'shortname' => 'eng']);
+ $position = Position::create(['id' => 2, 'name' => 'President', 'shortname' => 'ps']);
+ $user = $position->user()->create(['id' => 1, 'email' => 'taylorotwell@gmail.com', 'position_short' => 'ps']);
+ $otherUser = $otherPosition->user()->create(['id' => 2, 'email' => 'abigailotwell@gmail.com', 'position_short' => 'eng']);
+ $contract = $user->contract()->create(['title' => 'A title', 'body' => 'A body', 'email' => 'taylorotwell@gmail.com']);
+ $otherContract = $otherUser->contract()->create(['title' => 'Another title', 'body' => 'Another body', 'email' => 'abigailotwell@gmail.com']);
+
+ $this->assertTrue($position->contract()->is($contract));
+ $this->assertFalse($position->contract()->is($otherContract));
+ $this->assertTrue($otherPosition->contract()->isNot($contract));
+ $this->assertFalse($otherPosition->contract()->isNot($otherContract));
+ }
+
public function testItLoadsADefaultHasOneThroughRelation(): void
{
$this->migrateDefault();
diff --git a/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php b/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php
index 23ea857e34..ef16c5061d 100755
--- a/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php
+++ b/tests/Database/DatabaseEloquentHasOneThroughOfManyTest.php
@@ -107,6 +107,12 @@ public function testCorrectLatestOfManyQuery(): void
$this->assertSame('select "logins".* from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" where "intermediates"."user_id" = ? group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "intermediates"."user_id" = ?', $relation->getQuery()->toSql());
}
+ public function testCorrectLatestOfManyExistenceQuery(): void
+ {
+ $query = User::has('latest_login');
+ $this->assertSame('select * from "users" where exists (select * from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" inner join (select MAX("logins"."id") as "id_aggregate", "intermediates"."user_id" from "logins" inner join "intermediates" on "intermediates"."id" = "logins"."intermediate_id" group by "intermediates"."user_id") as "latest_login" on "latest_login"."id_aggregate" = "logins"."id" and "latest_login"."user_id" = "intermediates"."user_id" where "users"."id" = "intermediates"."user_id")', $query->toSql());
+ }
+
public function testEagerLoadingAppliesConstraintsToInnerJoinSubQuery(): void
{
$user = User::create();
@@ -333,6 +339,19 @@ public function testIsMethod(): void
$this->assertTrue($user->latest_login()->is($login2));
}
+ public function testIsMethodComparesTheFarParent(): void
+ {
+ $otherUser = User::create();
+ $user = User::create();
+ $login = $user->intermediates()->create()->logins()->create();
+ $otherLogin = $otherUser->intermediates()->create()->logins()->create();
+
+ $this->assertTrue($user->latest_login()->is($login));
+ $this->assertFalse($user->latest_login()->is($otherLogin));
+ $this->assertTrue($otherUser->latest_login()->is($otherLogin));
+ $this->assertFalse($otherUser->latest_login()->is($login));
+ }
+
public function testIsNotMethod(): void
{
$user = User::factory()->hasIntermediates(2)->create();
diff --git a/tests/Integration/Database/EloquentHasManyThroughTest.php b/tests/Integration/Database/EloquentHasManyThroughTest.php
index d0792a196b..bf452d1b6f 100644
--- a/tests/Integration/Database/EloquentHasManyThroughTest.php
+++ b/tests/Integration/Database/EloquentHasManyThroughTest.php
@@ -154,6 +154,22 @@ public function testHasSameParentAndThroughParentTable(): void
$this->assertEquals([1], $categories->pluck('id')->all());
}
+ public function testHasOneOfManySameParentAndThroughParentTable(): void
+ {
+ Category::create();
+ Category::create();
+ Category::create(['parent_id' => 1]);
+ Category::create(['parent_id' => 2]);
+
+ $oldest = Product::create(['category_id' => 3]);
+ $latest = Product::create(['category_id' => 3]);
+
+ $this->assertEquals([1], Category::has('latestSubProduct')->pluck('id')->all());
+ $this->assertEquals([1], Category::whereHas('latestSubProduct', fn ($query) => $query->whereKey($latest->id))->pluck('id')->all());
+ $this->assertEquals([], Category::whereHas('latestSubProduct', fn ($query) => $query->whereKey($oldest->id))->pluck('id')->all());
+ $this->assertEquals([1 => 1, 2 => 0, 3 => 0, 4 => 0], Category::withCount('latestSubProduct')->pluck('latest_sub_product_count', 'id')->all());
+ }
+
public function testFirstOrNewOnMissingRecord(): void
{
$taylor = User::create(['name' => 'Taylor', 'slug' => 'taylor']);
@@ -599,6 +615,14 @@ public function subProducts(): HasManyThrough
{
return $this->hasManyThrough(Product::class, self::class, 'parent_id');
}
+
+ /**
+ * Get the latest product in the category's child categories.
+ */
+ public function latestSubProduct(): HasOneThrough
+ {
+ return $this->hasOneThrough(Product::class, self::class, 'parent_id')->latestOfMany();
+ }
}
class Product extends Model
diff --git a/tests/Integration/Database/EloquentMorphOneIsTest.php b/tests/Integration/Database/EloquentMorphOneIsTest.php
index 37997b51af..7d344ae0bc 100644
--- a/tests/Integration/Database/EloquentMorphOneIsTest.php
+++ b/tests/Integration/Database/EloquentMorphOneIsTest.php
@@ -82,6 +82,16 @@ public function testChildIsNotModelWithAnotherTable(): void
$this->assertTrue($parent->attachment()->isNot($child));
}
+ public function testChildIsNotModelOfAnotherParentType(): void
+ {
+ $parent = Post::first();
+ $child = Attachment::first();
+ $child->attachable_type = 'another-type';
+
+ $this->assertFalse($parent->attachment()->is($child));
+ $this->assertTrue($parent->attachment()->isNot($child));
+ }
+
public function testChildIsNotModelWithAnotherConnection(): void
{
$parent = Post::first();
From bd4b2d80a027a617c211b56f31759fee0fe263e2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 11/34] Cover null passwords in session cookie hashing
Reconcile Laravel #61817 (05399f6c34). Hypervel already accepts a nullable password and hashes null as the empty string; its generated facade matches.
Port the upstream deprecation capture and empty-password equality assertions, restoring the error handler in finally.
The complete authentication guard test file and formatting pass.
---
tests/Auth/AuthGuardTest.php | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php
index 33725591d9..0ea33f55d7 100755
--- a/tests/Auth/AuthGuardTest.php
+++ b/tests/Auth/AuthGuardTest.php
@@ -836,6 +836,27 @@ public static function invalidRememberCookieHashes(): array
];
}
+ public function testHashPasswordForCookieAcceptsNullPassword(): void
+ {
+ $guard = $this->getGuard();
+ $deprecations = [];
+
+ set_error_handler(function (int $level, string $message) use (&$deprecations): bool {
+ $deprecations[] = $message;
+
+ return true;
+ }, E_DEPRECATED);
+
+ try {
+ $hash = $guard->hashPasswordForCookie(null);
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertSame([], $deprecations);
+ $this->assertSame($guard->hashPasswordForCookie(''), $hash);
+ }
+
public function testLoginOnceSetsUser(): void
{
[$guard, $provider, $session] = $this->getRealGuard();
From 4e569a4d360bccc64d74f7331868cc802424ebcc Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 12/34] Preserve raw binary cursors when paging cast model keys
Port Laravel #61816 (e2c81f5f5c) and #62086 (0f4fb7adef). Continue chunkById and lazyById from raw model attributes, preserving binary binding intent so cast UUID keys advance correctly. Release consumed lazy chunks before fetching the next page.
Normalize PostgreSQL binary streams at both public paging methods through BinaryParameter::fromStream, also reused by model binary preparation. Read the full stream and rewind it even when the chunk callback already consumed it. Keep explicit binary-string query-builder cursors on the existing afterQuery and alias API; do not guess a string's storage type.
Port both upstream result and binding regressions using actual SQLite BLOB bindings. Add ascending and descending chunk/lazy model coverage, consumed PostgreSQL stream coverage and the documented explicit-alias path.
Formatting and full static analysis pass. The complete binary model suite passes on SQLite, PostgreSQL, MySQL and MariaDB; PostgreSQL stream tests and affected database tests pass.
---
src/database/src/BinaryParameter.php | 13 ++++
src/database/src/Concerns/BuildsQueries.php | 19 +++++-
.../src/Eloquent/Concerns/HasAttributes.php | 8 +--
src/database/src/Query/Builder.php | 17 ++++-
src/docs/database.md | 18 +++++
.../Database/DatabaseEloquentBuilderTest.php | 68 +++++++++++++++++++
.../DatabaseQueryBuilderPaginationTest.php | 26 +++++++
...atabaseEloquentAsBinaryIntegrationTest.php | 34 ++++++++++
.../Database/Postgres/BinaryCursorTest.php | 46 +++++++++++++
9 files changed, 239 insertions(+), 10 deletions(-)
create mode 100644 tests/Integration/Database/Postgres/BinaryCursorTest.php
diff --git a/src/database/src/BinaryParameter.php b/src/database/src/BinaryParameter.php
index 8f658b7d76..deb83f3ea2 100644
--- a/src/database/src/BinaryParameter.php
+++ b/src/database/src/BinaryParameter.php
@@ -15,6 +15,19 @@ public function __construct(public string $value)
{
}
+ /**
+ * Create a binary parameter from a stream and rewind it.
+ *
+ * @param resource $stream
+ */
+ public static function fromStream(mixed $stream): static
+ {
+ $value = stream_get_contents($stream, offset: 0);
+ rewind($stream);
+
+ return new static($value);
+ }
+
/**
* Return the binary parameter value.
*/
diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php
index 823cdb2c9f..306d1a505a 100644
--- a/src/database/src/Concerns/BuildsQueries.php
+++ b/src/database/src/Concerns/BuildsQueries.php
@@ -7,6 +7,7 @@
use Generator;
use Hypervel\Container\Container;
use Hypervel\Database\Eloquent\Builder;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\MultipleRecordsFoundException;
use Hypervel\Database\Query\Builder as QueryBuilder;
use Hypervel\Database\Query\Expression;
@@ -205,7 +206,7 @@ public function orderedChunkById(int $count, callable $callback, ?string $column
return false;
}
- $lastId = data_get($results->last(), $alias);
+ $lastId = $this->getLastIdFromChunk($results->last(), $alias);
if ($lastId === null) {
throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
@@ -283,6 +284,8 @@ public function lazy(int $chunkSize = 1000): LazyCollection
return;
}
+ unset($results);
+
++$page;
}
});
@@ -366,17 +369,29 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null
return;
}
- $lastId = data_get($results->last(), $alias);
+ $lastId = $this->getLastIdFromChunk($results->last(), $alias);
if ($lastId === null) {
throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result.");
}
+ unset($results);
+
++$page;
}
});
}
+ /**
+ * Get the ID of the last result of a chunk to continue paging from.
+ */
+ protected function getLastIdFromChunk(mixed $result, string $alias): mixed
+ {
+ return $result instanceof Model
+ ? $result->prepareBinaryAttributesForDatabase([$alias => $result->getRawOriginal($alias)])[$alias]
+ : data_get($result, $alias);
+ }
+
/**
* Execute the query and get the first result.
*
diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php
index fb0439bd41..09cee774ff 100644
--- a/src/database/src/Eloquent/Concerns/HasAttributes.php
+++ b/src/database/src/Eloquent/Concerns/HasAttributes.php
@@ -1969,12 +1969,8 @@ public function prepareBinaryAttributesForDatabase(array $attributes): array
}
if (is_resource($value)) {
- $binary = stream_get_contents($value, offset: 0);
- rewind($value);
- $value = $binary;
- }
-
- if (is_string($value)) {
+ $attributes[$key] = BinaryParameter::fromStream($value);
+ } elseif (is_string($value)) {
$attributes[$key] = new BinaryParameter($value);
}
}
diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php
index 0c2662ee01..ba6973fe99 100644
--- a/src/database/src/Query/Builder.php
+++ b/src/database/src/Query/Builder.php
@@ -14,6 +14,7 @@
use Hypervel\Contracts\Pagination\CursorPaginator as CursorPaginatorContract;
use Hypervel\Contracts\Pagination\Paginator as PaginatorContract;
use Hypervel\Contracts\Support\Arrayable;
+use Hypervel\Database\BinaryParameter;
use Hypervel\Database\Concerns\BuildsQueries;
use Hypervel\Database\Concerns\BuildsWhereDateClauses;
use Hypervel\Database\Concerns\ExplainsQueries;
@@ -2747,9 +2748,15 @@ public function forPage(int $page, int $perPage = 15): static
/**
* Constrain the query to the previous "page" of results before a given ID.
+ *
+ * @param null|BinaryParameter|int|resource|string $lastId
*/
- public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static
+ public function forPageBeforeId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static
{
+ if (is_resource($lastId)) {
+ $lastId = BinaryParameter::fromStream($lastId);
+ }
+
$this->orders = $this->removeExistingOrdersFor($column);
if (is_null($lastId)) {
@@ -2764,9 +2771,15 @@ public function forPageBeforeId(int $perPage = 15, string|int|null $lastId = 0,
/**
* Constrain the query to the next "page" of results after a given ID.
+ *
+ * @param null|BinaryParameter|int|resource|string $lastId
*/
- public function forPageAfterId(int $perPage = 15, string|int|null $lastId = 0, string $column = 'id'): static
+ public function forPageAfterId(int $perPage = 15, mixed $lastId = 0, string $column = 'id'): static
{
+ if (is_resource($lastId)) {
+ $lastId = BinaryParameter::fromStream($lastId);
+ }
+
$this->orders = $this->removeExistingOrdersFor($column);
if (is_null($lastId)) {
diff --git a/src/docs/database.md b/src/docs/database.md
index cda6734a48..4803e733c0 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -749,6 +749,24 @@ $device = Device::query()->find(new BinaryParameter($uuidBytes));
The wrapper only expresses binding intent. It does not encode or validate the value, and ordinary text strings should not be wrapped.
+When using `chunkById` or `lazyById` with binary strings returned by the query builder, use an `afterQuery` callback to provide a binary cursor. Pass its name as the cursor alias so the original column remains unchanged:
+
+```php
+use Hypervel\Database\BinaryParameter;
+use Hypervel\Support\Collection;
+use Hypervel\Support\Facades\DB;
+
+DB::table('files')->afterQuery(function (Collection $rows) {
+ foreach ($rows as $row) {
+ $row->cursor = new BinaryParameter($row->hash);
+ }
+})->chunkById(100, function (Collection $files) {
+ // Process the files...
+}, 'hash', 'cursor');
+```
+
+Hypervel cannot distinguish an unwrapped binary string from text. Eloquent attributes using the `AsBinary` cast and binary streams returned by PostgreSQL are handled automatically.
+
#### Running an Insert Statement
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 7f49a2a06c..71a8552854 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -13,6 +13,7 @@
use Hypervel\Database\Connection;
use Hypervel\Database\ConnectionResolverInterface;
use Hypervel\Database\Eloquent\Builder;
+use Hypervel\Database\Eloquent\Casts\AsBinary;
use Hypervel\Database\Eloquent\Collection;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\ModelNotFoundException;
@@ -759,6 +760,29 @@ public function testLazyByIdIsLazy(): void
$this->assertSame([[]], $this->pagedBindings($connection));
}
+ public function testChunkByIdUsesTheRawKeyOfCastModels(): void
+ {
+ [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder();
+
+ $chunks = [];
+ $builder->chunkById(2, function (Collection $results) use (&$chunks): bool {
+ $chunks[] = $results->pluck('id')->all();
+
+ return count($chunks) < 3;
+ });
+
+ $this->assertSame([[$ids[0], $ids[1]], [$ids[2]]], $chunks);
+ $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection));
+ }
+
+ public function testLazyByIdUsesTheRawKeyOfCastModels(): void
+ {
+ [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder();
+
+ $this->assertSame($ids, $builder->lazyById(2)->take(4)->map(fn (BinaryUuidStub $model): string => $model->id)->all());
+ $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection));
+ }
+
public function testPluckReturnsTheMutatedAttributesOfAModel(): void
{
$builder = $this->getBuilder();
@@ -4015,6 +4039,33 @@ protected function newPagedBuilder(array $ids, string $column = 'id'): array
return [$connection, $this->newBuilder($connection)];
}
+ /**
+ * Create a builder for paged models with binary UUID keys.
+ *
+ * @return array{SQLiteConnection, Builder, list}
+ */
+ protected function newBinaryUuidPagedBuilder(): array
+ {
+ $ids = [
+ '00000000-0000-0000-0000-000000000001',
+ '00000000-0000-0000-0000-000000000002',
+ '00000000-0000-0000-0000-000000000003',
+ ];
+
+ $connection = $this->useInMemoryConnection();
+ $connection->statement('create table "table" ("id" blob primary key)');
+
+ foreach ($ids as $id) {
+ $connection->table('table')->insert(['id' => new BinaryParameter(hex2bin(str_replace('-', '', $id)))]);
+ }
+
+ $connection->enableQueryLog();
+
+ $builder = (new Builder($connection->query()))->setModel(new BinaryUuidStub);
+
+ return [$connection, $builder, $ids];
+ }
+
/**
* Get the bindings of each logged query.
*/
@@ -4061,6 +4112,23 @@ class Stub extends Model
protected ?string $table = 'table';
}
+class BinaryUuidStub extends Model
+{
+ protected ?string $table = 'table';
+
+ protected string $keyType = 'string';
+
+ public bool $incrementing = false;
+
+ /**
+ * Get the attributes that should be cast.
+ */
+ protected function casts(): array
+ {
+ return ['id' => AsBinary::uuid()];
+ }
+}
+
class FreshQueryBuilder extends Builder
{
}
diff --git a/tests/Database/DatabaseQueryBuilderPaginationTest.php b/tests/Database/DatabaseQueryBuilderPaginationTest.php
index cc20129d49..cab6c59c89 100644
--- a/tests/Database/DatabaseQueryBuilderPaginationTest.php
+++ b/tests/Database/DatabaseQueryBuilderPaginationTest.php
@@ -4,12 +4,14 @@
namespace Hypervel\Tests\Database;
+use Hypervel\Database\BinaryParameter;
use Hypervel\Database\Connection;
use Hypervel\Database\Query\Builder;
use Hypervel\Database\Query\Grammars\Grammar;
use Hypervel\Database\Query\Grammars\MySqlGrammar;
use Hypervel\Database\Query\Processors\Processor;
use Hypervel\Database\SQLiteConnection;
+use Hypervel\Support\Collection;
use Hypervel\Tests\TestCase;
use Mockery as m;
use PDO;
@@ -18,6 +20,30 @@
class DatabaseQueryBuilderPaginationTest extends TestCase
{
+ public function testBinaryStringsCanBePagedUsingAnExplicitCursorAlias(): void
+ {
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $connection->unprepared('create table files (hash blob primary key)');
+ $hashes = [pack('N', 1), pack('N', 2), pack('N', 3)];
+
+ foreach ($hashes as $hash) {
+ $connection->table('files')->insert(['hash' => new BinaryParameter($hash)]);
+ }
+
+ $actual = [];
+ $connection->table('files')->afterQuery(function (Collection $rows): void {
+ foreach ($rows as $row) {
+ $row->cursor = new BinaryParameter($row->hash);
+ }
+ })->chunkById(2, function (Collection $rows) use (&$actual): bool {
+ array_push($actual, ...$rows->pluck('hash')->all());
+
+ return count($actual) < 4;
+ }, 'hash', 'cursor');
+
+ $this->assertSame($hashes, $actual);
+ }
+
#[DataProvider('readWriteRouting')]
public function testGroupedCountsPreserveReadWriteRouting(bool $useWritePdo, int $expectedCount): void
{
diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
index 64b2cb1b27..68bedd536e 100644
--- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
+++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
@@ -6,6 +6,7 @@
use Hypervel\Database\BinaryParameter;
use Hypervel\Database\Eloquent\Casts\AsBinary;
+use Hypervel\Database\Eloquent\Collection;
use Hypervel\Database\Eloquent\Concerns\HasUuids;
use Hypervel\Database\Eloquent\Factories\Factory;
use Hypervel\Database\Eloquent\Model;
@@ -16,6 +17,7 @@
use Hypervel\Support\Str;
use Hypervel\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
@@ -206,6 +208,38 @@ public function testFactoryInsertPreparesGeneratedAndSuppliedBinaryPrimaryKeys()
}
}
+ #[TestWith(['chunkById', false])]
+ #[TestWith(['chunkByIdDesc', true])]
+ #[TestWith(['lazyById', false])]
+ #[TestWith(['lazyByIdDesc', true])]
+ public function testBinaryPrimaryKeysCanBePaged(string $method, bool $descending): void
+ {
+ $ids = [
+ '00000000-0000-0000-0000-000000000001',
+ '00000000-0000-0000-0000-000000000002',
+ '00000000-0000-0000-0000-000000000003',
+ ];
+
+ foreach ($ids as $id) {
+ AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]);
+ }
+
+ $query = AsBinaryPrimaryKeyModel::query();
+ $actual = [];
+
+ if (str_starts_with($method, 'lazy')) {
+ $actual = $query->{$method}(2)->take(4)->pluck('id')->all();
+ } else {
+ $query->{$method}(2, function (Collection $models) use (&$actual): bool {
+ array_push($actual, ...$models->pluck('id')->all());
+
+ return count($actual) < 4;
+ });
+ }
+
+ $this->assertSame($descending ? array_reverse($ids) : $ids, $actual);
+ }
+
public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void
{
$primaryId = '21107c1e-6448-43c2-b80b-40491d165946';
diff --git a/tests/Integration/Database/Postgres/BinaryCursorTest.php b/tests/Integration/Database/Postgres/BinaryCursorTest.php
new file mode 100644
index 0000000000..9bce6b2a6c
--- /dev/null
+++ b/tests/Integration/Database/Postgres/BinaryCursorTest.php
@@ -0,0 +1,46 @@
+binary('hash')->primary();
+ });
+
+ foreach ([1, 2, 3] as $id) {
+ DB::table('binary_cursors')->insert(['hash' => new BinaryParameter(pack('N', $id))]);
+ }
+ }
+
+ #[TestWith(['chunkById', [1, 2, 3]])]
+ #[TestWith(['chunkByIdDesc', [3, 2, 1]])]
+ public function testConsumedBinaryStreamsCanBeUsedAsPagingCursors(string $method, array $expected): void
+ {
+ $actual = [];
+
+ DB::table('binary_cursors')->{$method}(2, function (Collection $rows) use (&$actual): bool {
+ foreach ($rows as $row) {
+ $actual[] = unpack('N', stream_get_contents($row->hash))[1];
+ }
+
+ return count($actual) < 4;
+ }, 'hash');
+
+ $this->assertSame($expected, $actual);
+ }
+}
From 049e5d42ea64a34628d57b1f2b1dd504e15ef5eb Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 19:11:32 +0000
Subject: [PATCH 13/34] Touch custom polymorphic pivot owners before detaching
Port Laravel #61812 (7f5157ef45). A MorphPivot without its own primary key bypassed the owner timestamp update performed by normal pivots and models.
Call touchOwners after the deleting-event veto and before deletion, preserving event order and morph constraints. Port the upstream no-primary-key pivot fixture and both detach/timestamp assertions with immutable dates.
The regression reproduces the stale timestamp before the fix. The complete test file, affected pivot and morph comparison suites, formatting and full static analysis pass.
---
.../src/Eloquent/Relations/MorphPivot.php | 2 +
.../Database/EloquentPivotEventsTest.php | 47 +++++++++++++++++++
2 files changed, 49 insertions(+)
diff --git a/src/database/src/Eloquent/Relations/MorphPivot.php b/src/database/src/Eloquent/Relations/MorphPivot.php
index f157a77068..bb16aaf280 100644
--- a/src/database/src/Eloquent/Relations/MorphPivot.php
+++ b/src/database/src/Eloquent/Relations/MorphPivot.php
@@ -64,6 +64,8 @@ public function delete(): int
return 0;
}
+ $this->touchOwners();
+
$query = $this->getDeleteQuery();
$query->where($this->morphType, $this->morphClass);
diff --git a/tests/Integration/Database/EloquentPivotEventsTest.php b/tests/Integration/Database/EloquentPivotEventsTest.php
index 8e4c6d5f25..9c96931542 100644
--- a/tests/Integration/Database/EloquentPivotEventsTest.php
+++ b/tests/Integration/Database/EloquentPivotEventsTest.php
@@ -12,6 +12,7 @@
use Hypervel\Database\Eloquent\Relations\MorphToMany;
use Hypervel\Database\Eloquent\Relations\Pivot;
use Hypervel\Database\Schema\Blueprint;
+use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Facades\Schema;
use Hypervel\Tests\Integration\Database\DatabaseTestCase;
@@ -61,6 +62,11 @@ protected function afterRefreshingDatabase(): void
$table->foreignId('equipment_id');
});
+ Schema::create('equipmentables_no_id', function (Blueprint $table): void {
+ $table->morphs('equipmentable');
+ $table->foreignId('equipment_id');
+ });
+
Schema::create('project_users', function (Blueprint $table) {
$table->integer('user_id');
$table->integer('project_id');
@@ -207,6 +213,23 @@ public function testCustomMorphPivotClassDetachAttributes(): void
PivotEventsTestModelEquipment::$eventsMorphTypes
);
}
+
+ public function testCustomMorphPivotWithoutPrimaryKeyTouchesOwnersOnDetach(): void
+ {
+ CarbonImmutable::setTestNow('2017-10-10 10:10:10');
+
+ $project = PivotEventsTestProject::forceCreate(['name' => 'Test Project']);
+ $equipment = PivotEventsTestEquipment::forceCreate(['name' => 'important-equipment']);
+
+ $project->touchingEquipments()->attach($equipment);
+
+ CarbonImmutable::setTestNow('2017-10-10 11:10:10');
+
+ $project->touchingEquipments()->detach($equipment);
+
+ $this->assertSame(0, $project->touchingEquipments()->count());
+ $this->assertSame('2017-10-10 11:10:10', $equipment->refresh()->updated_at->toDateTimeString());
+ }
}
class PivotEventsTestUser extends Model
@@ -261,6 +284,15 @@ public function equipments(): MorphToMany
{
return $this->morphToMany(PivotEventsTestEquipment::class, 'equipmentable')->using(PivotEventsTestModelEquipment::class);
}
+
+ /**
+ * Get equipment through a pivot that touches its owner.
+ */
+ public function touchingEquipments(): MorphToMany
+ {
+ return $this->morphToMany(PivotEventsTestEquipment::class, 'equipmentable', 'equipmentables_no_id')
+ ->using(PivotEventsTestTouchingModelEquipment::class);
+ }
}
class PivotEventsTestModelEquipment extends MorphPivot
@@ -327,6 +359,21 @@ public function equipmentable(): MorphTo
}
}
+class PivotEventsTestTouchingModelEquipment extends MorphPivot
+{
+ public ?string $table = 'equipmentables_no_id';
+
+ protected array $touches = ['equipment'];
+
+ /**
+ * Get the equipment attached by this pivot.
+ */
+ public function equipment(): BelongsTo
+ {
+ return $this->belongsTo(PivotEventsTestEquipment::class);
+ }
+}
+
class PivotEventsTestCollaborator extends Pivot
{
public ?string $table = 'project_users';
From 3d47d9b06cf8c51826d6844dbadf2287d313ac3e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:20:31 +0000
Subject: [PATCH 14/34] test(collections): cover mixed eager and lazy
collection values
Reconcile Laravel framework PR #61811 (56a59fc902) at upstream revision 5d7797528723107cd4f495326838a7d7d67fbb20. Hypervel already accepts Enumerable values in collapse, collapseWithKeys and flatten, so the production implementation is retained.
Port every added array and collection assertion. Consolidate overlapping custom cases under the upstream test names while retaining strict comparisons, nested plain arrays, duplicate-key replacement and dropped non-collection values.
Validation: both complete test files, affected parallel tests, formatting and static analysis pass.
---
tests/Support/SupportArrTest.php | 10 ++++++++++
tests/Support/SupportCollectionTest.php | 22 ++++++++++++++++++++--
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/tests/Support/SupportArrTest.php b/tests/Support/SupportArrTest.php
index 52d192c933..59b0edf547 100644
--- a/tests/Support/SupportArrTest.php
+++ b/tests/Support/SupportArrTest.php
@@ -142,6 +142,10 @@ public function testCollapse(): void
$collection = collect(['baz', 'boom']);
$mixedArray = [[1], [2], [3], ['foo', 'bar'], $collection];
$this->assertEquals([1, 2, 3, 'foo', 'bar', 'baz', 'boom'], Arr::collapse($mixedArray));
+
+ // Case including lazy collections
+ $mixedArray = [[1], new LazyCollection([2, 3]), collect([4])];
+ $this->assertEquals([1, 2, 3, 4], Arr::collapse($mixedArray));
}
public function testCrossJoin(): void
@@ -660,6 +664,12 @@ public function testFlatten(): void
$this->assertEquals(['#foo', '#bar', '#zap', '#baz'], Arr::flatten($array));
}
+ public function testFlattenWithLazyCollections(): void
+ {
+ $array = [new LazyCollection(['#foo', ['#bar']]), ['#baz', new LazyCollection(['#zap'])]];
+ $this->assertEquals(['#foo', '#bar', '#baz', '#zap'], Arr::flatten($array));
+ }
+
public function testFlattenWithDepth(): void
{
// No depth flattens recursively
diff --git a/tests/Support/SupportCollectionTest.php b/tests/Support/SupportCollectionTest.php
index d3c95f84ee..3f40eac127 100644
--- a/tests/Support/SupportCollectionTest.php
+++ b/tests/Support/SupportCollectionTest.php
@@ -1457,6 +1457,13 @@ public function testFlatten($collection): void
// Nested arrays containing collections containing arrays are flattened
$c = new $collection([['#foo', new $collection(['#bar', ['#zap']])], ['#baz']]);
$this->assertEquals(['#foo', '#bar', '#zap', '#baz'], $c->flatten()->all());
+ }
+
+ #[DataProvider('collectionClassProvider')]
+ public function testFlattenWithMixedCollectionTypes(string $collection): void
+ {
+ $c = new $collection([new Collection(['#foo', new LazyCollection(['#bar'])]), new LazyCollection(['#baz', new Collection(['#zap'])])]);
+ $this->assertSame(['#foo', '#bar', '#baz', '#zap'], $c->flatten()->all());
$c = new $collection([new LazyCollection(['#foo', ['#bar']]), ['#baz']]);
$this->assertSame(['#foo', '#bar', '#baz'], $c->flatten()->all());
@@ -2016,9 +2023,13 @@ public function testCollapseWithNestedCollections($collection): void
{
$data = new $collection([new $collection([1, 2, 3]), new $collection([4, 5, 6])]);
$this->assertEquals([1, 2, 3, 4, 5, 6], $data->collapse()->all());
+ }
- $data = new $collection([new LazyCollection([1, 2, 3]), [4, 5, 6]]);
- $this->assertSame([1, 2, 3, 4, 5, 6], $data->collapse()->all());
+ #[DataProvider('collectionClassProvider')]
+ public function testCollapseWithMixedCollectionTypes(string $collection): void
+ {
+ $data = new $collection([new Collection([1, 2]), new LazyCollection([3, 4]), [5]]);
+ $this->assertSame([1, 2, 3, 4, 5], $data->collapse()->all());
}
#[DataProvider('collectionClassProvider')]
@@ -2037,6 +2048,13 @@ public function testCollapseWithKeysOnNestedCollections($collection): void
{
$data = new $collection([new $collection(['a' => '1a', 'b' => '1b']), new $collection(['b' => '2b', 'c' => '2c']), 'drop']);
$this->assertEquals(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all());
+ }
+
+ #[DataProvider('collectionClassProvider')]
+ public function testCollapseWithKeysWithMixedCollectionTypes(string $collection): void
+ {
+ $data = new $collection([new Collection(['a' => 1, 'b' => 2]), new LazyCollection(['b' => 3, 'c' => 4])]);
+ $this->assertSame(['a' => 1, 'b' => 3, 'c' => 4], $data->collapseWithKeys()->all());
$data = new $collection([new LazyCollection(['a' => '1a', 'b' => '1b']), ['b' => '2b', 'c' => '2c'], 'drop']);
$this->assertSame(['a' => '1a', 'b' => '2b', 'c' => '2c'], $data->collapseWithKeys()->all());
From 44eb8eb0e88a13be06e11dbef07ef74868cd37a9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:20:38 +0000
Subject: [PATCH 15/34] fix(database): normalize enum default connection names
Port Laravel framework PR #61810 (3f0cdf2ccd) and its facade update 023c99482c at upstream revision 5d7797528723107cd4f495326838a7d7d67fbb20. Accept unit and backed enums in DatabaseManager::setDefaultConnection and normalize their values before storing the override.
Keep the default connection local to the current coroutine, leave shared configuration unchanged, and preserve null as the way to clear an override. Regenerate the DB facade annotation and port the upstream setter and temporary-connection tests, including integer-backed zero coverage.
Validation: DatabaseManagerTest, scoped default-connection tests, FacadeDocblocksTest, affected parallel tests, formatting and full static analysis pass.
---
src/database/src/DatabaseManager.php | 6 ++-
src/support/src/Facades/DB.php | 2 +-
tests/Database/DatabaseManagerTest.php | 51 ++++++++++++++++++++++++++
3 files changed, 57 insertions(+), 2 deletions(-)
diff --git a/src/database/src/DatabaseManager.php b/src/database/src/DatabaseManager.php
index 56cc917495..10635c2442 100755
--- a/src/database/src/DatabaseManager.php
+++ b/src/database/src/DatabaseManager.php
@@ -571,8 +571,12 @@ public function allowQueryDurationHandlersToRunAgain(): void
* worker are not affected. A null value clears the override and
* getDefaultConnection() falls back to config('database.default').
*/
- public function setDefaultConnection(?string $name): void
+ public function setDefaultConnection(UnitEnum|string|null $name): void
{
+ if ($name instanceof UnitEnum) {
+ $name = (string) enum_value($name);
+ }
+
if ($name === null) {
CoroutineContext::forget(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY);
} else {
diff --git a/src/support/src/Facades/DB.php b/src/support/src/Facades/DB.php
index b5ac8b6a17..44315156dc 100644
--- a/src/support/src/Facades/DB.php
+++ b/src/support/src/Facades/DB.php
@@ -34,7 +34,7 @@
* @method static \Hypervel\Database\Connection reconnect(\UnitEnum|string|null $name = null)
* @method static void releaseIdleConnections()
* @method static \Hypervel\Database\DatabaseManager setApplication(\Hypervel\Contracts\Foundation\Application $app)
- * @method static void setDefaultConnection(string|null $name)
+ * @method static void setDefaultConnection(\UnitEnum|string|null $name)
* @method static void setReconnector(callable $reconnector)
* @method static string[] supportedDrivers()
* @method static mixed usingConnection(\UnitEnum|string $name, callable $callback)
diff --git a/tests/Database/DatabaseManagerTest.php b/tests/Database/DatabaseManagerTest.php
index d8026af0be..3e588ba9c0 100644
--- a/tests/Database/DatabaseManagerTest.php
+++ b/tests/Database/DatabaseManagerTest.php
@@ -5,8 +5,13 @@
namespace Hypervel\Tests\Database;
use Closure;
+use Hypervel\Config\Repository;
+use Hypervel\Container\Container;
+use Hypervel\Context\CoroutineContext;
use Hypervel\Database\Capsule\Manager as DB;
use Hypervel\Database\Connection;
+use Hypervel\Database\ConnectionResolver;
+use Hypervel\Database\Connectors\ConnectionFactory;
use Hypervel\Database\DatabaseManager;
use Hypervel\Database\Events\ConnectionEstablished;
use Hypervel\Database\PdoConnection;
@@ -39,6 +44,42 @@ protected function setUp(): void
]);
}
+ public function testSetDefaultConnectionAcceptsEnums(): void
+ {
+ $container = new Container;
+ $config = new Repository(['database' => ['default' => 'sqlite']]);
+ $container->instance('config', $config);
+
+ $manager = new DatabaseManager($container, m::mock(ConnectionFactory::class));
+
+ $manager->setDefaultConnection(DatabaseManagerTestBackedConnection::Reporting);
+
+ $this->assertSame('reporting', $manager->getDefaultConnection());
+ $this->assertSame('reporting', CoroutineContext::get(ConnectionResolver::DEFAULT_CONNECTION_CONTEXT_KEY));
+ $this->assertSame('sqlite', $config->get('database.default'));
+
+ $manager->setDefaultConnection(DatabaseManagerTestUnitConnection::analytics);
+
+ $this->assertSame('analytics', $manager->getDefaultConnection());
+
+ $manager->setDefaultConnection(DatabaseManagerConnectionName::Zero);
+
+ $this->assertSame('0', $manager->getDefaultConnection());
+ }
+
+ public function testUsingConnectionAcceptsEnumsAndRestoresTheDefaultConnection(): void
+ {
+ $container = new Container;
+ $container->instance('config', new Repository(['database' => ['default' => 'sqlite']]));
+
+ $manager = new DatabaseManager($container, m::mock(ConnectionFactory::class));
+
+ $default = $manager->usingConnection(DatabaseManagerTestBackedConnection::Reporting, fn (): ?string => $manager->getDefaultConnection());
+
+ $this->assertSame('reporting', $default);
+ $this->assertSame('sqlite', $manager->getDefaultConnection());
+ }
+
public function testReconnectorAcceptsArrayCallable(): void
{
$manager = $this->db->getDatabaseManager();
@@ -689,6 +730,16 @@ protected function createSqliteUsersDatabase(string $path, string $name): void
}
}
+enum DatabaseManagerTestBackedConnection: string
+{
+ case Reporting = 'reporting';
+}
+
+enum DatabaseManagerTestUnitConnection
+{
+ case analytics;
+}
+
enum DatabaseManagerConnectionName: int
{
case Zero = 0;
From a05d116334e2d53af5a81a14a9fb9b112075c0e1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:20:48 +0000
Subject: [PATCH 16/34] fix(object-pool): preserve cancellation through
throwing cleanup
A destroy or reset callback can throw while cancellation unwinds, leaving the cancellation in the exception chain. Recognize that chain at the pool destruction boundary and in lease failure finalizers instead of reporting it as an ordinary error or replacing it with a later cancellation.
Preserve the first cancellation-bearing error, finish owned cleanup, and report genuine secondary cleanup errors. Pool closure drains every idle object before propagating cancellation. Ordinary destroy callback failures retain their existing reporting behavior, and unexpected errors outside those callbacks still propagate.
Extend the existing precedence and destruction regressions with chained errors, exact exception identity, secondary reporting and capacity cleanup. Exercise lease release against the real ObjectPool implementation as well as the contract fixture.
Validation: complete changed test files, affected pool and queue parallel suites, formatting and full static analysis pass. Chained regression cases fail before the correction.
---
src/object-pool/src/Lease.php | 29 ++++++++++-----------
src/object-pool/src/ObjectPool.php | 20 +++++++++++----
tests/ObjectPool/LeaseTest.php | 31 +++++++++++++++++++++--
tests/ObjectPool/ObjectPoolTest.php | 39 +++++++++++++++++++++++++----
4 files changed, 91 insertions(+), 28 deletions(-)
diff --git a/src/object-pool/src/Lease.php b/src/object-pool/src/Lease.php
index 6e1639c92d..62b6962c94 100644
--- a/src/object-pool/src/Lease.php
+++ b/src/object-pool/src/Lease.php
@@ -6,8 +6,8 @@
use Closure;
use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract;
+use Hypervel\Coroutine\Coroutine;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
class Lease
@@ -53,21 +53,14 @@ public function release(): void
if ($this->releaseCallback !== null) {
($this->releaseCallback)($this->object);
}
- } catch (CanceledException $cancellation) {
- try {
- $this->pool->discard($this->object);
- } catch (CanceledException) {
- } catch (Throwable $exception) {
- PoolErrorReporter::report($exception);
- }
-
- throw $cancellation;
} catch (Throwable $exception) {
try {
$this->pool->discard($this->object);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
} catch (Throwable $discardException) {
+ if (Coroutine::causedByCancellation($discardException) && ! Coroutine::causedByCancellation($exception)) {
+ throw $discardException;
+ }
+
PoolErrorReporter::report($discardException);
}
@@ -97,9 +90,11 @@ public function releaseAfterFailure(Throwable $failure): never
{
try {
$this->release();
- } catch (CanceledException $cancellation) {
- throw $failure instanceof CanceledException ? $failure : $cancellation;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception) && ! Coroutine::causedByCancellation($failure)) {
+ throw $exception;
+ }
+
PoolErrorReporter::report($exception);
}
@@ -113,9 +108,11 @@ public function discardAfterFailure(Throwable $failure): never
{
try {
$this->discard();
- } catch (CanceledException $cancellation) {
- throw $failure instanceof CanceledException ? $failure : $cancellation;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception) && ! Coroutine::causedByCancellation($failure)) {
+ throw $exception;
+ }
+
PoolErrorReporter::report($exception);
}
diff --git a/src/object-pool/src/ObjectPool.php b/src/object-pool/src/ObjectPool.php
index 020460802d..0c3f026a64 100644
--- a/src/object-pool/src/ObjectPool.php
+++ b/src/object-pool/src/ObjectPool.php
@@ -6,11 +6,11 @@
use Closure;
use Hypervel\Contracts\ObjectPool\ObjectPool as ObjectPoolContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Coroutine\PoolChannel;
use Hypervel\ObjectPool\Exceptions\PoolClosedException;
use Hypervel\ObjectPool\Exceptions\PoolExhaustedException;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
/**
@@ -179,8 +179,16 @@ public function close(): void
while (($object = $this->channel->pop()) !== false) {
try {
$this->destroyObject($object);
- } catch (CanceledException $exception) {
- $cancellation ??= $exception;
+ } catch (Throwable $exception) {
+ if (! Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
+ if ($cancellation === null) {
+ $cancellation = $exception;
+ } else {
+ PoolErrorReporter::report($exception);
+ }
}
}
@@ -320,9 +328,11 @@ protected function destroyObject(object $object): void
if ($this->destroyCallback !== null) {
($this->destroyCallback)($object);
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
PoolErrorReporter::report($exception);
} finally {
unset(
diff --git a/tests/ObjectPool/LeaseTest.php b/tests/ObjectPool/LeaseTest.php
index dbb4d13291..d313560cd5 100644
--- a/tests/ObjectPool/LeaseTest.php
+++ b/tests/ObjectPool/LeaseTest.php
@@ -139,11 +139,24 @@ public function testDiscardFailureDoesNotMaskAReleaseCallbackFailure(): void
public function testReleaseCallbackPreservesCancellationPrecedence(
bool $callbackIsCancellation,
bool $expectCallbackFailure,
+ bool $callbackCleanupThrows = false,
+ bool $discardCleanupThrows = false,
): void {
$callbackFailure = $callbackIsCancellation
? new CanceledException('reset canceled')
: new RuntimeException('reset failed');
$discardCancellation = new CanceledException('discard canceled');
+ if ($callbackCleanupThrows) {
+ $callbackFailure = new RuntimeException('reset cleanup failed', previous: $callbackFailure);
+ }
+ if ($discardCleanupThrows) {
+ $discardCancellation = new RuntimeException('discard cleanup failed', previous: $discardCancellation);
+ }
+ $handler = m::mock(ExceptionHandler::class);
+ $expectation = $handler->shouldReceive('report')->with($discardCancellation);
+ $expectCallbackFailure && $discardCleanupThrows ? $expectation->once() : $expectation->never();
+ $this->container()->instance(ExceptionHandler::class, $handler);
+
$pool = new ContractOnlyObjectPool;
$pool->discardException = $discardCancellation;
$lease = new Lease($pool, new stdClass, function () use ($callbackFailure): never {
@@ -153,7 +166,7 @@ public function testReleaseCallbackPreservesCancellationPrecedence(
try {
$lease->release();
$this->fail('Expected lease release to fail.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame(
$expectCallbackFailure ? $callbackFailure : $discardCancellation,
$exception,
@@ -166,6 +179,9 @@ public static function releaseCallbackCancellationProvider(): array
return [
'ordinary callback failure' => [false, false],
'canceled callback' => [true, true],
+ 'chained callback cancellation' => [true, true, true],
+ 'chained discard cancellation' => [false, false, false, true],
+ 'both cancellations chained' => [true, true, true, true],
];
}
@@ -240,6 +256,8 @@ public function testFailureFinalizersPreserveCancellationPrecedence(
bool $primaryIsCancellation,
bool $cleanupIsCancellation,
bool $expectPrimary,
+ bool $primaryCleanupThrows = false,
+ bool $cleanupThrowsAgain = false,
): void {
$container = $this->container();
$primary = $primaryIsCancellation
@@ -248,9 +266,15 @@ public function testFailureFinalizersPreserveCancellationPrecedence(
$cleanup = $cleanupIsCancellation
? new CanceledException('cleanup canceled')
: new RuntimeException('cleanup failed');
+ if ($primaryCleanupThrows) {
+ $primary = new RuntimeException('operation cleanup failed', previous: $primary);
+ }
+ if ($cleanupThrowsAgain) {
+ $cleanup = new RuntimeException('finalizer cleanup failed', previous: $cleanup);
+ }
$handler = m::mock(ExceptionHandler::class);
$expectation = $handler->shouldReceive('report')->with($cleanup);
- $cleanupIsCancellation ? $expectation->never() : $expectation->once();
+ $expectPrimary && ! $cleanup instanceof CanceledException ? $expectation->once() : $expectation->never();
$container->instance(ExceptionHandler::class, $handler);
$pool = new ContractOnlyObjectPool;
@@ -275,6 +299,9 @@ public static function failureFinalizerProvider(): array
$cases["{$method}: canceled primary and ordinary cleanup"] = [$method, true, false, true];
$cases["{$method}: ordinary primary and canceled cleanup"] = [$method, false, true, false];
$cases["{$method}: canceled primary and cleanup"] = [$method, true, true, true];
+ $cases["{$method}: chained primary and native cleanup cancellation"] = [$method, true, true, true, true];
+ $cases["{$method}: ordinary primary and chained cleanup cancellation"] = [$method, false, true, false, false, true];
+ $cases["{$method}: both cancellations chained"] = [$method, true, true, true, true, true];
}
return $cases;
diff --git a/tests/ObjectPool/ObjectPoolTest.php b/tests/ObjectPool/ObjectPoolTest.php
index ec7f90f89c..bb2b6e910f 100644
--- a/tests/ObjectPool/ObjectPoolTest.php
+++ b/tests/ObjectPool/ObjectPoolTest.php
@@ -12,11 +12,13 @@
use Hypervel\Engine\Channel;
use Hypervel\ObjectPool\Exceptions\PoolClosedException;
use Hypervel\ObjectPool\Exceptions\PoolExhaustedException;
+use Hypervel\ObjectPool\Lease;
use Hypervel\ObjectPool\ObjectPool;
use Hypervel\ObjectPool\PoolOptions;
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use stdClass;
use Swoole\Coroutine\CanceledException;
@@ -50,10 +52,23 @@ public function testCloseDrainsIdleObjectsAndIsIdempotent(): void
$this->assertEqualsCanonicalizing($objects, $destroyed);
}
- public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellation(): void
+ #[TestWith([false, false])]
+ #[TestWith([true, false])]
+ #[TestWith([false, true])]
+ #[TestWith([true, true])]
+ public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellation(bool $firstCleanupThrows, bool $secondCleanupThrows): void
{
$firstCancellation = new CanceledException('first cleanup canceled');
$secondCancellation = new CanceledException('second cleanup canceled');
+ if ($firstCleanupThrows) {
+ $firstCancellation = new RuntimeException('First cleanup failed.', previous: $firstCancellation);
+ }
+ if ($secondCleanupThrows) {
+ $secondCancellation = new RuntimeException('Second cleanup failed.', previous: $secondCancellation);
+ }
+ $handler = m::mock(ExceptionHandler::class);
+ $handler->shouldReceive('report')->with($secondCancellation)->times($secondCleanupThrows ? 1 : 0);
+ $this->container()->instance(ExceptionHandler::class, $handler);
$destroyed = [];
$pool = $this->pool(
['max_objects' => 2],
@@ -72,7 +87,7 @@ public function testCloseDrainsEveryIdleObjectBeforeRethrowingTheFirstCancellati
try {
$pool->close();
$this->fail('Expected idle cleanup cancellation to be rethrown.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($firstCancellation, $exception);
}
@@ -752,9 +767,19 @@ function () use ($failure): never {
$this->assertSame(0, $pool->getIdleCount());
}
- public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void
+ #[TestWith([false, false])]
+ #[TestWith([true, false])]
+ #[TestWith([false, true])]
+ #[TestWith([true, true])]
+ public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(bool $cleanupThrows, bool $releaseFails): void
{
$cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Destroy cleanup failed.', previous: $cancellation);
+ }
+ $handler = m::mock(ExceptionHandler::class);
+ $handler->shouldNotReceive('report');
+ $this->container()->instance(ExceptionHandler::class, $handler);
$pool = $this->pool(
destroyCallback: static function () use ($cancellation): never {
throw $cancellation;
@@ -763,9 +788,13 @@ public function testDestroyCancellationEscapesAfterReleasingPoolCapacity(): void
$object = $pool->borrow();
try {
- $pool->discard($object);
+ if ($releaseFails) {
+ (new Lease($pool, $object, static fn () => throw new RuntimeException('Reset failed.')))->release();
+ } else {
+ $pool->discard($object);
+ }
$this->fail('Expected object destruction cancellation to be rethrown.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
From bc27228b0c3d6f21915c3a425823f15fb5f0cac9 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:01 +0000
Subject: [PATCH 17/34] fix(queue): preserve cancellation across SQS dispatch
and cleanup
Reconcile Laravel framework PR #61828 (7cd5134f93) at upstream revision 5d7797528723107cd4f495326838a7d7d67fbb20. Existing queue normalization already handles enum names; port the upstream test proving both queueing and queued events receive the normalized name.
Correct related dispatch paths that treated throwing cancellation cleanup as an ordinary failure. Single and batch lifecycle notifications now stop on cancellation without emitting failure events or replacing it with an earlier notification error. SQS transport cancellation retains ambiguous overflow payloads and propagates through outbox publication.
Overflow cleanup drains attempted writes while preserving the first cancellation-bearing exception and reporting secondary cleanup errors. A cleanup cancellation takes precedence over an ordinary write failure. Successful and explicitly rejected SQS entries retain their existing ownership and cleanup rules.
Validation: real Swoole cancellation with throwing finally blocks, expanded overflow precedence tests, complete SQS test files, affected parallel suites, formatting and full static analysis pass. The new regression cases reproduce lost cancellation or unwanted events before the fixes.
---
src/queue/src/Queue.php | 25 ++-
src/queue/src/SqsQueue.php | 68 ++++---
.../Queue/Outbox/SqsOutboxPublicationTest.php | 24 ++-
tests/Queue/QueueSqsQueueTest.php | 186 ++++++++++++++++--
tests/Queue/SqsBatchLifecycleTest.php | 112 ++++++++++-
5 files changed, 356 insertions(+), 59 deletions(-)
diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php
index 6e98a26324..55f49e30eb 100644
--- a/src/queue/src/Queue.php
+++ b/src/queue/src/Queue.php
@@ -15,6 +15,7 @@
use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
use Hypervel\Contracts\Queue\ShouldBeEncrypted;
use Hypervel\Contracts\Queue\ShouldQueueAfterCommit;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\Log\Context\Repository as ContextRepository;
use Hypervel\Queue\Attributes\Backoff;
@@ -558,9 +559,11 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu
$this->raiseJobQueueingEvent($queue, $job, $payload, $delay);
$storedPayload = $this->preparePayloadForPush($job, $payload, $queue);
$jobId = $callback($this, $storedPayload, $queue, $delay);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception);
throw $exception;
@@ -571,17 +574,21 @@ protected function enqueueNow(object|string $job, string $payload, ?string $queu
try {
$this->handlePayloadPushed($storedPayload, $queue);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$exception = $throwable;
}
try {
$this->raiseJobQueuedEvent($queue, $jobId, $job, $payload, $delay);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$exception ??= $throwable;
}
@@ -622,9 +629,11 @@ protected function notifyBatchMembers(array $members, Closure ...$notifications)
foreach ($notifications as $notification) {
try {
$notification($member);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$exception ??= $throwable;
}
}
diff --git a/src/queue/src/SqsQueue.php b/src/queue/src/SqsQueue.php
index bddbb46391..f84ecfc1de 100644
--- a/src/queue/src/SqsQueue.php
+++ b/src/queue/src/SqsQueue.php
@@ -16,6 +16,7 @@
use Hypervel\Contracts\Queue\Job as JobContract;
use Hypervel\Contracts\Queue\PublishesOutboxMessages;
use Hypervel\Contracts\Queue\Queue as QueueContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\ObjectPool\PoolErrorReporter;
use Hypervel\Queue\Jobs\SqsJob;
@@ -26,7 +27,6 @@
use Hypervel\Support\Str;
use LogicException;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
use UnitEnum;
@@ -257,15 +257,16 @@ public function pushRaw(string $payload, UnitEnum|string|null $queue = null, arr
try {
$this->storeOverflowPayload($store, $path, $overflowPayload);
- } catch (CanceledException $cancellation) {
+ } catch (Throwable $exception) {
try {
$this->cleanupOverflowPayloads($store, [$path]);
- } catch (CanceledException) {
- }
+ } catch (Throwable $cleanupException) {
+ if (! Coroutine::causedByCancellation($exception)) {
+ throw $cleanupException;
+ }
- throw $cancellation;
- } catch (Throwable $exception) {
- $this->cleanupOverflowPayloads($store, [$path]);
+ PoolErrorReporter::report($cleanupException);
+ }
throw $exception;
}
@@ -455,9 +456,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void
$queueUrl = $this->getQueue($queue);
$store = $overflow === [] ? null : $this->overflowStore();
$chunks = $this->chunkBatchEntries($entries);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception);
throw $exception;
@@ -475,9 +478,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void
if ($store !== null) {
$this->storeBatchOverflowPayloads($chunk, $overflow, $store);
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception);
throw $exception;
@@ -490,9 +495,11 @@ protected function sendBatchedMessages(array $messages, ?string $queue): void
'QueueUrl' => $queueUrl,
'Entries' => $chunk,
]);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception);
throw $exception;
@@ -545,9 +552,11 @@ function (array $success) use ($messages, $queue): void {
try {
$this->raiseBatchFailedEvents($messages, $outstandingMessageIds, $queue, $exception);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
} catch (Throwable $throwable) {
+ if (Coroutine::causedByCancellation($throwable)) {
+ throw $throwable;
+ }
+
$notificationFailure ??= $throwable;
}
@@ -639,9 +648,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult
}
}
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return new OutboxBatchResult($accepted, failure: $exception);
}
@@ -801,18 +812,17 @@ protected function storeBatchOverflowPayloads(array $chunk, array $overflow, Cac
$paths[] = $payload['path'];
$this->storeOverflowPayload($store, $payload['path'], $payload['payload']);
}
- } catch (CanceledException $cancellation) {
+ } catch (Throwable $exception) {
if ($paths !== []) {
try {
$this->cleanupOverflowPayloads($store, $paths);
- } catch (CanceledException) {
- }
- }
+ } catch (Throwable $cleanupException) {
+ if (! Coroutine::causedByCancellation($exception)) {
+ throw $cleanupException;
+ }
- throw $cancellation;
- } catch (Throwable $exception) {
- if ($paths !== []) {
- $this->cleanupOverflowPayloads($store, $paths);
+ PoolErrorReporter::report($cleanupException);
+ }
}
throw $exception;
@@ -833,10 +843,12 @@ protected function cleanupOverflowPayloads(CacheRepository $store, array $paths)
if (! $store->forget($path)) {
throw new RuntimeException("Unable to delete the SQS overflow payload [{$path}].");
}
- } catch (CanceledException $exception) {
- $cancellation ??= $exception;
} catch (Throwable $exception) {
- PoolErrorReporter::report($exception);
+ if ($cancellation === null && Coroutine::causedByCancellation($exception)) {
+ $cancellation = $exception;
+ } else {
+ PoolErrorReporter::report($exception);
+ }
}
}
diff --git a/tests/Queue/Outbox/SqsOutboxPublicationTest.php b/tests/Queue/Outbox/SqsOutboxPublicationTest.php
index ecb0483483..1f18951ebb 100644
--- a/tests/Queue/Outbox/SqsOutboxPublicationTest.php
+++ b/tests/Queue/Outbox/SqsOutboxPublicationTest.php
@@ -18,7 +18,10 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
class SqsOutboxPublicationTest extends TestCase
{
@@ -61,11 +64,18 @@ public static function deliveryTimes(): array
return [[null, false, null], [0, false, 0], [-30, false, 0], [30, false, 30], [1000, false, 900], [0, true, null]];
}
- public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation(): void
+ #[TestWith(['ordinary'])]
+ #[TestWith(['cancellation'])]
+ #[TestWith(['cleanup'])]
+ public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation(string $failureType): void
{
$client = m::mock(SqsClient::class);
$queue = new SqsQueue($client, 'orders');
- $failure = new RuntimeException('SDK retries exhausted.');
+ $failure = match ($failureType) {
+ 'cancellation' => new CanceledException,
+ 'cleanup' => new RuntimeException('SDK cleanup failed.', previous: new CanceledException),
+ default => new RuntimeException('SDK retries exhausted.'),
+ };
$calls = 0;
$client->expects('sendMessageBatch')->twice()->andReturnUsing(function (array $arguments) use (&$calls, $failure): Result {
if (++$calls === 2) {
@@ -78,8 +88,16 @@ public function testBatchSizeLimitsAndLaterSdkFailureRetainEarlierConfirmation()
return new Result(['Successful' => array_map(static fn (array $entry): array => ['Id' => $entry['Id'], 'MessageId' => 'sqs-' . $entry['Id']], $arguments['Entries'])]);
});
- $result = $queue->publishOutboxMessages(array_map(fn (int $id): OutboxMessage => $this->message($id), range(1, 11)));
+ try {
+ $result = $queue->publishOutboxMessages(array_map(fn (int $id): OutboxMessage => $this->message($id), range(1, 11)));
+ } catch (Throwable $exception) {
+ $this->assertNotSame('ordinary', $failureType);
+ $this->assertSame($failure, $exception);
+ return;
+ }
+
+ $this->assertSame('ordinary', $failureType, 'Cancellation must propagate instead of returning a batch result.');
$this->assertSame(range(1, 10), $result->accepted);
$this->assertSame($failure, $result->failure);
}
diff --git a/tests/Queue/QueueSqsQueueTest.php b/tests/Queue/QueueSqsQueueTest.php
index 1e54c14e22..8fc9ebb5fd 100644
--- a/tests/Queue/QueueSqsQueueTest.php
+++ b/tests/Queue/QueueSqsQueueTest.php
@@ -17,6 +17,7 @@
use Hypervel\Contracts\Cache\Repository as CacheRepository;
use Hypervel\Contracts\Cache\Store as CacheStore;
use Hypervel\Contracts\Container\Container as ContainerContract;
+use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Contracts\Events\Dispatcher as EventDispatcher;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Contracts\Queue\ShouldQueue;
@@ -44,9 +45,11 @@
use LogicException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Symfony\Component\Uid\Uuid;
+use Throwable;
class QueueSqsQueueTest extends TestCase
{
@@ -1031,6 +1034,55 @@ public static function nonPositiveFifoDelays(): array
];
}
+ #[TestWith(['finalizing'])]
+ #[TestWith(['queueing'])]
+ #[TestWith(['stored'])]
+ #[TestWith(['queued'])]
+ public function testPushCancellationStopsSubsequentLifecycleNotifications(string $phase): void
+ {
+ $container = new Container;
+ $events = new ConcreteEventDispatcher($container);
+ $container->instance('events', $events);
+ $cancellation = new RuntimeException('Listener cleanup failed.', previous: new CanceledException);
+ $notified = [];
+
+ foreach ([JobPayloadFinalizing::class => 'finalizing', JobQueueing::class => 'queueing', JobQueued::class => 'queued', JobQueueingFailed::class => 'failed'] as $eventClass => $name) {
+ $events->listen($eventClass, static function () use ($name, $phase, $cancellation, &$notified): void {
+ $notified[] = $name;
+ if ($name === $phase) {
+ throw $cancellation;
+ }
+ });
+ }
+
+ $queue = m::mock(SqsQueue::class, [$this->sqs, $this->queueName, $this->prefix])
+ ->makePartial()->shouldAllowMockingProtectedMethods();
+ $queue->setContainer($container);
+ $queue->setConnectionName('sqs');
+
+ if (in_array($phase, ['stored', 'queued'], true)) {
+ $this->sqs->expects('sendMessage')->andReturn($this->mockedSendMessageResponseModel);
+ $queue->expects('handlePayloadPushed')->andThrow(
+ $phase === 'stored' ? $cancellation : new RuntimeException('Earlier notification failed.'),
+ );
+ } else {
+ $this->sqs->shouldNotReceive('sendMessage');
+ }
+
+ try {
+ $queue->push('job');
+ $this->fail('Expected cancellation to escape dispatch.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ }
+
+ $this->assertSame(match ($phase) {
+ 'finalizing' => ['finalizing'],
+ 'queued' => ['finalizing', 'queueing', 'queued'],
+ default => ['finalizing', 'queueing'],
+ }, $notified);
+ }
+
public function testPushRawStoresPayloadToCacheWhenExceedingThreshold(): void
{
$uuid = 'test-uuid';
@@ -1213,14 +1265,25 @@ public static function invalidOverflowUuidProvider(): array
];
}
- public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse(): void
+ #[TestWith([null])]
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse(?bool $cleanupThrows): void
{
$payload = json_encode(['uuid' => 'failed-write'], JSON_THROW_ON_ERROR);
$path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'failed-write';
+ $cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Delete cleanup failed.', previous: $cancellation);
+ }
$store = m::mock(CacheRepository::class);
$store->expects('put')->andReturnFalse();
- $store->expects('forget')->with($path)->andReturnTrue();
+ if ($cleanupThrows === null) {
+ $store->expects('forget')->with($path)->andReturnTrue();
+ } else {
+ $store->expects('forget')->with($path)->andThrow($cancellation);
+ }
$cache = m::mock(CacheFactory::class);
$cache->expects('store')->with('database')->andReturn($store);
@@ -1238,21 +1301,44 @@ public function testPushRawFailsBeforeSendingWhenOverflowStorageReturnsFalse():
$this->sqs->shouldNotReceive('sendMessage');
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessageIsOrContains('Unable to store the SQS overflow payload');
-
- $queue->pushRaw($payload, $this->queueName);
+ try {
+ $queue->pushRaw($payload, $this->queueName);
+ $this->fail('Expected the overflow write or cleanup failure.');
+ } catch (Throwable $exception) {
+ if ($cleanupThrows === null) {
+ $this->assertInstanceOf(RuntimeException::class, $exception);
+ $this->assertStringContainsString('Unable to store the SQS overflow payload', $exception->getMessage());
+ } else {
+ $this->assertSame($cancellation, $exception);
+ }
+ }
}
- public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPayload(): void
+ #[DataProvider('overflowCancellationProvider')]
+ public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPayload(bool $writeCleanupThrows, ?bool $deleteCleanupThrows): void
{
$payload = json_encode(['uuid' => 'canceled-write'], JSON_THROW_ON_ERROR);
$path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'canceled-write';
$cancellation = new CanceledException;
+ if ($writeCleanupThrows) {
+ $cancellation = new RuntimeException('write cleanup failed', previous: $cancellation);
+ }
+ $cleanupCancellation = new CanceledException;
+ if ($deleteCleanupThrows) {
+ $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation);
+ }
+
+ $handler = m::mock(ExceptionHandler::class);
+ $handler->shouldReceive('report')->with($cleanupCancellation)->times($deleteCleanupThrows ? 1 : 0);
+ Container::getInstance()->instance(ExceptionHandler::class, $handler);
$store = m::mock(CacheRepository::class);
$store->expects('put')->with($path, $payload)->andThrow($cancellation);
- $store->expects('forget')->with($path)->andReturnTrue();
+ if ($deleteCleanupThrows === null) {
+ $store->expects('forget')->with($path)->andReturnTrue();
+ } else {
+ $store->expects('forget')->with($path)->andThrow($cleanupCancellation);
+ }
$cache = m::mock(CacheFactory::class);
$cache->expects('store')->with('database')->andReturn($store);
@@ -1273,11 +1359,25 @@ public function testPushRawPreservesCancellationAndCleansTheAttemptedOverflowPay
try {
$queue->pushRaw($payload, $this->queueName);
$this->fail('Expected the overflow write cancellation to be rethrown.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
}
+ /**
+ * Provide native cancellations and errors thrown while cancellation unwinds.
+ */
+ public static function overflowCancellationProvider(): array
+ {
+ return [
+ 'successful cleanup' => [false, null],
+ 'native write and delete' => [false, false],
+ 'chained write' => [true, false],
+ 'chained delete' => [false, true],
+ 'chained write and delete' => [true, true],
+ ];
+ }
+
public function testPushRawRetainsOverflowPayloadWhenSqsDeliveryIsAmbiguous(): void
{
$payload = json_encode(['uuid' => 'ambiguous'], JSON_THROW_ON_ERROR);
@@ -1655,6 +1755,30 @@ public function testBulkRaisesQueueingAndQueuedEventsForEachJob(): void
$this->assertSame(['mid-0', 'mid-1'], array_map(fn (JobQueued $event): mixed => $event->id, array_values($queuedEvents)));
}
+ public function testBulkNormalizesEnumQueueNameInEvents(): void
+ {
+ $container = new Container;
+ $events = new ConcreteEventDispatcher($container);
+ $container->instance('events', $events);
+ $queues = [];
+
+ $events->listen([JobQueueing::class, JobQueued::class], function (JobQueueing|JobQueued $event) use (&$queues): void {
+ $queues[] = $event->queue;
+ });
+
+ $queue = new SqsQueue($this->sqs, $this->queueName, $this->prefix);
+ $queue->setContainer($container);
+ $queue->setConnectionName('sqs');
+ $this->sqs->expects('sendMessageBatch')->andReturn(new Result([
+ 'Successful' => [['Id' => '0', 'MessageId' => 'mid-0']],
+ 'Failed' => [],
+ ]));
+
+ $queue->bulk(['a'], 'data', SqsQueueName::Emails);
+
+ $this->assertSame([$this->queueName, $this->queueName], $queues);
+ }
+
public function testBulkHonoursPerJobDelay(): void
{
$jobA = new FakeSqsJob;
@@ -1993,7 +2117,8 @@ static function (object $event) use (&$dispatched): object {
$this->assertSame($failed[0]->exception, $failed[10]->exception);
}
- public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers(): void
+ #[DataProvider('overflowCancellationProvider')]
+ public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers(bool $writeCleanupThrows, ?bool $deleteCleanupThrows): void
{
$firstPayload = json_encode(['uuid' => 'first'], JSON_THROW_ON_ERROR);
$secondPayload = json_encode(['uuid' => 'second'], JSON_THROW_ON_ERROR);
@@ -2001,11 +2126,25 @@ public function testBulkPreservesWriteCancellationWhileDrainingAttemptedPointers
$secondPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'second';
$cancellation = new CanceledException('write canceled');
$cleanupCancellation = new CanceledException('cleanup canceled');
+ if ($writeCleanupThrows) {
+ $cancellation = new RuntimeException('write cleanup failed', previous: $cancellation);
+ }
+ if ($deleteCleanupThrows) {
+ $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation);
+ }
+
+ $handler = m::mock(ExceptionHandler::class);
+ $handler->shouldReceive('report')->with($cleanupCancellation)->times($deleteCleanupThrows ? 1 : 0);
+ Container::getInstance()->instance(ExceptionHandler::class, $handler);
$store = m::mock(CacheRepository::class);
$store->expects('put')->with($firstPath, $firstPayload)->ordered()->andReturnTrue();
$store->expects('put')->with($secondPath, $secondPayload)->ordered()->andThrow($cancellation);
- $store->expects('forget')->with($firstPath)->ordered()->andThrow($cleanupCancellation);
+ if ($deleteCleanupThrows === null) {
+ $store->expects('forget')->with($firstPath)->ordered()->andReturnTrue();
+ } else {
+ $store->expects('forget')->with($firstPath)->ordered()->andThrow($cleanupCancellation);
+ }
$store->expects('forget')->with($secondPath)->ordered()->andReturnTrue();
$cache = m::mock(CacheFactory::class);
@@ -2052,7 +2191,7 @@ static function (object $event) use (&$dispatched): object {
try {
$queue->bulk(['a', 'b'], 'data', $this->queueName);
$this->fail('Expected the overflow write cancellation to be rethrown.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
@@ -2062,13 +2201,18 @@ static function (object $event) use (&$dispatched): object {
);
}
- public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(bool $cleanupThrows): void
{
$firstPayload = json_encode(['uuid' => 'first'], JSON_THROW_ON_ERROR);
$secondPayload = json_encode(['uuid' => 'second'], JSON_THROW_ON_ERROR);
$firstPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'first';
$secondPath = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'second';
$cleanupCancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cleanupCancellation = new RuntimeException('delete cleanup failed', previous: $cleanupCancellation);
+ }
$store = m::mock(CacheRepository::class);
$store->expects('put')->with($firstPath, $firstPayload)->ordered()->andReturnTrue();
@@ -2105,16 +2249,21 @@ public function testBulkCleanupCancellationSupersedesAnOrdinaryWriteFailure(): v
try {
$queue->bulk(['a', 'b'], 'data', $this->queueName);
$this->fail('Expected cleanup cancellation to supersede the write failure.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cleanupCancellation, $exception);
}
}
- public function testBulkRetainsPointersAndSuppressesFailureEventsWhenSqsDeliveryIsCanceled(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBulkRetainsPointersAndSuppressesFailureEventsWhenSqsDeliveryIsCanceled(bool $cleanupThrows): void
{
$payload = json_encode(['uuid' => 'canceled-batch'], JSON_THROW_ON_ERROR);
$path = SqsQueue::EXTENDED_PAYLOAD_CACHE_PREFIX . 'canceled-batch';
$cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('transport cleanup failed', previous: $cancellation);
+ }
$store = m::mock(CacheRepository::class);
$store->expects('put')->with($path, $payload)->andReturnTrue();
@@ -2161,7 +2310,7 @@ static function (object $event) use (&$dispatched): object {
try {
$queue->bulk(['a'], 'data', $this->queueName);
$this->fail('Expected the SQS delivery cancellation to be rethrown.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
@@ -2620,3 +2769,8 @@ public function testPopPassesOverflowStorageOptionsToJob(): void
class SqsBulkAttributeDelayJob
{
}
+
+enum SqsQueueName: string
+{
+ case Emails = 'emails';
+}
diff --git a/tests/Queue/SqsBatchLifecycleTest.php b/tests/Queue/SqsBatchLifecycleTest.php
index 8c4343b430..3215a76d1e 100644
--- a/tests/Queue/SqsBatchLifecycleTest.php
+++ b/tests/Queue/SqsBatchLifecycleTest.php
@@ -19,6 +19,8 @@
use Hypervel\Contracts\Queue\Queue as QueueContract;
use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Database\DatabaseTransactionsManager;
+use Hypervel\Engine\Channel;
+use Hypervel\Engine\Coroutine as EngineCoroutine;
use Hypervel\Events\Dispatcher;
use Hypervel\Foundation\Bus\PendingDispatch;
use Hypervel\Queue\Events\JobPayloadFinalizing;
@@ -31,6 +33,8 @@
use Mockery as m;
use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
class SqsBatchLifecycleTest extends TestCase
{
@@ -94,6 +98,38 @@ public function testPreparationFailureTerminatesEveryOutstandingMember(string $e
$this->assertSame([$exception, $exception, $exception], array_column($failed, 'exception'));
}
+ #[TestWith([JobPayloadFinalizing::class])]
+ #[TestWith([JobQueueing::class])]
+ public function testPreparationCancellationDoesNotNotifyOutstandingMembers(string $eventClass): void
+ {
+ [$queue, $client, $events] = $this->queue();
+ $cleanup = new RuntimeException('Preparation cleanup failed.');
+ $gate = $this->armCurrentCoroutineCancellation();
+ $events->listen($eventClass, static function () use ($gate, $cleanup): never {
+ try {
+ $gate->push(true);
+ } finally {
+ throw $cleanup;
+ }
+ });
+ $failed = [];
+ $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void {
+ $failed[] = $event;
+ });
+ $client->shouldNotReceive('sendMessageBatch');
+
+ try {
+ $queue->bulk(['first', 'second']);
+ $this->fail('Expected preparation cancellation.');
+ } catch (Throwable $caught) {
+ $this->assertSame($cleanup, $caught);
+ $this->assertInstanceOf(CanceledException::class, $caught->getPrevious());
+ $this->assertSame([], $failed);
+ } finally {
+ $gate->close();
+ }
+ }
+
public function testFailureNotificationsDrainAndRetainThePrimaryAttemptError(): void
{
[$queue, $client, $events] = $this->queue();
@@ -117,7 +153,54 @@ public function testFailureNotificationsDrainAndRetainThePrimaryAttemptError():
$this->assertSame([$primary, $primary], array_column($failed, 'exception'));
}
- public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExplicitRejects(): void
+ #[TestWith([JobQueued::class])]
+ #[TestWith([JobQueueingFailed::class])]
+ public function testNotificationCancellationStopsAfterAnEarlierOrdinaryFailure(string $eventClass): void
+ {
+ [$queue, $client, $events] = $this->queue();
+ if ($eventClass === JobQueued::class) {
+ $client->expects('sendMessageBatch')->andReturn(new Result([
+ 'Successful' => [
+ ['Id' => '0', 'MessageId' => 'first'],
+ ['Id' => '1', 'MessageId' => 'second'],
+ ['Id' => '2', 'MessageId' => 'third'],
+ ],
+ ]));
+ } else {
+ $client->expects('sendMessageBatch')->andThrow(new RuntimeException('Transport failed.'));
+ }
+ $cleanup = new RuntimeException('Notification cleanup failed.');
+ $gate = $this->armCurrentCoroutineCancellation();
+ $notified = [];
+ $events->listen($eventClass, static function (JobQueued|JobQueueingFailed $event) use ($gate, $cleanup, &$notified): void {
+ $notified[] = $event->job;
+ if ($event->job === 'first') {
+ throw new RuntimeException('First listener failed.');
+ }
+ if ($event->job === 'second') {
+ try {
+ $gate->push(true);
+ } finally {
+ throw $cleanup;
+ }
+ }
+ });
+
+ try {
+ $queue->bulk(['first', 'second', 'third']);
+ $this->fail('Expected notification cancellation.');
+ } catch (Throwable $caught) {
+ $this->assertSame($cleanup, $caught);
+ $this->assertInstanceOf(CanceledException::class, $caught->getPrevious());
+ $this->assertSame(['first', 'second'], $notified);
+ } finally {
+ $gate->close();
+ }
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExplicitRejects(bool $failureListenerCanceled): void
{
[$queue, $client, $events, $container] = $this->queue(['enabled' => true, 'always' => true, 'store' => 'overflow']);
$store = new Repository(new WorkerArrayStore);
@@ -135,23 +218,27 @@ public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExpli
});
$queued = $failed = [];
$listener = new RuntimeException('Monitoring failed.');
+ $cancellation = new RuntimeException('Failure listener cleanup failed.', previous: new CanceledException);
$events->listen(JobQueued::class, static function (JobQueued $event) use (&$queued): void {
$queued[] = $event->job;
});
$events->listen(JobQueued::class, static fn () => throw $listener);
- $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void {
+ $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed, $failureListenerCanceled, $cancellation): void {
$failed[] = $event->job;
+ if ($failureListenerCanceled) {
+ throw $cancellation;
+ }
});
try {
$queue->bulk(array_map('strval', range(1, 11)));
$this->fail('The monitoring failure should be rethrown.');
} catch (RuntimeException $caught) {
- $this->assertSame($listener, $caught);
+ $this->assertSame($failureListenerCanceled ? $cancellation : $listener, $caught);
}
$this->assertSame(array_map('strval', range(1, 9)), $queued);
- $this->assertSame(['10', '11'], $failed);
+ $this->assertSame($failureListenerCanceled ? ['10'] : ['10', '11'], $failed);
$this->assertFalse($store->has($paths[9]));
foreach (array_slice($paths, 0, 9) as $path) {
@@ -159,6 +246,23 @@ public function testSuccessListenerFailurePreservesAcceptedEntriesAndCleansExpli
}
}
+ /**
+ * Arm cancellation at a controlled channel handoff.
+ */
+ private function armCurrentCoroutineCancellation(): Channel
+ {
+ $gate = new Channel(1);
+ $coroutineId = EngineCoroutine::id();
+
+ EngineCoroutine::create(static function () use ($coroutineId, $gate): void {
+ if ($gate->pop(5)) {
+ EngineCoroutine::cancelById($coroutineId, throwException: true);
+ }
+ });
+
+ return $gate;
+ }
+
/**
* Create a queue with real lifecycle dispatch and a controlled SDK boundary.
*
From b736724bd935cb6509f085ea5ceb8f4ef3fc126e Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:11 +0000
Subject: [PATCH 18/34] fix(queue): propagate database dispatch and recovery
cancellation
Recognize cancellation carried by cleanup exceptions during database bulk dispatch, outbox publication and failed reservation recovery. Cancellation must escape without emitting ordinary queue failure events, returning a failed batch result, or restoring an earlier reservation error over the cancellation.
Keep ordinary insertion and reservation failures unchanged. Extend the existing reservation and real-cancellation bulk tests, and cover publication cancellation through a real SQLite query callback with no stored jobs or open transaction left behind.
Validation: complete database queue and outbox test files, affected parallel tests on isolated SQLite, formatting and full static analysis pass. The chained cases fail before the correction.
---
src/queue/src/DatabaseQueue.php | 22 +++++++----
.../Outbox/DatabaseOutboxPublicationTest.php | 28 ++++++++++++++
tests/Queue/QueueDatabaseQueueUnitTest.php | 37 ++++++++++++++-----
3 files changed, 70 insertions(+), 17 deletions(-)
diff --git a/src/queue/src/DatabaseQueue.php b/src/queue/src/DatabaseQueue.php
index b533b63346..ac189dc2fa 100644
--- a/src/queue/src/DatabaseQueue.php
+++ b/src/queue/src/DatabaseQueue.php
@@ -11,6 +11,7 @@
use Hypervel\Contracts\Queue\Job;
use Hypervel\Contracts\Queue\PublishesOutboxMessages;
use Hypervel\Contracts\Queue\Queue as QueueContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\ConnectionInterface;
use Hypervel\Database\ConnectionResolverInterface;
use Hypervel\Database\DatabaseTransactionsManager;
@@ -27,7 +28,6 @@
use Hypervel\Queue\Outbox\OutboxMessage;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Support\Collection;
-use Swoole\Coroutine\CanceledException;
use Throwable;
use UnitEnum;
@@ -426,9 +426,11 @@ protected function enqueueBatch(array $jobs, ?string $queue): mixed
}
$result = true;
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
throw $this->notifyBatchMembers(
$jobs,
fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception),
@@ -500,9 +502,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult
}
$this->insertDatabaseRows($connection, $this->table, $rows);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return new OutboxBatchResult(failure: $exception);
}
@@ -590,9 +594,11 @@ public function pop(UnitEnum|string|null $queue = null): ?Job
$this->connectionName,
$queue
))->fail($exception);
- } catch (CanceledException $cancellation) {
- throw $cancellation;
- } catch (Throwable) {
+ } catch (Throwable $cleanupException) {
+ if (Coroutine::causedByCancellation($cleanupException)) {
+ throw $cleanupException;
+ }
+
// Preserve the original reservation failure if failing the job also fails.
}
}
diff --git a/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php b/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php
index ea569ab7bc..25d4e89b63 100644
--- a/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php
+++ b/tests/Queue/Outbox/DatabaseOutboxPublicationTest.php
@@ -17,6 +17,10 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PDO;
+use PHPUnit\Framework\Attributes\TestWith;
+use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
class DatabaseOutboxPublicationTest extends TestCase
{
@@ -58,6 +62,30 @@ public function testARejectedLaterChunkRollsBackEveryInsertedMessage(): void
$this->assertSame(0, $database->transactionLevel());
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCancellationIsNotReturnedAsAPublicationFailure(bool $cleanupThrows): void
+ {
+ [$queue, $database] = $this->queue();
+ $cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Insert cleanup failed.', previous: $cancellation);
+ }
+ $database->beforeExecuting(static fn () => throw $cancellation);
+
+ try {
+ $queue->publishOutboxMessages([$this->message(1, 'orders')]);
+ $this->fail('Expected publication cancellation to propagate.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ } finally {
+ $database->clearBeforeExecutingCallbacks();
+ }
+
+ $this->assertSame(0, $database->table('jobs')->count());
+ $this->assertSame(0, $database->transactionLevel());
+ }
+
/**
* Create a real target jobs table with a two-row binding limit.
*/
diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php
index da1049d096..adbe017b25 100644
--- a/tests/Queue/QueueDatabaseQueueUnitTest.php
+++ b/tests/Queue/QueueDatabaseQueueUnitTest.php
@@ -51,6 +51,7 @@
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use ReflectionClass;
use RuntimeException;
use stdClass;
@@ -153,14 +154,14 @@ public static function transientReservationFailureProvider(): array
}
#[DataProvider('reservationCleanupFailureProvider')]
- public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure): void
+ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(Throwable $cleanupFailure, bool $canceled): void
{
$failure = new QueryException('database', 'update jobs', [], new PDOException('Reservation failed.'));
[$queue, $events] = $this->createFailingReservationQueue($failure);
$queue->shouldReceive('causedByReservationQuery')->once()->andReturn(true);
$queue->shouldReceive('deleteReserved')->once()->with('default', '1')->andThrow($cleanupFailure);
- if ($cleanupFailure instanceof CanceledException) {
+ if ($canceled) {
$events->shouldReceive('dispatch')->never();
} else {
$events->shouldReceive('hasListeners')->once()->with(JobFailed::class)->andReturn(false);
@@ -170,7 +171,7 @@ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(
$queue->pop();
$this->fail('Expected the reservation or cancellation failure.');
} catch (Throwable $exception) {
- $this->assertSame($cleanupFailure instanceof CanceledException ? $cleanupFailure : $failure, $exception);
+ $this->assertSame($canceled ? $cleanupFailure : $failure, $exception);
}
}
@@ -180,8 +181,9 @@ public function testReservationRecoveryPreservesFailureOrPropagatesCancellation(
public static function reservationCleanupFailureProvider(): array
{
return [
- 'ordinary failure' => [new RuntimeException('Deletion failed.')],
- 'cancellation' => [new CanceledException('Deletion canceled.')],
+ 'ordinary failure' => [new RuntimeException('Deletion failed.'), false],
+ 'cancellation' => [new CanceledException('Deletion canceled.'), true],
+ 'cleanup during cancellation' => [new RuntimeException('Deletion cleanup failed.', previous: new CanceledException), true],
];
}
@@ -724,7 +726,9 @@ public function testBulkRaisesExactFailureEventsWhenInsertThrows(): void
$this->assertSame($exception, $events[3]->exception);
}
- public function testBulkCancellationDoesNotRaiseFailureEvents(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBulkCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void
{
$queue = new TestDatabaseQueue(
resolver: $resolver = m::mock(ConnectionResolverInterface::class),
@@ -742,8 +746,15 @@ public function testBulkCancellationDoesNotRaiseFailureEvents(): void
$connection->shouldReceive('table')->with('table')->andReturn($query = m::mock(Builder::class));
$resolver->shouldReceive('connection')->with(null)->andReturn($connection)->once();
$gate = $this->armCurrentCoroutineCancellation();
- $query->shouldReceive('insert')->once()->andReturnUsing(static function () use ($gate): never {
- $gate->push(true);
+ $cleanup = new RuntimeException('Insert cleanup failed.');
+ $query->shouldReceive('insert')->once()->andReturnUsing(static function () use ($gate, $cleanup, $cleanupThrows): never {
+ try {
+ $gate->push(true);
+ } finally {
+ if ($cleanupThrows) {
+ throw $cleanup;
+ }
+ }
throw new RuntimeException('Cancellation was not delivered.');
});
@@ -759,11 +770,19 @@ public function testBulkCancellationDoesNotRaiseFailureEvents(): void
try {
$queue->bulk(['first', 'second'], queue: 'emails');
$this->fail('Expected cancellation to escape the bulk insert.');
- } catch (CanceledException) {
+ } catch (Throwable $exception) {
+ if ($cleanupThrows) {
+ $this->assertSame($cleanup, $exception);
+ $this->assertInstanceOf(CanceledException::class, $exception->getPrevious());
+ } else {
+ $this->assertInstanceOf(CanceledException::class, $exception);
+ }
$this->assertSame([
JobQueueing::class,
JobQueueing::class,
], array_map(static fn (object $event): string => $event::class, $events));
+ } finally {
+ $gate->close();
}
}
From b10b063d4cd6569855c1a305345bec972e0bb183 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:19 +0000
Subject: [PATCH 19/34] fix(queue): retain cancellation through Redis batch
publication
Let errors carrying cancellation escape Redis bulk dispatch and outbox publication. Previously a cleanup exception could trigger failure events or become an ordinary OutboxBatchResult even though the operation had been canceled.
Preserve confirmation and failure handling for ordinary Redis errors. Extend the existing later-destination failure test with cancellation variants, and verify bulk cancellation does not notify failed jobs.
Validation: complete Redis queue and outbox publication tests, affected parallel suites, formatting and full static analysis pass; chained cancellation regressions fail before the fix.
---
src/queue/src/RedisQueue.php | 14 +++++---
.../Outbox/RedisOutboxPublicationTest.php | 24 +++++++++++--
tests/Queue/QueueRedisQueueTest.php | 35 +++++++++++++++++++
3 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/src/queue/src/RedisQueue.php b/src/queue/src/RedisQueue.php
index 4e02a5416d..7069558d39 100644
--- a/src/queue/src/RedisQueue.php
+++ b/src/queue/src/RedisQueue.php
@@ -12,6 +12,7 @@
use Hypervel\Contracts\Queue\PublishesOutboxMessages;
use Hypervel\Contracts\Queue\Queue as QueueContract;
use Hypervel\Contracts\Redis\Factory as Redis;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\Queue\Jobs\InspectedJob;
use Hypervel\Queue\Jobs\RedisJob;
@@ -22,7 +23,6 @@
use Hypervel\Support\Collection;
use Hypervel\Support\Str;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
use UnitEnum;
@@ -433,9 +433,11 @@ protected function enqueueBatch(array $jobs, ?string $queue): void
$this->isCluster ??= $connection->isCluster();
$queueKey = $this->getQueueRedisKey($queue);
$this->pushRawBatch($connection, $queueKey, $arguments);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
throw $this->notifyBatchMembers(
$jobs,
fn (array $job) => $this->raiseJobQueueingFailedEvent($queue, $job['job'], $job['payload'], $job['delay'], $exception),
@@ -503,9 +505,11 @@ public function publishOutboxMessages(array $messages): OutboxBatchResult
throw $failure;
}
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return new OutboxBatchResult($accepted, failure: $exception);
}
diff --git a/tests/Queue/Outbox/RedisOutboxPublicationTest.php b/tests/Queue/Outbox/RedisOutboxPublicationTest.php
index 0ad0b723ed..742986de54 100644
--- a/tests/Queue/Outbox/RedisOutboxPublicationTest.php
+++ b/tests/Queue/Outbox/RedisOutboxPublicationTest.php
@@ -22,7 +22,10 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
class RedisOutboxPublicationTest extends TestCase
{
@@ -56,15 +59,30 @@ public static function clusterModes(): array
return [[false], [true]];
}
- public function testLaterDestinationFailurePreservesEarlierConfirmation(): void
+ #[TestWith(['ordinary'])]
+ #[TestWith(['cancellation'])]
+ #[TestWith(['cleanup'])]
+ public function testLaterDestinationFailurePreservesEarlierConfirmation(string $failureType): void
{
[$queue, $connection] = $this->queue();
- $failure = new RuntimeException('Redis connection lost.');
+ $failure = match ($failureType) {
+ 'cancellation' => new CanceledException,
+ 'cleanup' => new RuntimeException('Redis cleanup failed.', previous: new CanceledException),
+ default => new RuntimeException('Redis connection lost.'),
+ };
$connection->expects('evalWithShaCache')->withArgs(fn (string $script, array $keys): bool => $keys[0] === 'queues:first')->andReturn(1);
$connection->expects('evalWithShaCache')->withArgs(fn (string $script, array $keys): bool => $keys[0] === 'queues:second')->andThrow($failure);
- $result = $queue->publishOutboxMessages([$this->message(1, 'first'), $this->message(2, 'second'), $this->message(3, 'third')]);
+ try {
+ $result = $queue->publishOutboxMessages([$this->message(1, 'first'), $this->message(2, 'second'), $this->message(3, 'third')]);
+ } catch (Throwable $exception) {
+ $this->assertNotSame('ordinary', $failureType);
+ $this->assertSame($failure, $exception);
+ return;
+ }
+
+ $this->assertSame('ordinary', $failureType, 'Cancellation must propagate instead of returning a batch result.');
$this->assertSame([1], $result->accepted);
$this->assertSame($failure, $result->failure);
}
diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php
index 42030a6712..55da0baf9f 100644
--- a/tests/Queue/QueueRedisQueueTest.php
+++ b/tests/Queue/QueueRedisQueueTest.php
@@ -33,8 +33,11 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
use Symfony\Component\Uid\Uuid;
+use Throwable;
class QueueRedisQueueTest extends TestCase
{
@@ -160,6 +163,38 @@ public function testBulkUsesOneClusterSafeLuaCall(): void
$this->assertNull($queue->bulk(['first', 'second']));
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBulkCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void
+ {
+ $cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Redis cleanup failed.', previous: $cancellation);
+ }
+ $connection = $this->mockRedisProxyWithShaCache();
+ $connection->expects('isCluster')->andReturnFalse();
+ $connection->expects('evalWithShaCache')->andThrow($cancellation);
+ $redis = m::mock(Redis::class);
+ $redis->expects('connection')->with('default')->andReturn($connection);
+ $queue = new RedisQueue($redis, 'default', 'default');
+ $events = new EventDispatcher($container = new Container);
+ $container->instance('events', $events);
+ $queue->setContainer($container)->setConnectionName('redis');
+ $failed = [];
+ $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void {
+ $failed[] = $event;
+ });
+
+ try {
+ $queue->bulk(['first', 'second']);
+ $this->fail('Expected bulk cancellation to propagate.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ }
+
+ $this->assertSame([], $failed);
+ }
+
public function testEmptyBulkDoesNotResolveRedis(): void
{
$redis = m::mock(Redis::class);
From 62672896316bf9598bbaaec1c278536aa65756af Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:28 +0000
Subject: [PATCH 20/34] fix(queue): stop outbox staging when cleanup carries
cancellation
Apply the existing cancellation-chain classification to both single-job and batch outbox staging. A finalization or storage cleanup error carrying cancellation must propagate without emitting JobQueueingFailed for the canceled work.
Keep ordinary staging failure notifications and accepted-job ownership unchanged. Add single and bulk regressions asserting the original exception escapes, no failure event is emitted and no outbox row is stored.
Validation: complete OutboxDispatchTest and the queue parallel suite pass on isolated in-memory SQLite; formatting and full static analysis also pass.
---
src/queue/src/Concerns/StagesOutboxJobs.php | 14 +++++++----
tests/Queue/Outbox/OutboxDispatchTest.php | 28 +++++++++++++++++++++
2 files changed, 37 insertions(+), 5 deletions(-)
diff --git a/src/queue/src/Concerns/StagesOutboxJobs.php b/src/queue/src/Concerns/StagesOutboxJobs.php
index 35c2406fbb..cde9962fe9 100644
--- a/src/queue/src/Concerns/StagesOutboxJobs.php
+++ b/src/queue/src/Concerns/StagesOutboxJobs.php
@@ -8,6 +8,7 @@
use DateTimeInterface;
use Hypervel\Context\CoroutineContext;
use Hypervel\Contracts\Queue\ShouldQueueViaOutbox;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\ConnectionResolverInterface;
use Hypervel\Database\DatabaseTransactionsManager;
use Hypervel\Queue\InvalidPayloadException;
@@ -15,7 +16,6 @@
use Hypervel\Support\Str;
use JsonException;
use LogicException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
trait StagesOutboxJobs
@@ -104,9 +104,11 @@ protected function enqueueViaOutbox(object|string $job, string $payload, ?string
$row = $this->prepareOutboxRow($job, $payload, $queue, $delay);
$this->raiseJobQueueingEvent($queue, $job, $payload, $delay);
$dispatcher->store($connection, [$row], [$job]);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception);
throw $exception;
@@ -186,9 +188,11 @@ protected function stageOutboxJobs(array $jobs, mixed $data, ?string $queue): ar
throw $failure;
}
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$failure = $this->notifyBatchMembers($prepared, function (array $member) use ($queue, $exception): void {
$this->raiseJobQueueingFailedEvent($queue, $member['job'], $member['payload'], $member['delay'], $exception);
});
diff --git a/tests/Queue/Outbox/OutboxDispatchTest.php b/tests/Queue/Outbox/OutboxDispatchTest.php
index 402eade6a0..52e2d1d9a7 100644
--- a/tests/Queue/Outbox/OutboxDispatchTest.php
+++ b/tests/Queue/Outbox/OutboxDispatchTest.php
@@ -32,7 +32,10 @@
use Pheanstalk\Contract\PheanstalkPublisherInterface;
use Pheanstalk\Contract\PheanstalkSubscriberInterface;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
+use Swoole\Coroutine\CanceledException;
+use Throwable;
use UnitEnum;
class OutboxDispatchTest extends TestCase
@@ -203,6 +206,31 @@ public function testBulkPreparationFailureStoresNothingAndTerminatesEveryMember(
$this->assertSame(0, DB::table('job_outbox')->count());
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testStagingCancellationDoesNotNotifyFailureOrStoreJobs(bool $bulk): void
+ {
+ $queue = $this->queue();
+ $cancellation = new RuntimeException('Finalization cleanup failed.', previous: new CanceledException);
+ $events = $this->app->make('events');
+ $events->listen(JobPayloadFinalizing::class, static fn () => throw $cancellation);
+ $failed = [];
+ $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void {
+ $failed[] = $event;
+ });
+ $job = (new OutboxDispatchJob)->viaOutbox();
+
+ try {
+ $bulk ? $queue->bulk([$job, clone $job]) : $queue->push($job);
+ $this->fail('Expected staging cancellation to propagate.');
+ } catch (Throwable $exception) {
+ $this->assertSame($cancellation, $exception);
+ }
+
+ $this->assertSame([], $failed);
+ $this->assertSame(0, DB::table('job_outbox')->count());
+ }
+
public function testAcceptedBulkIsNotFailedWhenItsQueuedListenerThrows(): void
{
$queue = $this->queue();
From 2210ce62e072a1f26d31a856cd872fc9fb2a823f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:41 +0000
Subject: [PATCH 21/34] fix(queue): propagate cancellation from sequential
outbox delivery
The fallback for queue drivers without native batch publication must not convert cancellation hidden by throwing cleanup into an ordinary batch failure. Use the existing cancellation classifier before recording a failed result.
Extend the pooled-publication regression to prove that the exact chained error escapes and the borrowed transport is released. Ordinary partial confirmation and deferred-message behavior remain unchanged.
Validation: the full pooled-publication test file and affected queue and pool suites pass, along with formatting and full static analysis.
---
.../Concerns/PublishesOutboxMessagesSequentially.php | 8 +++++---
tests/Queue/Outbox/PooledOutboxPublicationTest.php | 11 +++++++++--
2 files changed, 14 insertions(+), 5 deletions(-)
diff --git a/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php b/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php
index be8fa02f75..f927c3dd57 100644
--- a/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php
+++ b/src/queue/src/Outbox/Concerns/PublishesOutboxMessagesSequentially.php
@@ -5,10 +5,10 @@
namespace Hypervel\Queue\Outbox\Concerns;
use Hypervel\Contracts\Queue\Queue;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Queue\Outbox\OutboxBatchResult;
use Hypervel\Queue\Outbox\OutboxMessage;
use Hypervel\Support\InteractsWithTime;
-use Swoole\Coroutine\CanceledException;
use Throwable;
trait PublishesOutboxMessagesSequentially
@@ -40,9 +40,11 @@ protected function publishOutboxMessagesSequentially(Queue $queue, array $messag
$queue->pushRaw($message->payload, $message->queue, $message->options);
$accepted[] = $message->id;
}
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return new OutboxBatchResult($accepted, $deferred, $exception);
}
diff --git a/tests/Queue/Outbox/PooledOutboxPublicationTest.php b/tests/Queue/Outbox/PooledOutboxPublicationTest.php
index fc6ee1909f..f2a200430b 100644
--- a/tests/Queue/Outbox/PooledOutboxPublicationTest.php
+++ b/tests/Queue/Outbox/PooledOutboxPublicationTest.php
@@ -16,8 +16,10 @@
use Hypervel\Support\Str;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
+use Throwable;
class PooledOutboxPublicationTest extends TestCase
{
@@ -65,10 +67,15 @@ public function testSequentialFallbackRetainsConfirmedPrefixAndDefersFutureRows(
}
}
- public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellation(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellation(bool $cleanupThrows): void
{
$queue = m::mock(SyncQueue::class)->makePartial();
$cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Transport cleanup failed.', previous: $cancellation);
+ }
$queue->expects('pushRaw')->andThrow($cancellation);
$released = 0;
$pools = new PoolManager;
@@ -78,7 +85,7 @@ public function testCancellationReleasesTheBorrowedTransportAndRemainsCancellati
try {
$proxy->publishOutboxMessages([$this->message(1)]);
$this->fail('Expected publication to remain cancelled.');
- } catch (CanceledException $exception) {
+ } catch (Throwable $exception) {
$this->assertSame($cancellation, $exception);
}
From 9fee2154351b9c7ba34af78d12b0b7e9b869aee3 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:49 +0000
Subject: [PATCH 22/34] fix(queue): preserve cancellation across outbox relay
phases
Recognize cleanup exceptions carrying cancellation before turning publication or settlement errors into ordinary outcomes or reporting listener failures. Preserve the explicit stop behavior for an independently canceled child.
Keep each phase inside its existing Waiter boundary, which finishes owned cleanup and distinguishes child cancellation from cancellation of the waiting coroutine. Extend the independent-phase regression across sequential delivery, native batch publication, settlement and notification.
Validation: the complete OutboxRelayTest and queue parallel suite pass, with exact causes and resource cleanup checked. Formatting and full static analysis pass.
---
src/queue/src/Outbox/OutboxRelay.php | 20 +++++++++-----
tests/Queue/Outbox/OutboxRelayTest.php | 38 ++++++++++++++++++++++----
2 files changed, 46 insertions(+), 12 deletions(-)
diff --git a/src/queue/src/Outbox/OutboxRelay.php b/src/queue/src/Outbox/OutboxRelay.php
index 9659b14936..034b6807ae 100644
--- a/src/queue/src/Outbox/OutboxRelay.php
+++ b/src/queue/src/Outbox/OutboxRelay.php
@@ -9,6 +9,7 @@
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Contracts\Queue\Factory;
use Hypervel\Contracts\Queue\PublishesOutboxMessages;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Coroutine\Exceptions\ChildCancellationException;
use Hypervel\Coroutine\Waiter;
use Hypervel\Queue\Events\OutboxBatchDelivered;
@@ -16,7 +17,6 @@
use Hypervel\Queue\Outbox\Concerns\PublishesOutboxMessagesSequentially;
use LogicException;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
class OutboxRelay
@@ -71,9 +71,11 @@ public function deliver(string $database, array $messages): bool
return $queue instanceof PublishesOutboxMessages
? $queue->publishOutboxMessages($group)
: $this->publishOutboxMessagesSequentially($queue, $group);
- } catch (CanceledException|ChildCancellationException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return new OutboxBatchResult(failure: $exception);
}
});
@@ -86,9 +88,11 @@ public function deliver(string $database, array $messages): bool
}
return null;
- } catch (CanceledException|ChildCancellationException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
return $exception;
}
});
@@ -185,9 +189,11 @@ protected function reportOutcome(
try {
$this->events->dispatch($event);
- } catch (CanceledException|ChildCancellationException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if ($exception instanceof ChildCancellationException || Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$listenerFailure = $exception;
}
}
diff --git a/tests/Queue/Outbox/OutboxRelayTest.php b/tests/Queue/Outbox/OutboxRelayTest.php
index dd935d07a1..2b8c0e00e0 100644
--- a/tests/Queue/Outbox/OutboxRelayTest.php
+++ b/tests/Queue/Outbox/OutboxRelayTest.php
@@ -23,6 +23,7 @@
use LogicException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
@@ -254,22 +255,49 @@ public static function reportingOutcomes(): array
return [[false], [true]];
}
- public function testIndependentPhaseCancellationReleasesResourcesAndStopsWithoutSettlement(): void
+ #[TestWith(['sequential', false])]
+ #[TestWith(['sequential', true])]
+ #[TestWith(['publication', true])]
+ #[TestWith(['settlement', true])]
+ #[TestWith(['notification', true])]
+ public function testIndependentPhaseCancellationReleasesResourcesAndStopsDelivery(string $phase, bool $cleanupThrows): void
{
$repository = m::mock(DatabaseOutboxRepository::class);
$queues = m::mock(Factory::class);
- $queue = m::mock(Queue::class);
+ $queue = m::mock($phase === 'sequential' ? Queue::class : Queue::class . ', ' . PublishesOutboxMessages::class);
$exceptions = m::mock(ExceptionHandler::class);
$cancellation = new CanceledException;
+ if ($cleanupThrows) {
+ $cancellation = new RuntimeException('Phase cleanup failed.', previous: $cancellation);
+ }
$released = false;
$queues->expects('connection')->andReturn($queue);
- $queue->expects('pushRaw')->andReturnUsing(static function () use ($cancellation, &$released): never {
+ $cancel = static function () use ($cancellation, &$released): never {
Coroutine::defer(static function () use (&$released): void { $released = true; });
throw $cancellation;
- });
+ };
+ $events = new Dispatcher;
+
+ if ($phase === 'sequential') {
+ $queue->expects('pushRaw')->andReturnUsing($cancel);
+ } elseif ($phase === 'publication') {
+ $queue->expects('publishOutboxMessages')->andReturnUsing($cancel);
+ } else {
+ $queue->expects('publishOutboxMessages')->andReturn(new OutboxBatchResult([1]));
+ if ($phase === 'settlement') {
+ $repository->expects('settle')->andReturnUsing($cancel);
+ } else {
+ $repository->expects('settle')->andReturn(1);
+ $events->listen(OutboxBatchDelivered::class, $cancel);
+ }
+ }
+ $messages = [$this->message(1)];
+ if ($phase !== 'notification') {
+ $messages[] = $this->message(2, 'other');
+ }
try {
- (new OutboxRelay($repository, $queues, new Dispatcher, $exceptions))->deliver('primary', [$this->message(1), $this->message(2, 'other')]);
+ (new OutboxRelay($repository, $queues, $events, $exceptions))->deliver('primary', $messages);
$this->fail('Child cancellation must stop delivery.');
} catch (ChildCancellationException $exception) {
$this->assertSame($cancellation, $exception->getPrevious());
From a91186c37945edbe0817f4b13441ddbb27d93cb1 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:21:57 +0000
Subject: [PATCH 23/34] fix(queue): retain cancellation during SQS job deletion
cleanup
Treat cancellation carried by a lease-release cleanup error like native cancellation, so deletion does not start additional overflow cleanup afterward. If overflow cleanup is itself canceled after an ordinary release error, propagate the cancellation-bearing error unchanged.
Preserve ordinary release and overflow failure reporting. Extend both existing cancellation regressions with chained cleanup errors while retaining their backend and cache-call expectations.
Validation: complete QueueSqsJobTest, affected queue and pool suites, formatting and full static analysis pass.
---
src/queue/src/Jobs/SqsJob.php | 14 +++++++++-----
tests/Queue/QueueSqsJobTest.php | 15 +++++++++++++--
2 files changed, 22 insertions(+), 7 deletions(-)
diff --git a/src/queue/src/Jobs/SqsJob.php b/src/queue/src/Jobs/SqsJob.php
index 8f8dbb8f11..31a59541a5 100644
--- a/src/queue/src/Jobs/SqsJob.php
+++ b/src/queue/src/Jobs/SqsJob.php
@@ -8,10 +8,10 @@
use Hypervel\Contracts\Cache\Factory as CacheFactory;
use Hypervel\Contracts\Cache\Repository as CacheRepository;
use Hypervel\Contracts\Container\Container;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\ObjectPool\PoolErrorReporter;
use Hypervel\Support\Arr;
use RuntimeException;
-use Swoole\Coroutine\CanceledException;
use Throwable;
class SqsJob extends Job
@@ -79,17 +79,21 @@ public function delete(): void
try {
$this->releasePoolLease();
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$releaseException = $exception;
}
try {
$this->deleteOverflowPayload();
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $cleanupException) {
+ if (Coroutine::causedByCancellation($cleanupException)) {
+ throw $cleanupException;
+ }
+
if ($releaseException !== null) {
PoolErrorReporter::report($cleanupException);
diff --git a/tests/Queue/QueueSqsJobTest.php b/tests/Queue/QueueSqsJobTest.php
index 957691c160..4a2385519f 100644
--- a/tests/Queue/QueueSqsJobTest.php
+++ b/tests/Queue/QueueSqsJobTest.php
@@ -19,6 +19,7 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use stdClass;
use Swoole\Coroutine\CanceledException;
@@ -434,9 +435,14 @@ public function testDeleteCleansOverflowPayloadAfterLeaseReleaseFails(): void
}
}
- public function testDeleteDoesNotStartOverflowCleanupAfterLeaseReleaseCancellation(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDeleteDoesNotStartOverflowCleanupAfterLeaseReleaseCancellation(bool $cleanupThrows): void
{
$releaseCancellation = new CanceledException('release canceled');
+ if ($cleanupThrows) {
+ $releaseCancellation = new RuntimeException('Release cleanup failed.', previous: $releaseCancellation);
+ }
[, $lease] = $this->lease(
releaseCallback: static fn () => throw $releaseCancellation,
);
@@ -550,10 +556,15 @@ function (Throwable $exception) use (&$cleanupFailure): bool {
$this->assertSame("Unable to delete the SQS overflow payload [{$pointer}].", $cleanupFailure->getMessage());
}
- public function testOverflowCleanupCancellationSupersedesAnOrdinaryLeaseReleaseFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testOverflowCleanupCancellationSupersedesAnOrdinaryLeaseReleaseFailure(bool $cleanupThrows): void
{
$releaseFailure = new Exception('release failed');
$cleanupCancellation = new CanceledException('overflow cleanup canceled');
+ if ($cleanupThrows) {
+ $cleanupCancellation = new RuntimeException('Overflow cleanup failed.', previous: $cleanupCancellation);
+ }
[, $lease] = $this->lease(
releaseCallback: static fn () => throw $releaseFailure,
);
From c96fcdce839285b1bfef97bcacb4b4454f016ec7 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:22:06 +0000
Subject: [PATCH 24/34] fix(queue): avoid requeueing after canceled lease
attachment
A throwing cleanup callback can hide cancellation during job lease attachment or recovery. Recognize the chain before attempting a backend requeue, and retain recovery cancellation instead of reporting it and replacing it with the earlier attachment failure.
Use the existing lease discard and failure finalizers to release pool ownership. Extend both attachment regressions with chained errors and assert that canceled attachment never requeues and that pool capacity is released.
Validation: complete QueuePoolProxyTest, affected queue and pool parallel suites, formatting and full static analysis pass.
---
src/queue/src/QueuePoolProxy.php | 13 +++++++++----
tests/Queue/QueuePoolProxyTest.php | 15 +++++++++++++--
2 files changed, 22 insertions(+), 6 deletions(-)
diff --git a/src/queue/src/QueuePoolProxy.php b/src/queue/src/QueuePoolProxy.php
index ad94e2ca3d..d2de1d46aa 100644
--- a/src/queue/src/QueuePoolProxy.php
+++ b/src/queue/src/QueuePoolProxy.php
@@ -12,6 +12,7 @@
use Hypervel\Contracts\Queue\Job;
use Hypervel\Contracts\Queue\PublishesOutboxMessages;
use Hypervel\Contracts\Queue\Queue as QueueContract;
+use Hypervel\Coroutine\Coroutine;
use Hypervel\Database\ConnectionResolver;
use Hypervel\ObjectPool\Lease;
use Hypervel\ObjectPool\PoolDefinition;
@@ -298,14 +299,18 @@ public function pop(UnitEnum|string|null $queue = null, int $index = 0): ?Job
try {
return $job->withPoolLease($lease);
- } catch (CanceledException $attachmentCancellation) {
- $lease->discardAfterFailure($attachmentCancellation);
} catch (Throwable $attachmentException) {
+ if (Coroutine::causedByCancellation($attachmentException)) {
+ $lease->discardAfterFailure($attachmentException);
+ }
+
try {
$job->release(0);
- } catch (CanceledException $recoveryCancellation) {
- $lease->discardAfterFailure($recoveryCancellation);
} catch (Throwable $recoveryException) {
+ if (Coroutine::causedByCancellation($recoveryException)) {
+ $lease->discardAfterFailure($recoveryException);
+ }
+
PoolErrorReporter::report($recoveryException);
$lease->discardAfterFailure($attachmentException);
diff --git a/tests/Queue/QueuePoolProxyTest.php b/tests/Queue/QueuePoolProxyTest.php
index 5df7333ed4..87ebac8682 100644
--- a/tests/Queue/QueuePoolProxyTest.php
+++ b/tests/Queue/QueuePoolProxyTest.php
@@ -34,6 +34,7 @@
use Pheanstalk\Contract\PheanstalkPublisherInterface;
use Pheanstalk\Contract\PheanstalkSubscriberInterface;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Throwable;
@@ -456,10 +457,15 @@ public static function beanstalkAttachmentFailureDataProvider(): array
];
}
- public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure(bool $cleanupThrows): void
{
$attachmentException = new Exception('stats failed');
$recoveryCancellation = new CanceledException('release canceled');
+ if ($cleanupThrows) {
+ $recoveryCancellation = new RuntimeException('Recovery cleanup failed.', previous: $recoveryCancellation);
+ }
$pheanstalk = m::mock(implode(',', [
PheanstalkManagerInterface::class,
PheanstalkPublisherInterface::class,
@@ -493,9 +499,14 @@ public function testAttachmentRecoveryCancellationSupersedesTheAttachmentFailure
$this->assertSame(0, $pool->getManagedCount());
}
- public function testAttachmentCancellationDoesNotStartBackendRecovery(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testAttachmentCancellationDoesNotStartBackendRecovery(bool $cleanupThrows): void
{
$attachmentCancellation = new CanceledException('stats canceled');
+ if ($cleanupThrows) {
+ $attachmentCancellation = new RuntimeException('Attachment cleanup failed.', previous: $attachmentCancellation);
+ }
$pheanstalk = m::mock(implode(',', [
PheanstalkManagerInterface::class,
PheanstalkPublisherInterface::class,
From 9c23d3c744f0882c6716fafe834b1e218c675de6 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 20:22:13 +0000
Subject: [PATCH 25/34] docs: align package README section ordering
Move the upstream reference below Differences From Laravel in the collections, concurrency and events READMEs, matching the package documentation convention. Preserve all existing explanations and links.
Validation: reviewed the three documentation diffs and checked whitespace.
---
src/collections/README.md | 4 ++--
src/concurrency/README.md | 4 ++--
src/events/README.md | 4 ++--
3 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/src/collections/README.md b/src/collections/README.md
index 1851458d66..37f52128b5 100644
--- a/src/collections/README.md
+++ b/src/collections/README.md
@@ -5,8 +5,6 @@ Collections for Hypervel
Documentation: https://hypervel.org/docs/collections
-Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Collections
-
## Differences From Laravel
Laravel's deprecated `containsOneItem()` and `containsManyItems()` aliases are intentionally not ported. Use `hasSole()` and `hasMany()` instead.
@@ -14,3 +12,5 @@ Laravel's deprecated `containsOneItem()` and `containsManyItems()` aliases are i
`Enumerable::random()` exposes the optional `preserveKeys` argument supported by both concrete collection implementations and reports the preserved key type accurately. Laravel exposes this argument only on the concrete classes and always reports integer keys through the contract.
`Arr::push()` accepts arrays only. Laravel also advertises `ArrayAccess`, but dot notation requires nested by-reference mutation that `ArrayAccess` cannot provide.
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Collections
diff --git a/src/concurrency/README.md b/src/concurrency/README.md
index a0069d9a3c..e575350db8 100644
--- a/src/concurrency/README.md
+++ b/src/concurrency/README.md
@@ -5,8 +5,8 @@ Concurrency for Hypervel
Documentation: https://hypervel.org/docs/concurrency
-Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Concurrency
-
## Differences From Laravel
Hypervel uses its Swoole coroutine driver by default; the process and sync drivers remain available explicitly. Laravel's fork driver is omitted because coroutine concurrency is Hypervel's native lightweight execution model.
+
+Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Concurrency
diff --git a/src/events/README.md b/src/events/README.md
index 397557ad65..ded3c48222 100644
--- a/src/events/README.md
+++ b/src/events/README.md
@@ -5,8 +5,8 @@ Events for Hypervel
Documentation: https://hypervel.org/docs/events
-Ported from: https://github.com/laravel/framework
-
## Differences From Laravel
Hypervel queued listeners accept the current array payload produced by the event dispatcher. Laravel's pre-2017 serialized-string compatibility path is intentionally omitted because Hypervel 0.4 has no legacy listener payloads and every supported producer uses the array form.
+
+Ported from: https://github.com/laravel/framework
From c7e6a0682355f0099eee9a413a44ec7e752f3261 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:12 +0000
Subject: [PATCH 26/34] Release only dispatch locks owned by scoped queue fakes
Reconstructing a unique lock from a recorded job could release a lock acquired by another dispatch. Capture the existing dispatch ownership before the fake accepts the job and release that snapshot, including debounce state and its original release context.
Scoped fake helpers now clean up the active fake when a callback replaces it, while restoring the original manager even if cleanup throws. Keep the public cleanup method and replace the internal job list with owned dispatch snapshots.
Regression coverage verifies unrelated direct pushes, replacement fakes, debounce cleanup and manager restoration. Formatting, static analysis and the affected queue and fake suites pass.
---
src/support/src/Facades/Queue.php | 12 ++++---
src/support/src/Testing/Fakes/QueueFake.php | 34 ++++++++-----------
tests/Integration/Queue/DebouncedJobTest.php | 17 ++++++++++
tests/Integration/Queue/UniqueJobTest.php | 27 +++++++++++----
tests/Support/SupportTestingQueueFakeTest.php | 16 +++++++++
5 files changed, 76 insertions(+), 30 deletions(-)
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index db520ad686..7ae78b56d1 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -153,13 +153,15 @@ public static function fakeFor(callable $callable, array $jobsToFake = []): mixe
{
$originalQueueManager = static::getFacadeRoot();
- $fake = static::fake($jobsToFake);
+ static::fake($jobsToFake);
try {
return $callable();
} finally {
try {
- $fake->releaseUniqueJobLocks();
+ if (($fake = static::getFacadeRoot()) instanceof QueueFake) {
+ $fake->releaseUniqueJobLocks();
+ }
} finally {
static::swap($originalQueueManager);
}
@@ -173,13 +175,15 @@ public static function fakeExceptFor(callable $callable, array $jobsToAllow = []
{
$originalQueueManager = static::getFacadeRoot();
- $fake = static::fakeExcept($jobsToAllow);
+ static::fakeExcept($jobsToAllow);
try {
return $callable();
} finally {
try {
- $fake->releaseUniqueJobLocks();
+ if (($fake = static::getFacadeRoot()) instanceof QueueFake) {
+ $fake->releaseUniqueJobLocks();
+ }
} finally {
static::swap($originalQueueManager);
}
diff --git a/src/support/src/Testing/Fakes/QueueFake.php b/src/support/src/Testing/Fakes/QueueFake.php
index 4254dbf277..fe5e6bea42 100644
--- a/src/support/src/Testing/Fakes/QueueFake.php
+++ b/src/support/src/Testing/Fakes/QueueFake.php
@@ -9,13 +9,10 @@
use DateInterval;
use DateTimeInterface;
use Hypervel\Bus\DispatchLockContext;
-use Hypervel\Bus\UniqueLock;
-use Hypervel\Contracts\Cache\Repository as Cache;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Queue\Factory as FactoryContract;
use Hypervel\Contracts\Queue\Job;
use Hypervel\Contracts\Queue\Queue;
-use Hypervel\Contracts\Queue\ShouldBeUnique;
use Hypervel\Events\CallQueuedListener;
use Hypervel\Queue\Attributes\Delay;
use Hypervel\Queue\Attributes\ReadsQueueAttributes;
@@ -33,6 +30,7 @@
/**
* @phpstan-type RawPushType array{payload: string, queue: ?string, options: array}
+ * @phpstan-import-type LockSnapshot from DispatchLockContext
*/
class QueueFake extends QueueManager implements Fake, Queue
{
@@ -68,9 +66,11 @@ class QueueFake extends QueueManager implements Fake, Queue
protected array $rawPushes = [];
/**
- * All of the unique jobs that were pushed.
+ * The dispatch locks owned by faked jobs.
+ *
+ * @var list
*/
- protected array $uniqueJobs = [];
+ protected array $dispatchLocks = [];
/**
* All of the jobs that have been marked as reserved.
@@ -621,12 +621,12 @@ protected function enqueueUsing(
'delay' => $delay,
];
- if ($job instanceof ShouldBeUnique
- || ($job instanceof CallQueuedListener && $job->shouldBeUnique())) {
- $this->uniqueJobs[] = $job;
- }
-
if (is_object($job)) {
+ // Retain acquired ownership; reconstructing locks from jobs can release another dispatch's lock.
+ if (($snapshot = DispatchLockContext::snapshot($job)) !== null) {
+ $this->dispatchLocks[] = $snapshot;
+ }
+
DispatchLockContext::accept($job);
}
@@ -809,21 +809,15 @@ protected function serializeAndRestoreJob(mixed $job): mixed
}
/**
- * Release the locks for all unique jobs that were pushed.
+ * Release the dispatch locks owned by faked jobs.
*/
public function releaseUniqueJobLocks(): void
{
- if (empty($this->uniqueJobs)) {
- return;
- }
-
- $lock = new UniqueLock($this->app->make(Cache::class));
-
- foreach ($this->uniqueJobs as $job) {
- $lock->release($job);
+ foreach ($this->dispatchLocks as $snapshot) {
+ DispatchLockContext::releaseSnapshot($snapshot);
}
- $this->uniqueJobs = [];
+ $this->dispatchLocks = [];
}
/**
diff --git a/tests/Integration/Queue/DebouncedJobTest.php b/tests/Integration/Queue/DebouncedJobTest.php
index 332cebcece..9bd5659268 100644
--- a/tests/Integration/Queue/DebouncedJobTest.php
+++ b/tests/Integration/Queue/DebouncedJobTest.php
@@ -203,6 +203,23 @@ public function testQueueFakeCapturesDebouncedJob(): void
Queue::assertPushed(DebouncedTestJob::class);
}
+ public function testScopedQueueFakeReleasesDebounceState(): void
+ {
+ $job = new DebouncedWithMaxWaitJob('entity-1');
+ $cache = $this->app->get(Cache::class);
+ $key = DebounceLock::getKey($job);
+
+ Queue::fakeFor(function () use ($job, $cache, $key): void {
+ dispatch($job);
+
+ $this->assertTrue($cache->has($key));
+ $this->assertTrue($cache->has($key . ':first_dispatched_at'));
+ });
+
+ $this->assertNull($cache->get($key));
+ $this->assertNull($cache->get($key . ':first_dispatched_at'));
+ }
+
public function testBusFakeRetainsDebounceMaximumWaitState(): void
{
Bus::fake();
diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php
index bca3cf79ca..aac6ba9dfb 100644
--- a/tests/Integration/Queue/UniqueJobTest.php
+++ b/tests/Integration/Queue/UniqueJobTest.php
@@ -4,7 +4,9 @@
namespace Hypervel\Tests\Integration\Queue\UniqueJobTest;
+use Closure;
use Exception;
+use Hypervel\Bus\DispatchLockContext;
use Hypervel\Bus\Queueable;
use Hypervel\Bus\UniqueLock;
use Hypervel\Cache\Repository as CacheRepository;
@@ -28,7 +30,6 @@
use Hypervel\Testbench\Attributes\WithMigration;
use Hypervel\Testbench\Factories\UserFactory;
use Hypervel\Tests\Integration\Queue\QueueTestCase;
-use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
@@ -374,6 +375,21 @@ public function testQueueFakeExceptForReleasesUniqueJobLocks(): void
);
}
+ #[TestWith(['fakeFor'])]
+ #[TestWith(['fakeExceptFor'])]
+ public function testScopedQueueFakeReleasesReplacementFakesLocks(string $method): void
+ {
+ Queue::{$method}(function (): void {
+ Queue::fake();
+ UniqueTestJob::dispatch();
+ Queue::assertPushed(UniqueTestJob::class);
+ });
+
+ $this->assertTrue(
+ $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()
+ );
+ }
+
#[TestWith(['fakeFor'])]
#[TestWith(['fakeExceptFor'])]
public function testScopedQueueFakeRestoresManagerWhenLockCleanupThrows(string $method): void
@@ -381,14 +397,13 @@ public function testScopedQueueFakeRestoresManagerWhenLockCleanupThrows(string $
$manager = Queue::getFacadeRoot();
$failure = new RuntimeException('Cache unavailable during lock cleanup.');
$caught = null;
+ DispatchLockContext::captureReleaseContextUsing(
+ static fn (): Closure => static fn (): never => throw $failure
+ );
try {
- Queue::{$method}(function () use ($failure): void {
+ Queue::{$method}(function (): void {
UniqueTestJob::dispatch();
-
- $cache = m::mock(Cache::class);
- $cache->shouldReceive('getStore')->once()->andThrow($failure);
- $this->app->instance(Cache::class, $cache);
});
} catch (RuntimeException $exception) {
$caught = $exception;
diff --git a/tests/Support/SupportTestingQueueFakeTest.php b/tests/Support/SupportTestingQueueFakeTest.php
index 7cd3ea2f19..c285caaac5 100644
--- a/tests/Support/SupportTestingQueueFakeTest.php
+++ b/tests/Support/SupportTestingQueueFakeTest.php
@@ -554,6 +554,22 @@ public function testFakedUniqueJobAcceptsDispatchOwnershipUntilFakeCleanup(): vo
$this->assertTrue($lock->acquire(new QueueFakeUniqueJobStub));
}
+ public function testDirectPushDoesNotReleaseAnotherDispatchesUniqueLock(): void
+ {
+ $application = new Application;
+ $cache = new Repository(new WorkerArrayStore);
+ $application->instance(CacheContract::class, $cache);
+ $fake = new QueueFake($application);
+ $lock = new UniqueLock($cache);
+
+ $this->assertTrue($lock->acquire(new QueueFakeUniqueJobStub));
+
+ $fake->push(new QueueFakeUniqueJobStub);
+ $fake->releaseUniqueJobLocks();
+
+ $this->assertFalse($lock->acquire(new QueueFakeUniqueJobStub));
+ }
+
public function testItCanInvokeCallbacksBeforeAndAfterPushingFakedJobs(): void
{
$steps = [];
From cbf7b5402217251bc0756c67981db521e11a3cda Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:13 +0000
Subject: [PATCH 27/34] Preserve model values and binary binding intent during
pagination
Chunk and lazy paging retain raw stored values while accepting cursor aliases supplied by accessors or query callbacks. Prepare binary bindings using the declared alias or the model's own paging column without changing the existing extraction override.
Cursor pagination now reverses binary casts at the database binding boundary, keeping encoded cursors readable and preserving explicit BinaryParameter values. Pivot ordering uses the pivot's qualified casts. Compound Eloquent unions accept their nested base-query builders without changing protected extension points or introducing shared state.
The same builder now creates forced models directly, avoiding a throwaway model that consumed pending attribute values through mutators twice. The binary query example handles PostgreSQL streams explicitly.
Regression tests cover computed and selected aliases, forward and backward binary cursors, compound unions, pivot casts and a consumed-stream mutator. The complete binary and cursor integration suites pass on PostgreSQL, MySQL and MariaDB; builder tests, formatting and static analysis also pass.
---
src/database/src/Concerns/BuildsQueries.php | 58 +++++++++++----
src/database/src/Eloquent/Builder.php | 15 +++-
.../src/Eloquent/Concerns/HasAttributes.php | 16 +++-
.../src/Eloquent/Relations/BelongsToMany.php | 9 +++
src/docs/database.md | 4 +-
.../Database/DatabaseEloquentBuilderTest.php | 74 +++++++++++++++++++
.../DatabaseEloquentWithAttributesTest.php | 36 +++++++++
...atabaseEloquentAsBinaryIntegrationTest.php | 58 +++++++++++++++
.../Database/EloquentCursorPaginateTest.php | 34 +++++++++
9 files changed, 288 insertions(+), 16 deletions(-)
diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php
index 306d1a505a..9addfb7cbc 100644
--- a/src/database/src/Concerns/BuildsQueries.php
+++ b/src/database/src/Concerns/BuildsQueries.php
@@ -206,7 +206,7 @@ public function orderedChunkById(int $count, callable $callback, ?string $column
return false;
}
- $lastId = $this->getLastIdFromChunk($results->last(), $alias);
+ $lastId = $this->getNextPageIdFromChunk($results->last(), $column, $alias);
if ($lastId === null) {
throw new RuntimeException("The chunkById operation was aborted because the [{$alias}] column is not present in the query result.");
@@ -369,7 +369,7 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null
return;
}
- $lastId = $this->getLastIdFromChunk($results->last(), $alias);
+ $lastId = $this->getNextPageIdFromChunk($results->last(), $column, $alias);
if ($lastId === null) {
throw new RuntimeException("The lazyById operation was aborted because the [{$alias}] column is not present in the query result.");
@@ -387,11 +387,34 @@ protected function orderedLazyById(int $chunkSize = 1000, ?string $column = null
*/
protected function getLastIdFromChunk(mixed $result, string $alias): mixed
{
- return $result instanceof Model
- ? $result->prepareBinaryAttributesForDatabase([$alias => $result->getRawOriginal($alias)])[$alias]
+ if (! $result instanceof Model) {
+ return data_get($result, $alias);
+ }
+
+ // Callbacks and accessors can provide cursor aliases that are not selected columns.
+ return array_key_exists($alias, $original = $result->getRawOriginal())
+ ? $original[$alias]
: data_get($result, $alias);
}
+ /**
+ * Prepare the last chunk ID for comparison with the paging column.
+ */
+ protected function getNextPageIdFromChunk(mixed $result, string $column, string $alias): mixed
+ {
+ $lastId = $this->getLastIdFromChunk($result, $alias);
+
+ if (! $result instanceof Model) {
+ return $lastId;
+ }
+
+ // Either the selected alias or the model's paging column can declare binary binding intent.
+ $attribute = Str::chopStart($column, $result->getTable() . '.');
+ $lastId = $result->prepareBinaryAttributesForDatabase([$alias => $lastId])[$alias];
+
+ return $result->prepareBinaryAttributesForDatabase([$attribute => $lastId])[$attribute];
+ }
+
/**
* Execute the query and get the first result.
*
@@ -469,8 +492,9 @@ protected function paginateUsingCursor(
// Reset the union bindings so we can add the cursor where in the correct position...
$this->setBindings([], 'union');
- $addCursorConditions = function (self $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) {
- $unionBuilders = $builder->getUnionBuilders();
+ $addCursorConditions = function (QueryBuilder|Builder $builder, $previousColumn, $originalColumn, $i) use (&$addCursorConditions, $cursor, $orders) {
+ // Unions are base queries; the other builder family only appears in nested constraints without unions.
+ $unionBuilders = $builder instanceof self ? $builder->getUnionBuilders() : new Collection;
if (! is_null($previousColumn)) {
$originalColumn ??= $this->getOriginalColumnNameForCursorPagination($this, $previousColumn);
@@ -478,21 +502,21 @@ protected function paginateUsingCursor(
$builder->where(
Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
'=',
- $cursor->parameter($previousColumn)
+ $this->getCursorParameterBinding($cursor, $previousColumn)
);
$unionBuilders->each(function ($unionBuilder) use ($previousColumn, $cursor) {
$unionBuilder->where(
$this->getOriginalColumnNameForCursorPagination($unionBuilder, $previousColumn),
'=',
- $cursor->parameter($previousColumn)
+ $this->getCursorParameterBinding($cursor, $previousColumn)
);
$this->addBinding($unionBuilder->getRawBindings()['where'], 'union');
});
}
- $builder->where(function (self $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) {
+ $builder->where(function (QueryBuilder|Builder $secondBuilder) use ($addCursorConditions, $cursor, $orders, $i, $unionBuilders) {
['column' => $column, 'direction' => $direction] = $orders[$i];
$originalColumn = $this->getOriginalColumnNameForCursorPagination($this, $column);
@@ -500,11 +524,11 @@ protected function paginateUsingCursor(
$secondBuilder->where(
Str::contains($originalColumn, ['(', ')']) ? new Expression($originalColumn) : $originalColumn,
$direction === 'asc' ? '>' : '<',
- $cursor->parameter($column)
+ $this->getCursorParameterBinding($cursor, $column)
);
if ($i < $orders->count() - 1) {
- $secondBuilder->orWhere(function (self $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
+ $secondBuilder->orWhere(function (QueryBuilder|Builder $thirdBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
$addCursorConditions($thirdBuilder, $column, $originalColumn, $i + 1);
});
}
@@ -517,11 +541,11 @@ protected function paginateUsingCursor(
$unionBuilder->where(
$originalColumn,
$direction === 'asc' ? '>' : '<',
- $cursor->parameter($column)
+ $this->getCursorParameterBinding($cursor, $column)
);
if ($i < $orders->count() - 1) {
- $unionBuilder->orWhere(function (self $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
+ $unionBuilder->orWhere(function (QueryBuilder|Builder $fourthBuilder) use ($addCursorConditions, $column, $originalColumn, $i) {
$addCursorConditions($fourthBuilder, $column, $originalColumn, $i + 1);
});
}
@@ -545,6 +569,14 @@ protected function paginateUsingCursor(
]);
}
+ /**
+ * Get a cursor parameter for database binding.
+ */
+ protected function getCursorParameterBinding(Cursor $cursor, string $parameter): mixed
+ {
+ return $cursor->parameter($parameter);
+ }
+
/**
* Get the original column name of the given column, without any aliasing.
*
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 17324d11e4..c07a9163bc 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -1186,6 +1186,19 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false):
->values();
}
+ /**
+ * Get a cursor parameter for database binding.
+ */
+ protected function getCursorParameterBinding(Cursor $cursor, string $parameter): mixed
+ {
+ // Reverse the cast that produced the readable cursor, preserving qualified pivot casts.
+ $key = $this->model->hasCast($parameter)
+ ? $parameter
+ : Str::chopStart($parameter, $this->model->getTable() . '.');
+
+ return $this->model->encodeBinaryAttributeForDatabase($key, $cursor->parameter($parameter));
+ }
+
/**
* Save a new model and return the instance.
*
@@ -1216,7 +1229,7 @@ public function createQuietly(array $attributes = []): Model
public function forceCreate(array $attributes): Model
{
return $this->model::unguarded(function () use ($attributes) {
- return $this->newModelInstance()->create(array_merge($this->pendingAttributes, $attributes));
+ return $this->create($attributes);
});
}
diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php
index 09cee774ff..345f829ea3 100644
--- a/src/database/src/Eloquent/Concerns/HasAttributes.php
+++ b/src/database/src/Eloquent/Concerns/HasAttributes.php
@@ -1954,7 +1954,7 @@ private function isBinaryCast(?string $cast): bool
}
/**
- * Prepare binary-cast attributes for database binding.
+ * Wrap raw binary attribute bytes or streams for database binding.
*
* @param array $attributes
* @return array
@@ -1978,6 +1978,20 @@ public function prepareBinaryAttributesForDatabase(array $attributes): array
return $attributes;
}
+ /**
+ * Encode a binary attribute's cast value, such as a UUID string, for database binding.
+ */
+ public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mixed
+ {
+ if ($value instanceof BinaryParameter || ! $this->isBinaryCast($this->getCasts()[$key] ?? null)) {
+ return $value;
+ }
+
+ $bytes = $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes)[$key];
+
+ return is_string($bytes) ? new BinaryParameter($bytes) : $bytes;
+ }
+
/**
* Set the array of model attributes. No checking is done.
*/
diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php
index fd142dca43..5bc99afb19 100644
--- a/src/database/src/Eloquent/Relations/BelongsToMany.php
+++ b/src/database/src/Eloquent/Relations/BelongsToMany.php
@@ -1058,6 +1058,15 @@ public function cursorPaginate(
): mixed {
$this->query->addSelect($this->shouldSelect($columns));
+ $casts = [];
+
+ // Cursor parameters from the pivot must be encoded with the pivot's casts, not the related model's.
+ foreach ($this->newPivot()->getCasts() as $key => $cast) {
+ $casts[$this->qualifyPivotColumn($key)] = $cast;
+ }
+
+ $this->query->withCasts($casts);
+
return tap($this->query->cursorPaginate($perPage, $columns, $cursorName, $cursor), function ($paginator) {
$this->hydratePivotRelation($paginator->items());
});
diff --git a/src/docs/database.md b/src/docs/database.md
index 4803e733c0..973204b0bd 100644
--- a/src/docs/database.md
+++ b/src/docs/database.md
@@ -758,7 +758,9 @@ use Hypervel\Support\Facades\DB;
DB::table('files')->afterQuery(function (Collection $rows) {
foreach ($rows as $row) {
- $row->cursor = new BinaryParameter($row->hash);
+ $row->cursor = is_resource($row->hash)
+ ? BinaryParameter::fromStream($row->hash)
+ : new BinaryParameter($row->hash);
}
})->chunkById(100, function (Collection $files) {
// Process the files...
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 71a8552854..d7c9f425fa 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -783,6 +783,69 @@ public function testLazyByIdUsesTheRawKeyOfCastModels(): void
$this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection));
}
+ #[TestWith(['chunkById', false])]
+ #[TestWith(['lazyById', false])]
+ #[TestWith(['chunkById', true])]
+ #[TestWith(['lazyById', true])]
+ public function testPagingUsesCursorAliasesAddedAfterHydration(string $method, bool $accessor): void
+ {
+ [, $builder] = $this->newPagedBuilder([1, 2, 3]);
+
+ if ($accessor) {
+ $builder->setModel(new CursorAliasStub);
+ } else {
+ $builder->afterQuery(static function (Collection $models): void {
+ foreach ($models as $model) {
+ $model->setAttribute('cursor', $model->getRawOriginal('id'));
+ }
+ });
+ }
+
+ $actual = [];
+
+ if ($method === 'lazyById') {
+ $actual = $builder->lazyById(2, 'id', 'cursor')->take(4)->pluck('id')->all();
+ } else {
+ $builder->chunkById(2, function (Collection $models) use (&$actual): bool {
+ array_push($actual, ...$models->pluck('id')->all());
+
+ return count($actual) < 4;
+ }, 'id', 'cursor');
+ }
+
+ $this->assertSame([1, 2, 3], $actual);
+ }
+
+ #[TestWith(['chunkById', 'id'])]
+ #[TestWith(['lazyById', 'id'])]
+ #[TestWith(['chunkById', 'table.id'])]
+ #[TestWith(['lazyById', 'table.id'])]
+ #[TestWith(['chunkById', 't.id', true])]
+ public function testPagingPreservesBinaryBindingsForSelectedAliases(string $method, string $column, bool $castAlias = false): void
+ {
+ [$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder();
+ $builder->select(['id', 'id as cursor']);
+
+ if ($castAlias) {
+ $builder->from('table as t')->withCasts(['cursor' => AsBinary::uuid()]);
+ }
+
+ $actual = [];
+
+ if ($method === 'lazyById') {
+ $actual = $builder->lazyById(2, $column, 'cursor')->take(4)->pluck('id')->all();
+ } else {
+ $builder->chunkById(2, function (Collection $models) use (&$actual): bool {
+ array_push($actual, ...$models->pluck('id')->all());
+
+ return count($actual) < 4;
+ }, $column, 'cursor');
+ }
+
+ $this->assertSame($ids, $actual);
+ $this->assertEquals([[], [new BinaryParameter(hex2bin(str_replace('-', '', $ids[1])))]], $this->pagedBindings($connection));
+ }
+
public function testPluckReturnsTheMutatedAttributesOfAModel(): void
{
$builder = $this->getBuilder();
@@ -4112,6 +4175,17 @@ class Stub extends Model
protected ?string $table = 'table';
}
+class CursorAliasStub extends Stub
+{
+ /**
+ * Return the raw key as an explicit paging cursor.
+ */
+ public function getCursorAttribute(): mixed
+ {
+ return $this->getRawOriginal('id');
+ }
+}
+
class BinaryUuidStub extends Model
{
protected ?string $table = 'table';
diff --git a/tests/Database/DatabaseEloquentWithAttributesTest.php b/tests/Database/DatabaseEloquentWithAttributesTest.php
index d6080dca8b..7bf95e6a23 100755
--- a/tests/Database/DatabaseEloquentWithAttributesTest.php
+++ b/tests/Database/DatabaseEloquentWithAttributesTest.php
@@ -129,6 +129,29 @@ public function testForceCreateAddsAttributesViaDb(): void
$this->assertSame(WithAttributesEnum::Internal, $model->type);
}
+ public function testForceCreateAppliesPendingAttributeMutatorsOnce(): void
+ {
+ $this->bootTable();
+ $stream = fopen('php://memory', 'w+');
+
+ try {
+ fwrite($stream, 'first');
+ rewind($stream);
+
+ $model = WithAttributesStreamModel::query()
+ ->withAttributes(['first_name' => $stream])
+ ->forceCreate([
+ 'last_name' => 'last',
+ 'is_admin' => false,
+ 'type' => WithAttributesEnum::Internal,
+ ]);
+
+ $this->assertSame('first', $model->fresh()->getRawOriginal('first_name'));
+ } finally {
+ fclose($stream);
+ }
+ }
+
protected function bootTable(): void
{
$this->schema()->create((new WithAttributesModel)->getTable(), function ($table) {
@@ -175,6 +198,19 @@ protected function lastName(): Attribute
}
}
+class WithAttributesStreamModel extends WithAttributesModel
+{
+ protected ?string $table = 'with_attributes_models';
+
+ /**
+ * Read the pending name from its stream.
+ */
+ public function setFirstNameAttribute(mixed $value): void
+ {
+ $this->attributes['first_name'] = stream_get_contents($value);
+ }
+}
+
enum WithAttributesEnum: string
{
case Internal = 'int';
diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
index 68bedd536e..da32281700 100644
--- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
+++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
@@ -13,6 +13,7 @@
use Hypervel\Database\Eloquent\SoftDeletes;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Foundation\Testing\RefreshDatabase;
+use Hypervel\Pagination\Cursor;
use Hypervel\Support\Facades\Schema;
use Hypervel\Support\Str;
use Hypervel\Testbench\TestCase;
@@ -240,6 +241,63 @@ public function testBinaryPrimaryKeysCanBePaged(string $method, bool $descending
$this->assertSame($descending ? array_reverse($ids) : $ids, $actual);
}
+ public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(): void
+ {
+ $ids = [
+ '00000000-0000-0000-0000-000000000001',
+ '00000000-0000-0000-0000-000000000002',
+ '00000000-0000-0000-0000-000000000003',
+ ];
+
+ foreach ($ids as $id) {
+ AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]);
+ }
+
+ $explicit = AsBinaryPrimaryKeyModel::query()->orderBy('id')->cursorPaginate(2, cursor: new Cursor([
+ 'id' => new BinaryParameter(Uuid::fromString($ids[1])->toBinary()),
+ ]));
+ $this->assertSame([$ids[2]], $explicit->pluck('id')->all());
+
+ $first = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2);
+ $next = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2, cursor: $first->nextCursor()->encode());
+
+ $this->assertSame([$ids[2]], $next->pluck('id')->all());
+
+ $previous = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2, cursor: $next->previousCursor()->encode());
+
+ $this->assertSame([$ids[0], $ids[1]], $previous->pluck('id')->all());
+ }
+
+ public function testCompoundCursorPaginationPreservesBinaryEqualityAcrossUnions(): void
+ {
+ foreach ([1, 2, 3] as $id) {
+ AsBinaryIdentifierModel::create([
+ 'uuid' => '00000000-0000-0000-0000-00000000000' . $id,
+ 'ulid' => $id < 3 ? '00000000000000000000000001' : '00000000000000000000000002',
+ ]);
+ }
+
+ $actual = [];
+ $cursor = null;
+
+ for ($page = 0; $page < 4; ++$page) {
+ $query = AsBinaryIdentifierModel::query()->where('id', 1)
+ ->unionAll(AsBinaryIdentifierModel::query()->where('id', 2))
+ ->unionAll(AsBinaryIdentifierModel::query()->where('id', 3))
+ ->orderBy('ulid')
+ ->orderBy('id');
+ $results = $query->cursorPaginate(1, cursor: $cursor);
+ array_push($actual, ...$results->pluck('id')->all());
+ $cursor = $results->nextCursor()?->encode();
+
+ if ($cursor === null) {
+ break;
+ }
+ }
+
+ $this->assertSame([1, 2, 3], $actual);
+ }
+
public function testBinaryPrimaryKeysPreserveBindingIntentAcrossModelOperations(): void
{
$primaryId = '21107c1e-6448-43c2-b80b-40491d165946';
diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php
index 77a91d53f8..b884195aaf 100644
--- a/tests/Integration/Database/EloquentCursorPaginateTest.php
+++ b/tests/Integration/Database/EloquentCursorPaginateTest.php
@@ -4,9 +4,12 @@
namespace Hypervel\Tests\Integration\Database;
+use Hypervel\Database\BinaryParameter;
+use Hypervel\Database\Eloquent\Casts\AsBinary;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\Relations\BelongsToMany;
use Hypervel\Database\Eloquent\Relations\HasManyThrough;
+use Hypervel\Database\Eloquent\Relations\Pivot;
use Hypervel\Database\Query\Expression;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Pagination\Cursor;
@@ -338,6 +341,32 @@ public function testCursorPaginationPreservesFloatOrderValues(): void
$this->assertSame(2.5, $result->items()[0]->score);
}
+ public function testCursorPaginationUsesTheBinaryPivotCast(): void
+ {
+ Schema::table('test_post_user', function (Blueprint $table): void {
+ $table->binary('position', length: 16, fixed: true)->nullable();
+ });
+ $user = TestUser::create();
+
+ foreach ([1, 2, 3] as $position) {
+ $post = TestPost::create();
+ DB::table('test_post_user')->insert([
+ 'user_id' => $user->id,
+ 'post_id' => $post->id,
+ 'position' => new BinaryParameter(hex2bin(str_pad((string) $position, 32, '0', STR_PAD_LEFT))),
+ ]);
+ }
+
+ $relation = fn (): BelongsToMany => $user->favoritePosts()
+ ->using(CursorBinaryPivot::class)
+ ->withPivot('position')
+ ->orderBy('test_post_user.position');
+ $first = $relation()->cursorPaginate(2);
+ $next = $relation()->cursorPaginate(2, cursor: $first->nextCursor()->encode());
+
+ $this->assertSame([3], $next->pluck('id')->all());
+ }
+
public function testRelationsAcceptCursorObjects(): void
{
TestOwner::fillAndInsert([[]]);
@@ -390,6 +419,11 @@ public function favoritePosts(): BelongsToMany
}
}
+class CursorBinaryPivot extends Pivot
+{
+ protected array $casts = ['position' => AsBinary::class . ':uuid'];
+}
+
class TestOwner extends Model
{
protected array $guarded = [];
From 175250682b971fd162b275254779b1d9f7b4878d Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:13 +0000
Subject: [PATCH 28/34] Set relationship keys before forced creation mutators
Pending attribute setters can derive values from the parent key or morph type. Put the relationship keys first when preparing forced creation attributes so those setters see the owning relationship, while retaining precedence over conflicting pending and explicit keys.
Cover ordinary and polymorphic relationships with a persisted derived parent reference. The relationship tests, formatting and static analysis pass.
---
.../src/Eloquent/Relations/HasOneOrMany.php | 6 +--
.../src/Eloquent/Relations/MorphOneOrMany.php | 9 ++--
...EloquentHasOneOrManyWithAttributesTest.php | 41 +++++++++++++++++++
3 files changed, 49 insertions(+), 7 deletions(-)
diff --git a/src/database/src/Eloquent/Relations/HasOneOrMany.php b/src/database/src/Eloquent/Relations/HasOneOrMany.php
index bc5820b74c..9da47cdef1 100755
--- a/src/database/src/Eloquent/Relations/HasOneOrMany.php
+++ b/src/database/src/Eloquent/Relations/HasOneOrMany.php
@@ -393,9 +393,9 @@ public function createQuietly(array $attributes = []): Model
*/
public function forceCreate(array $attributes = []): Model
{
- $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes);
-
- $attributes[$this->getForeignKeyName()] = $this->getParentKey();
+ // Pending-attribute mutators may read the relationship key.
+ $attributes = [$this->getForeignKeyName() => $this->getParentKey()]
+ + array_merge($this->getQuery()->pendingAttributes, $attributes);
return $this->applyInverseRelationToModel($this->related->forceCreate($attributes));
}
diff --git a/src/database/src/Eloquent/Relations/MorphOneOrMany.php b/src/database/src/Eloquent/Relations/MorphOneOrMany.php
index 49c4caa32e..b3eb34eae7 100644
--- a/src/database/src/Eloquent/Relations/MorphOneOrMany.php
+++ b/src/database/src/Eloquent/Relations/MorphOneOrMany.php
@@ -70,10 +70,11 @@ public function addEagerConstraints(array $models): void
*/
public function forceCreate(array $attributes = []): Model
{
- $attributes = array_merge($this->getQuery()->pendingAttributes, $attributes);
-
- $attributes[$this->getForeignKeyName()] = $this->getParentKey();
- $attributes[$this->getMorphType()] = $this->morphClass;
+ // Pending-attribute mutators may read the relationship keys.
+ $attributes = [
+ $this->getForeignKeyName() => $this->getParentKey(),
+ $this->getMorphType() => $this->morphClass,
+ ] + array_merge($this->getQuery()->pendingAttributes, $attributes);
return $this->applyInverseRelationToModel($this->related->forceCreate($attributes));
}
diff --git a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
index 1e83159ea8..63b8447483 100755
--- a/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
+++ b/tests/Database/DatabaseEloquentHasOneOrManyWithAttributesTest.php
@@ -8,6 +8,7 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
class DatabaseEloquentHasOneOrManyWithAttributesTest extends TestCase
{
@@ -323,6 +324,30 @@ public function testMorphManyForceCreateAddsAttributes(): void
$this->assertTrue($relatedModel->fresh()->is_admin);
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testForceCreateSetsRelationKeysBeforePendingAttributeMutators(bool $polymorphic): void
+ {
+ $this->createTable();
+ $parent = new RelatedWithAttributesModel;
+ $parent->id = 123;
+ $relation = $polymorphic
+ ? $parent->morphMany(RelatedWithParentAttributesModel::class, 'relatable')
+ : $parent->hasMany(RelatedWithParentAttributesModel::class, 'parent_id');
+ $foreignKey = $polymorphic ? 'relatable_id' : 'parent_id';
+
+ $model = $relation->withAttributes([
+ 'parent_reference' => true,
+ $foreignKey => 456,
+ ...($polymorphic ? ['relatable_type' => 'wrong-type'] : []),
+ ])->forceCreate([$foreignKey => 789]);
+
+ $this->assertSame(
+ ($polymorphic ? $parent::class : 'parent') . ':123',
+ $model->fresh()->parent_reference
+ );
+ }
+
/**
* Create the table used to verify persisted relationship attributes.
*/
@@ -333,6 +358,7 @@ protected function createTable(): void
$table->unsignedBigInteger('parent_id')->nullable();
$table->nullableMorphs('relatable');
$table->boolean('is_admin')->default(false);
+ $table->string('parent_reference')->nullable();
$table->timestamps();
});
}
@@ -346,3 +372,18 @@ class RelatedWithAttributesModel extends Model
'is_admin' => 'boolean',
];
}
+
+class RelatedWithParentAttributesModel extends RelatedWithAttributesModel
+{
+ protected ?string $table = 'related_with_attributes_models';
+
+ /**
+ * Derive the reference from the relationship keys.
+ */
+ public function setParentReferenceAttribute(bool $value): void
+ {
+ $type = $this->attributes['relatable_type'] ?? 'parent';
+ $key = $this->attributes['relatable_id'] ?? $this->attributes['parent_id'] ?? 'missing';
+ $this->attributes['parent_reference'] = "{$type}:{$key}";
+ }
+}
From 3359ca45fa9b6c623e97edc9f20e04a61cf75525 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:13 +0000
Subject: [PATCH 29/34] Reject unrelated models before reading relationship
keys
Relationship comparisons could read absent attributes on a model from another table or connection, raising strict-mode errors instead of returning false. Check table and connection identity before extracting comparison keys.
Move MorphOne's morph-type check into its comparison key helper so it follows the shared checks and retains one-of-many behavior. Regression coverage exercises strict missing-attribute handling across all shared relationship types. The affected relationship suites, formatting and static analysis pass.
---
.../Concerns/ComparesRelatedModels.php | 4 ++--
.../src/Eloquent/Relations/MorphOne.php | 19 +++++--------------
...abaseEloquentComparesRelatedModelsTest.php | 14 ++++++++++++++
3 files changed, 21 insertions(+), 16 deletions(-)
diff --git a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
index 720604f429..1f287f4000 100644
--- a/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
+++ b/src/database/src/Eloquent/Relations/Concerns/ComparesRelatedModels.php
@@ -16,9 +16,9 @@ trait ComparesRelatedModels
public function is(?Model $model): bool
{
$match = ! is_null($model)
- && $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model))
&& $this->related->getTable() === $model->getTable()
- && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName());
+ && ConnectionName::withoutWriteType($this->related->getConnectionName()) === ConnectionName::withoutWriteType($model->getConnectionName())
+ && $this->compareKeys($this->getParentKey(), $this->getRelatedKeyFrom($model));
if ($match && $this instanceof SupportsPartialRelations && $this->isOneOfMany()) {
return (clone $this->query)
diff --git a/src/database/src/Eloquent/Relations/MorphOne.php b/src/database/src/Eloquent/Relations/MorphOne.php
index 24eec4e8d7..16e3dfab23 100644
--- a/src/database/src/Eloquent/Relations/MorphOne.php
+++ b/src/database/src/Eloquent/Relations/MorphOne.php
@@ -22,9 +22,7 @@
class MorphOne extends MorphOneOrMany implements SupportsPartialRelations
{
use CanBeOneOfMany;
- use ComparesRelatedModels {
- is as protected isRelatedModel;
- }
+ use ComparesRelatedModels;
use SupportsDefaultModels;
public function getResults()
@@ -104,21 +102,14 @@ public function newRelatedInstanceFor(Model $parent): Model
}
/**
- * Determine if the model is the related instance of the relationship.
- */
- public function is(?Model $model): bool
- {
- return $model?->getAttribute($this->getMorphType()) === $this->morphClass
- && $this->isRelatedModel($model);
- }
-
- /**
- * Get the value of the model's foreign key.
+ * Get the foreign key when the model has the matching morph type.
*
* @param TRelatedModel $model
*/
protected function getRelatedKeyFrom(Model $model): mixed
{
- return $model->getAttribute($this->getForeignKeyName());
+ return $model->getAttribute($this->getMorphType()) === $this->morphClass
+ ? $model->getAttribute($this->getForeignKeyName())
+ : null;
}
}
diff --git a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
index 81060d9eb3..fbf2f834ff 100644
--- a/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
+++ b/tests/Database/DatabaseEloquentComparesRelatedModelsTest.php
@@ -85,6 +85,20 @@ public static function comparisons(): Generator
}
}
+ #[DataProvider('relationTypes')]
+ public function testUnrelatedModelsAreRejectedBeforeReadingMissingKeys(string $type): void
+ {
+ Model::preventAccessingMissingAttributes();
+ [$relation] = $this->newRelation($type, 1);
+
+ foreach ([['other_table', 'connection'], ['table', 'other_connection']] as [$table, $connection]) {
+ $model = (new ComparesRelatedModelsStub)->setTable($table)->newFromBuilder([], $connection);
+
+ $this->assertFalse($relation->is($model));
+ $this->assertTrue($relation->isNot($model));
+ }
+ }
+
/**
* Create a relation of the given type and the related key attribute it compares.
*
From 3763d4520e008de1f6a821604990b0663a4b5336 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:13 +0000
Subject: [PATCH 30/34] Preserve cancellation during queue outbox selection
Resolving the transaction manager for outbox selection can run application callbacks. A cleanup exception during cancellation can therefore carry cancellation in its cause rather than being the native cancellation exception itself.
Classify that chain before sending ordinary queueing-failure events and propagate the original exception unchanged. Real coroutine cancellation tests cover native cancellation and throwing cleanup, checking exception identity and the absence of failure notifications. The queue tests, formatting and static analysis pass.
---
src/queue/src/Queue.php | 6 ++-
tests/Queue/QueueDatabaseQueueUnitTest.php | 45 ++++++++++++++++++++++
2 files changed, 49 insertions(+), 2 deletions(-)
diff --git a/src/queue/src/Queue.php b/src/queue/src/Queue.php
index 55f49e30eb..3c35517ff3 100644
--- a/src/queue/src/Queue.php
+++ b/src/queue/src/Queue.php
@@ -442,9 +442,11 @@ protected function enqueueUsing(object|string $job, string $payload, ?string $qu
{
try {
$stageViaOutbox = $this->shouldStageViaOutbox($job);
- } catch (CanceledException $exception) {
- throw $exception;
} catch (Throwable $exception) {
+ if (Coroutine::causedByCancellation($exception)) {
+ throw $exception;
+ }
+
$this->raiseJobQueueingFailedEvent($queue, $job, $payload, $delay, $exception);
throw $exception;
diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php
index adbe017b25..94b127fcc0 100644
--- a/tests/Queue/QueueDatabaseQueueUnitTest.php
+++ b/tests/Queue/QueueDatabaseQueueUnitTest.php
@@ -726,6 +726,51 @@ public function testBulkRaisesExactFailureEventsWhenInsertThrows(): void
$this->assertSame($exception, $events[3]->exception);
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testOutboxSelectionCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void
+ {
+ $queue = new DatabaseQueue(m::mock(ConnectionResolverInterface::class), null, 'jobs', 'default');
+ $queue->setConnectionName('database');
+ $container = new Container;
+ $events = new Dispatcher($container);
+ $container->instance('events', $events);
+ $queue->setContainer($container);
+ $gate = $this->armCurrentCoroutineCancellation();
+ $cleanup = new RuntimeException('Transaction resolution cleanup failed.');
+ $container->bind('db.transactions', static function () use ($gate, $cleanup, $cleanupThrows): never {
+ try {
+ $gate->push(true);
+ } finally {
+ if ($cleanupThrows) {
+ throw $cleanup;
+ }
+ }
+
+ throw new RuntimeException('Cancellation was not delivered.');
+ });
+ $failed = [];
+ $events->listen(JobQueueingFailed::class, static function (JobQueueingFailed $event) use (&$failed): void {
+ $failed[] = $event;
+ });
+
+ try {
+ $queue->push((object) ['viaOutbox' => true]);
+ $this->fail('Expected cancellation to escape outbox selection.');
+ } catch (Throwable $exception) {
+ if ($cleanupThrows) {
+ $this->assertSame($cleanup, $exception);
+ $this->assertInstanceOf(CanceledException::class, $exception->getPrevious());
+ } else {
+ $this->assertInstanceOf(CanceledException::class, $exception);
+ }
+
+ $this->assertSame([], $failed);
+ } finally {
+ $gate->close();
+ }
+ }
+
#[TestWith([false])]
#[TestWith([true])]
public function testBulkCancellationDoesNotRaiseFailureEvents(bool $cleanupThrows): void
From 49d2c8e5d1c470ea8a607dc52bb3b4d102d53a35 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:10:13 +0000
Subject: [PATCH 31/34] Clarify scheduler defaults during provider boot
Use the concrete scheduler class when configuring the static single-server default during provider boot, avoiding premature console-kernel resolution through the facade.
Clarify that the unnamed-callback exclusion applies to invokable objects as well as closures.
---
src/docs/scheduling.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md
index 71eefc578a..7c5f525e27 100644
--- a/src/docs/scheduling.md
+++ b/src/docs/scheduling.md
@@ -395,7 +395,7 @@ Schedule::command('report:generate')
To apply `onOneServer` to all scheduled tasks, you may call the `alwaysOnOneServer` method in the `boot` method of your application's `AppServiceProvider`:
```php
-use Hypervel\Support\Facades\Schedule;
+use Hypervel\Console\Scheduling\Schedule;
public function boot(): void
{
@@ -403,7 +403,7 @@ public function boot(): void
}
```
-Scheduled closures without a [name](#naming-unique-jobs) are excluded and will continue to run on every server.
+Scheduled callbacks without a [name](#naming-unique-jobs), including closures and invokable objects, are excluded and will continue to run on every server.
You may use the `useCache` method to override the configured cache store used by the scheduler to obtain the atomic locks necessary for single-server tasks:
From 2d4e1958b15c1eb1d8c83d649e71325d126df870 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:41:05 +0000
Subject: [PATCH 32/34] Resolve binary paging casts through the query source
alias
A model queried through a table alias could lose its binary binding intent when a paging column used that alias. Chunk, lazy and cursor pagination could then compare raw or readable strings instead of binary values and repeat earlier rows.
Share attribute lookup across the paging paths. Preserve exact declared casts, then strip only the model table or the source qualifier already known by the query builder. Read that metadata directly from the underlying query so lookup does not reapply global scopes or clone scoped builders for each predicate.
Extend existing regressions for aliased chunk and lazy paging and encoded cursors in both directions. Affected paging tests, formatting and complete static analysis pass.
---
src/database/src/Concerns/BuildsQueries.php | 22 ++++++++++++++++++-
src/database/src/Eloquent/Builder.php | 4 +---
.../Database/DatabaseEloquentBuilderTest.php | 8 ++++++-
...atabaseEloquentAsBinaryIntegrationTest.php | 13 +++++++----
4 files changed, 38 insertions(+), 9 deletions(-)
diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php
index 9addfb7cbc..9a052ab9da 100644
--- a/src/database/src/Concerns/BuildsQueries.php
+++ b/src/database/src/Concerns/BuildsQueries.php
@@ -409,12 +409,32 @@ protected function getNextPageIdFromChunk(mixed $result, string $column, string
}
// Either the selected alias or the model's paging column can declare binary binding intent.
- $attribute = Str::chopStart($column, $result->getTable() . '.');
+ $attribute = $this->getPagingAttribute($result, $column);
$lastId = $result->prepareBinaryAttributesForDatabase([$alias => $lastId])[$alias];
return $result->prepareBinaryAttributesForDatabase([$attribute => $lastId])[$attribute];
}
+ /**
+ * Get the model attribute behind a qualified paging column.
+ */
+ protected function getPagingAttribute(Model $model, string $column): string
+ {
+ if ($model->hasCast($column)) {
+ return $column;
+ }
+
+ $prefixes = [$model->getTable() . '.'];
+
+ $query = $this instanceof Builder ? $this->getQuery() : $this;
+
+ if (($alias = $query->getFromAlias()) !== null) {
+ $prefixes[] = $alias . '.';
+ }
+
+ return Str::chopStart($column, $prefixes);
+ }
+
/**
* Execute the query and get the first result.
*
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index c07a9163bc..52821cc823 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -1192,9 +1192,7 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false):
protected function getCursorParameterBinding(Cursor $cursor, string $parameter): mixed
{
// Reverse the cast that produced the readable cursor, preserving qualified pivot casts.
- $key = $this->model->hasCast($parameter)
- ? $parameter
- : Str::chopStart($parameter, $this->model->getTable() . '.');
+ $key = $this->getPagingAttribute($this->model, $parameter);
return $this->model->encodeBinaryAttributeForDatabase($key, $cursor->parameter($parameter));
}
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index d7c9f425fa..44e3189e2e 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -820,14 +820,20 @@ public function testPagingUsesCursorAliasesAddedAfterHydration(string $method, b
#[TestWith(['lazyById', 'id'])]
#[TestWith(['chunkById', 'table.id'])]
#[TestWith(['lazyById', 'table.id'])]
+ #[TestWith(['chunkById', 't.id'])]
+ #[TestWith(['lazyById', 't.id'])]
#[TestWith(['chunkById', 't.id', true])]
public function testPagingPreservesBinaryBindingsForSelectedAliases(string $method, string $column, bool $castAlias = false): void
{
[$connection, $builder, $ids] = $this->newBinaryUuidPagedBuilder();
$builder->select(['id', 'id as cursor']);
+ if ($column === 't.id') {
+ $builder->from('table as t');
+ }
+
if ($castAlias) {
- $builder->from('table as t')->withCasts(['cursor' => AsBinary::uuid()]);
+ $builder->withCasts(['cursor' => AsBinary::uuid()]);
}
$actual = [];
diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
index da32281700..1eb937e2d0 100644
--- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
+++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
@@ -241,7 +241,9 @@ public function testBinaryPrimaryKeysCanBePaged(string $method, bool $descending
$this->assertSame($descending ? array_reverse($ids) : $ids, $actual);
}
- public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(bool $aliased): void
{
$ids = [
'00000000-0000-0000-0000-000000000001',
@@ -258,12 +260,15 @@ public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(): voi
]));
$this->assertSame([$ids[2]], $explicit->pluck('id')->all());
- $first = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2);
- $next = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2, cursor: $first->nextCursor()->encode());
+ $query = fn () => $aliased
+ ? AsBinaryPrimaryKeyModel::query()->from('binary_primary_keys as identifiers')->orderBy('identifiers.id')
+ : AsBinaryPrimaryKeyModel::query();
+ $first = $query()->cursorPaginate(2);
+ $next = $query()->cursorPaginate(2, cursor: $first->nextCursor()->encode());
$this->assertSame([$ids[2]], $next->pluck('id')->all());
- $previous = AsBinaryPrimaryKeyModel::query()->cursorPaginate(2, cursor: $next->previousCursor()->encode());
+ $previous = $query()->cursorPaginate(2, cursor: $next->previousCursor()->encode());
$this->assertSame([$ids[0], $ids[1]], $previous->pluck('id')->all());
}
From f2878630b671717669dad1afaa7104946873dc6c Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:41:06 +0000
Subject: [PATCH 33/34] Retain scoped fake lock ownership when the facade is
replaced
Scoped queue fakes must release the locks they acquired even when the callback replaces the facade root with a non-fake manager or mock. Retain the original fake and clean it up before checking for a distinct active replacement fake.
Both scope helpers share the cleanup method and retain unconditional restoration of the original queue manager. Keep the existing fail-fast behavior when cache cleanup throws; no retry or broader cleanup policy is introduced.
Regression coverage swaps in a non-fake queue manager mock inside each scope and checks lock release and manager restoration. Existing replacement-fake and throwing-cleanup cases remain. Affected tests, formatting and complete static analysis pass.
---
src/support/src/Facades/Queue.php | 27 ++++++++++++++++-------
tests/Integration/Queue/UniqueJobTest.php | 21 ++++++++++++++++++
2 files changed, 40 insertions(+), 8 deletions(-)
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 7ae78b56d1..ca7102b5b8 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -153,15 +153,13 @@ public static function fakeFor(callable $callable, array $jobsToFake = []): mixe
{
$originalQueueManager = static::getFacadeRoot();
- static::fake($jobsToFake);
+ $fake = static::fake($jobsToFake);
try {
return $callable();
} finally {
try {
- if (($fake = static::getFacadeRoot()) instanceof QueueFake) {
- $fake->releaseUniqueJobLocks();
- }
+ static::releaseScopedFakeLocks($fake);
} finally {
static::swap($originalQueueManager);
}
@@ -175,21 +173,34 @@ public static function fakeExceptFor(callable $callable, array $jobsToAllow = []
{
$originalQueueManager = static::getFacadeRoot();
- static::fakeExcept($jobsToAllow);
+ $fake = static::fakeExcept($jobsToAllow);
try {
return $callable();
} finally {
try {
- if (($fake = static::getFacadeRoot()) instanceof QueueFake) {
- $fake->releaseUniqueJobLocks();
- }
+ static::releaseScopedFakeLocks($fake);
} finally {
static::swap($originalQueueManager);
}
}
}
+ /**
+ * Release the dispatch locks owned by a scoped fake and its replacement.
+ */
+ protected static function releaseScopedFakeLocks(QueueFake $fake): void
+ {
+ $fake->releaseUniqueJobLocks();
+
+ // A fake installed inside the callback owns dispatches made after it replaced this one.
+ $active = static::getFacadeRoot();
+
+ if ($active instanceof QueueFake && $active !== $fake) {
+ $active->releaseUniqueJobLocks();
+ }
+ }
+
/**
* Get the registered name of the component.
*/
diff --git a/tests/Integration/Queue/UniqueJobTest.php b/tests/Integration/Queue/UniqueJobTest.php
index aac6ba9dfb..d730bb70d8 100644
--- a/tests/Integration/Queue/UniqueJobTest.php
+++ b/tests/Integration/Queue/UniqueJobTest.php
@@ -22,6 +22,7 @@
use Hypervel\Foundation\Bus\Dispatchable;
use Hypervel\Queue\Events\UniqueJobSkipped;
use Hypervel\Queue\InteractsWithQueue;
+use Hypervel\Queue\QueueManager;
use Hypervel\Queue\SerializesModels;
use Hypervel\Support\Facades\Bus;
use Hypervel\Support\Facades\DB;
@@ -30,6 +31,7 @@
use Hypervel\Testbench\Attributes\WithMigration;
use Hypervel\Testbench\Factories\UserFactory;
use Hypervel\Tests\Integration\Queue\QueueTestCase;
+use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
@@ -390,6 +392,25 @@ public function testScopedQueueFakeReleasesReplacementFakesLocks(string $method)
);
}
+ #[TestWith(['fakeFor'])]
+ #[TestWith(['fakeExceptFor'])]
+ public function testScopedQueueFakeReleasesItsLocksAfterSwappingTheFacade(string $method): void
+ {
+ $manager = Queue::getFacadeRoot();
+
+ Queue::{$method}(function (): void {
+ UniqueTestJob::dispatch();
+ Queue::assertPushed(UniqueTestJob::class);
+
+ Queue::swap(m::mock(QueueManager::class));
+ });
+
+ $this->assertSame($manager, Queue::getFacadeRoot());
+ $this->assertTrue(
+ $this->app->get(Cache::class)->lock($this->getLockKey(UniqueTestJob::class), 10)->get()
+ );
+ }
+
#[TestWith(['fakeFor'])]
#[TestWith(['fakeExceptFor'])]
public function testScopedQueueFakeRestoresManagerWhenLockCleanupThrows(string $method): void
From 3507b4396f45a310dbf7e45b435ec00b1f7cecfe Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Wed, 28 Oct 2026 22:53:32 +0000
Subject: [PATCH 34/34] Preserve explicit stream values in binary cursor
bindings
Explicit cursor parameters already support stream resources as LOB bindings. Reversing a binary attribute cast must not send those raw bytes through the readable UUID or ULID encoder, which rejects resource values.
Normalize streams through BinaryParameter::fromStream before invoking the caster. Keep non-binary attributes unchanged and preserve native conversion errors for malformed readable values. Extend the existing explicit-cursor regression while retaining binary-wrapper, encoded-cursor and source-alias coverage without duplicate execution.
The regression reproduces the resource TypeError before the correction. The complete binary and cursor integration suites pass on SQLite, PostgreSQL, MySQL and MariaDB; formatting and complete static analysis also pass.
---
.../src/Eloquent/Concerns/HasAttributes.php | 6 +++++
...atabaseEloquentAsBinaryIntegrationTest.php | 22 +++++++++++++++----
2 files changed, 24 insertions(+), 4 deletions(-)
diff --git a/src/database/src/Eloquent/Concerns/HasAttributes.php b/src/database/src/Eloquent/Concerns/HasAttributes.php
index 345f829ea3..562cb8a028 100644
--- a/src/database/src/Eloquent/Concerns/HasAttributes.php
+++ b/src/database/src/Eloquent/Concerns/HasAttributes.php
@@ -1980,6 +1980,8 @@ public function prepareBinaryAttributesForDatabase(array $attributes): array
/**
* Encode a binary attribute's cast value, such as a UUID string, for database binding.
+ *
+ * Preserve explicit binary parameters and raw streams without encoding them again.
*/
public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mixed
{
@@ -1987,6 +1989,10 @@ public function encodeBinaryAttributeForDatabase(string $key, mixed $value): mix
return $value;
}
+ if (is_resource($value)) {
+ return BinaryParameter::fromStream($value);
+ }
+
$bytes = $this->resolveCasterClass($key)->set($this, $key, $value, $this->attributes)[$key];
return is_string($bytes) ? new BinaryParameter($bytes) : $bytes;
diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
index 1eb937e2d0..c983d4a887 100644
--- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
+++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php
@@ -255,10 +255,24 @@ public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(bool $
AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]);
}
- $explicit = AsBinaryPrimaryKeyModel::query()->orderBy('id')->cursorPaginate(2, cursor: new Cursor([
- 'id' => new BinaryParameter(Uuid::fromString($ids[1])->toBinary()),
- ]));
- $this->assertSame([$ids[2]], $explicit->pluck('id')->all());
+ if (! $aliased) {
+ $bytes = Uuid::fromString($ids[1])->toBinary();
+ $stream = fopen('php://memory', 'w+');
+
+ try {
+ fwrite($stream, $bytes);
+ rewind($stream);
+
+ foreach ([new BinaryParameter($bytes), $stream] as $parameter) {
+ $explicit = AsBinaryPrimaryKeyModel::query()->orderBy('id')
+ ->cursorPaginate(2, cursor: new Cursor(['id' => $parameter]));
+
+ $this->assertSame([$ids[2]], $explicit->pluck('id')->all());
+ }
+ } finally {
+ fclose($stream);
+ }
+ }
$query = fn () => $aliased
? AsBinaryPrimaryKeyModel::query()->from('binary_primary_keys as identifiers')->orderBy('identifiers.id')