From e28bae44eb96cbba86e6b56f2ce275d4fb201d59 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:04:52 +0000
Subject: [PATCH 01/16] Document supported operating systems
State Linux and macOS support in the installation requirements and direct Windows developers to WSL2. Make the native Windows support boundary explicit without duplicating the installation guidance elsewhere.
---
src/docs/installation.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/src/docs/installation.md b/src/docs/installation.md
index ea0613ad3..9671338ef 100644
--- a/src/docs/installation.md
+++ b/src/docs/installation.md
@@ -21,6 +21,8 @@
### Requirements
+Hypervel supports Linux and macOS. Windows developers should use [WSL2](https://learn.microsoft.com/en-us/windows/wsl/install). Native Windows execution is not supported.
+
Before creating your first Hypervel application, make sure that your local machine has [PHP](https://php.net), [Composer](https://getcomposer.org), and the Swoole PHP extension installed. In addition, you should install either [Node and NPM](https://nodejs.org) or [Bun](https://bun.sh/) so that you can compile your application's frontend assets.
The Hypervel framework has a few system requirements:
From 33797911a1bf085d6652d643c0982b1e36c1e358 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:05 +0000
Subject: [PATCH 02/16] Allow per-report exception log levels
Forward an optional log level through the exception handler contract, helpers, testing wrappers and fakes. An explicit report level takes precedence over the exception-type default while preserving report context and cancellation handling. Update handler implementations, generated facade annotations and reporting expectations, and document the public helper argument.
Upstream:
https://github.com/laravel/framework/pull/61694
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/contracts/src/Debug/ExceptionHandler.php | 4 +-
src/docs/errors.md | 8 ++++
src/foundation/src/Exceptions/Handler.php | 10 +++--
.../InteractsWithExceptionHandling.php | 4 +-
src/foundation/src/helpers.php | 16 +++++---
src/support/src/Facades/Exceptions.php | 2 +-
.../Testing/Fakes/ExceptionHandlerFake.php | 6 ++-
.../FoundationExceptionsHandlerTest.php | 21 +++++++++-
tests/Grpc/ServerTest.php | 4 +-
.../Foundation/FoundationHelpersTest.php | 39 +++++++++++++++++++
.../Foundation/MaintenanceModeTest.php | 4 +-
.../ThrottlesExceptionsRedisStoreTest.php | 2 +-
.../Queue/ThrottlesExceptionsTest.php | 2 +-
tests/Queue/QueueWorkerTest.php | 4 +-
tests/Routing/ImplicitRouteBindingTest.php | 4 +-
tests/Scout/Unit/SearchableDispatchTest.php | 2 +-
.../Fixtures/TestCaseExceptionHandler.php | 6 ++-
.../Fakes/ExceptionHandlerFakeTest.php | 14 +++++++
.../ValidationNotPwnedVerifierTest.php | 2 +-
19 files changed, 126 insertions(+), 28 deletions(-)
diff --git a/src/contracts/src/Debug/ExceptionHandler.php b/src/contracts/src/Debug/ExceptionHandler.php
index 58ae33b4e..588014017 100644
--- a/src/contracts/src/Debug/ExceptionHandler.php
+++ b/src/contracts/src/Debug/ExceptionHandler.php
@@ -5,6 +5,7 @@
namespace Hypervel\Contracts\Debug;
use Hypervel\Http\Request;
+use Psr\Log\LogLevel;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
@@ -18,10 +19,11 @@ interface ExceptionHandler
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*
* @throws Throwable
*/
- public function report(Throwable $e, array $context = []): void;
+ public function report(Throwable $e, array $context = [], ?string $level = null): void;
/**
* Determine if the exception should be reported.
diff --git a/src/docs/errors.md b/src/docs/errors.md
index f853fbcea..37d763b07 100644
--- a/src/docs/errors.md
+++ b/src/docs/errors.md
@@ -181,6 +181,14 @@ use Psr\Log\LogLevel;
})
```
+To override the level for a single report, pass a `level` argument to `report`, `report_if`, or `report_unless`. This takes precedence over the level configured for the exception type:
+
+```php
+use Psr\Log\LogLevel;
+
+report($e, ['order_id' => $order->id], level: LogLevel::WARNING);
+```
+
### Ignoring Exceptions by Type
diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php
index e492e1440..b8e9c7dd0 100644
--- a/src/foundation/src/Exceptions/Handler.php
+++ b/src/foundation/src/Exceptions/Handler.php
@@ -459,10 +459,11 @@ public function level(string $type, string $level): static
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*
* @throws Throwable
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
// Cancellation must not reach user-defined exception mappers.
if ($e instanceof CanceledException) {
@@ -475,17 +476,18 @@ public function report(Throwable $e, array $context = []): void
return;
}
- $this->reportThrowable($e, $context);
+ $this->reportThrowable($e, $context, $level);
}
/**
* Report error based on report method on exception or to logger.
*
* @param array $context
+ * @param null|LogLevel::* $level
*
* @throws Throwable
*/
- protected function reportThrowable(Throwable $e, array $context = []): void
+ protected function reportThrowable(Throwable $e, array $context = [], ?string $level = null): void
{
if ($this->withoutDuplicates) {
$this->reportedException($e);
@@ -515,7 +517,7 @@ protected function reportThrowable(Throwable $e, array $context = []): void
throw $e;
}
- $level = $this->mapLogLevel($e);
+ $level ??= $this->mapLogLevel($e);
$this->whileReporting($e, function () use ($logger, $level, $e, $context): void {
method_exists($logger, $level)
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php
index e45818f69..c4c1abcd9 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithExceptionHandling.php
@@ -11,6 +11,7 @@
use Hypervel\Support\Traits\ReflectsClosures;
use Hypervel\Testing\Assert;
use Hypervel\Validation\ValidationException;
+use Psr\Log\LogLevel;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpFoundation\Response;
@@ -91,8 +92,9 @@ public function __construct(
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
}
diff --git a/src/foundation/src/helpers.php b/src/foundation/src/helpers.php
index 250aba042..d9bf26278 100644
--- a/src/foundation/src/helpers.php
+++ b/src/foundation/src/helpers.php
@@ -50,6 +50,7 @@
use Hypervel\Translation\Translator;
use League\Uri\Contracts\UriInterface;
use Psr\Log\LoggerInterface;
+use Psr\Log\LogLevel;
use Swoole\Coroutine\CanceledException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;
@@ -700,14 +701,15 @@ function redirect(?string $to = null, int $status = 302, array $headers = [], ?b
* Report an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- function report(string|Throwable $exception, array $context = []): void
+ function report(string|Throwable $exception, array $context = [], ?string $level = null): void
{
if (is_string($exception)) {
$exception = new Exception($exception);
}
- app(ExceptionHandlerContract::class)->report($exception, $context);
+ app(ExceptionHandlerContract::class)->report($exception, $context, $level);
}
}
@@ -716,11 +718,12 @@ function report(string|Throwable $exception, array $context = []): void
* Report an exception if the given condition is true.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- function report_if(bool $boolean, string|Throwable $exception, array $context = []): void
+ function report_if(bool $boolean, string|Throwable $exception, array $context = [], ?string $level = null): void
{
if ($boolean) {
- report($exception, $context);
+ report($exception, $context, $level);
}
}
}
@@ -730,11 +733,12 @@ function report_if(bool $boolean, string|Throwable $exception, array $context =
* Report an exception unless the given condition is true.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- function report_unless(bool $boolean, string|Throwable $exception, array $context = []): void
+ function report_unless(bool $boolean, string|Throwable $exception, array $context = [], ?string $level = null): void
{
if (! $boolean) {
- report($exception, $context);
+ report($exception, $context, $level);
}
}
}
diff --git a/src/support/src/Facades/Exceptions.php b/src/support/src/Facades/Exceptions.php
index e282717b0..83766f45f 100644
--- a/src/support/src/Facades/Exceptions.php
+++ b/src/support/src/Facades/Exceptions.php
@@ -27,7 +27,7 @@
* @method static \Symfony\Component\HttpFoundation\Response render(\Hypervel\Http\Request $request, Throwable $e)
* @method static \Hypervel\Foundation\Exceptions\Handler renderable(callable $renderUsing)
* @method static void renderForConsole(\Symfony\Component\Console\Output\OutputInterface $output, Throwable $e)
- * @method static void report(Throwable $e, array $context = [])
+ * @method static void report(Throwable $e, array $context = [], null|string $level = null)
* @method static \Hypervel\Foundation\Exceptions\ReportableHandler reportable(callable $reportUsing)
* @method static \Hypervel\Foundation\Exceptions\Handler respondUsing(callable $callback)
* @method static \Hypervel\Foundation\Exceptions\Handler shouldRenderJsonWhen(callable $callback)
diff --git a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
index 7701154bc..118e55397 100644
--- a/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
+++ b/src/support/src/Testing/Fakes/ExceptionHandlerFake.php
@@ -14,6 +14,7 @@
use Hypervel\Support\Traits\ReflectsClosures;
use PHPUnit\Framework\Assert as PHPUnit;
use PHPUnit\Framework\ExpectationFailedException;
+use Psr\Log\LogLevel;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
@@ -141,13 +142,14 @@ public function assertNothingReported(): void
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*
* @throws Throwable
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
if (! $this->isFakedException($e)) {
- $this->handler->report($e, $context);
+ $this->handler->report($e, $context, $level);
return;
}
diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php
index 9fc692718..ae146c325 100644
--- a/tests/Foundation/FoundationExceptionsHandlerTest.php
+++ b/tests/Foundation/FoundationExceptionsHandlerTest.php
@@ -352,6 +352,25 @@ public function testHandlerReportsExceptionWithCustomLogLevel(): void
$this->handler->report(new OutOfRangeException('Custom message'));
}
+ public function testHandlerReportsExceptionWithInlineLogLevel(): void
+ {
+ $logger = m::mock(LoggerInterface::class);
+ $this->container->instance(LoggerInterface::class, $logger);
+
+ $logger->expects('warning')->withArgs([
+ 'Warning message',
+ m::subset(['from' => 'user@example.com']),
+ ]);
+
+ $this->handler->level(InvalidArgumentException::class, LogLevel::CRITICAL);
+
+ $this->handler->report(
+ new InvalidArgumentException('Warning message'),
+ ['from' => 'user@example.com'],
+ LogLevel::WARNING,
+ );
+ }
+
public function testHandlerIgnoresNotReportableExceptions()
{
$logger = m::mock(LoggerInterface::class);
@@ -927,7 +946,7 @@ public function renderHttpExceptionForTest(HttpException $exception): SymfonyRes
return $this->renderHttpException($exception);
}
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
$this->reported = true;
}
diff --git a/tests/Grpc/ServerTest.php b/tests/Grpc/ServerTest.php
index 97bc309f3..6734e81db 100644
--- a/tests/Grpc/ServerTest.php
+++ b/tests/Grpc/ServerTest.php
@@ -42,6 +42,7 @@
use LogicException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use Psr\Log\LogLevel;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
use Swoole\Http\Request as SwooleRequest;
@@ -641,8 +642,9 @@ class RecordingExceptionHandler implements ExceptionHandler
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
$this->reported[] = $e;
}
diff --git a/tests/Integration/Foundation/FoundationHelpersTest.php b/tests/Integration/Foundation/FoundationHelpersTest.php
index 05c20d6a9..9e718894c 100644
--- a/tests/Integration/Foundation/FoundationHelpersTest.php
+++ b/tests/Integration/Foundation/FoundationHelpersTest.php
@@ -7,11 +7,28 @@
use Exception;
use Faker\Provider\en_AU\Address as AustralianAddress;
use Faker\Provider\en_US\Address as AmericanAddress;
+use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Testbench\TestCase;
+use Psr\Log\LogLevel;
use Swoole\Coroutine\CanceledException;
+use Throwable;
class FoundationHelpersTest extends TestCase
{
+ public function testReportHelpersForwardContextAndLevel(): void
+ {
+ $handler = new FakeHandler;
+ $this->app->instance(ExceptionHandler::class, $handler);
+
+ report($first = new Exception('First'), ['id' => 1], LogLevel::WARNING);
+ report_if(true, $second = new Exception('Second'), ['id' => 2], LogLevel::NOTICE);
+ report_unless(false, $third = new Exception('Third'), ['id' => 3], LogLevel::INFO);
+
+ $this->assertSame([$first, $second, $third], $handler->reported);
+ $this->assertSame([['id' => 1], ['id' => 2], ['id' => 3]], $handler->contexts);
+ $this->assertSame([LogLevel::WARNING, LogLevel::NOTICE, LogLevel::INFO], $handler->levels);
+ }
+
public function testRescue(): void
{
$this->assertSame(
@@ -122,3 +139,25 @@ public function testFakeUsesLocale()
]);
}
}
+
+class FakeHandler
+{
+ /** @var list */
+ public array $reported = [];
+
+ /** @var list> */
+ public array $contexts = [];
+
+ /** @var list */
+ public array $levels = [];
+
+ /**
+ * Record the reported exception, context and level.
+ */
+ public function report(Throwable $exception, array $context = [], ?string $level = null): void
+ {
+ $this->reported[] = $exception;
+ $this->contexts[] = $context;
+ $this->levels[] = $level;
+ }
+}
diff --git a/tests/Integration/Foundation/MaintenanceModeTest.php b/tests/Integration/Foundation/MaintenanceModeTest.php
index 30cbb2c82..d28985faf 100644
--- a/tests/Integration/Foundation/MaintenanceModeTest.php
+++ b/tests/Integration/Foundation/MaintenanceModeTest.php
@@ -411,7 +411,7 @@ public function testDownAttemptsReloadAfterEventFailureAndPreservesTheEventFailu
);
$handler = m::mock(ExceptionHandler::class);
- $handler->shouldReceive('report')->once()->with($eventException, [])->andThrow($reportException);
+ $handler->shouldReceive('report')->once()->with($eventException, [], null)->andThrow($reportException);
$this->app->instance(ExceptionHandler::class, $handler);
$this->artisan(FailingReloadDownCommand::class)
@@ -486,7 +486,7 @@ public function testUpReportsDriverFailureBeforeMaintenanceStateIsCommitted(): v
$this->app->instance(MaintenanceModeContract::class, $mode);
$handler = m::mock(ExceptionHandler::class);
- $handler->shouldReceive('report')->once()->with($exception, []);
+ $handler->shouldReceive('report')->once()->with($exception, [], null);
$this->app->instance(ExceptionHandler::class, $handler);
$this->artisan(UpCommand::class)
diff --git a/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php
index 8a505e521..294d6d45f 100644
--- a/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php
+++ b/tests/Integration/Queue/Redis/ThrottlesExceptionsRedisStoreTest.php
@@ -129,7 +129,7 @@ public function testReportingExceptions(): void
$this->spy(ExceptionHandler::class)
->expects('report')
->times(2)
- ->with(m::type(RuntimeException::class), []);
+ ->with(m::type(RuntimeException::class), [], null);
$job = new class {
/**
diff --git a/tests/Integration/Queue/ThrottlesExceptionsTest.php b/tests/Integration/Queue/ThrottlesExceptionsTest.php
index 586fc8607..f3ffe8bce 100644
--- a/tests/Integration/Queue/ThrottlesExceptionsTest.php
+++ b/tests/Integration/Queue/ThrottlesExceptionsTest.php
@@ -476,7 +476,7 @@ public function testReportingExceptions(): void
$this->spy(ExceptionHandler::class)
->expects('report')
->times(2)
- ->with(m::type(RuntimeException::class), []);
+ ->with(m::type(RuntimeException::class), [], null);
$job = new class {
/**
diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php
index ca27debab..85effe85d 100644
--- a/tests/Queue/QueueWorkerTest.php
+++ b/tests/Queue/QueueWorkerTest.php
@@ -57,6 +57,7 @@
use Hypervel\Tests\TestCase;
use Mockery as m;
use PHPUnit\Framework\Attributes\TestWith;
+use Psr\Log\LogLevel;
use ReflectionProperty;
use RuntimeException;
use Swoole\Coroutine\CanceledException;
@@ -2488,8 +2489,9 @@ class ShouldntRetryExceptionHandler implements ExceptionHandlerContract
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
}
diff --git a/tests/Routing/ImplicitRouteBindingTest.php b/tests/Routing/ImplicitRouteBindingTest.php
index 3899db3af..4a73701ef 100644
--- a/tests/Routing/ImplicitRouteBindingTest.php
+++ b/tests/Routing/ImplicitRouteBindingTest.php
@@ -157,7 +157,7 @@ public function testItThrowsModelNotFoundExceptionOnInvalidValueException(bool $
$mock = m::mock(ExceptionHandler::class);
if ($shouldReport) {
- $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), []);
+ $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), [], null);
} else {
$mock->shouldReceive('report')->never();
}
@@ -207,7 +207,7 @@ public function testItDoesNotConvertUnrelatedQueryExceptions(): void
public function testItConvertsInvalidValueExceptionsFromNonModelBindings(): void
{
$mock = m::mock(ExceptionHandler::class);
- $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), []);
+ $mock->shouldReceive('report')->once()->with(m::type(InvalidValueException::class), [], null);
$container = Container::getInstance();
$container->instance(ExceptionHandler::class, $mock);
diff --git a/tests/Scout/Unit/SearchableDispatchTest.php b/tests/Scout/Unit/SearchableDispatchTest.php
index 1915a7a04..de630649b 100644
--- a/tests/Scout/Unit/SearchableDispatchTest.php
+++ b/tests/Scout/Unit/SearchableDispatchTest.php
@@ -127,7 +127,7 @@ public function testAReportedFailureDoesNotStopLaterJobs(): void
{
$exception = new RuntimeException('indexing failed');
$handler = m::mock(ExceptionHandler::class);
- $handler->shouldReceive('report')->once()->with($exception, []);
+ $handler->shouldReceive('report')->once()->with($exception, [], null);
$this->app->instance(ExceptionHandler::class, $handler);
$events = [];
diff --git a/tests/Sentry/Fixtures/TestCaseExceptionHandler.php b/tests/Sentry/Fixtures/TestCaseExceptionHandler.php
index 6122de0ff..a3663d735 100644
--- a/tests/Sentry/Fixtures/TestCaseExceptionHandler.php
+++ b/tests/Sentry/Fixtures/TestCaseExceptionHandler.php
@@ -7,6 +7,7 @@
use Hypervel\Contracts\Debug\ExceptionHandler;
use Hypervel\Http\Request;
use Hypervel\Sentry\Integration;
+use Psr\Log\LogLevel;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
@@ -28,14 +29,15 @@ public function __construct(
* Report or log an exception.
*
* @param array $context
+ * @param null|LogLevel::* $level
*
* @throws Throwable
*/
- public function report(Throwable $e, array $context = []): void
+ public function report(Throwable $e, array $context = [], ?string $level = null): void
{
Integration::captureUnhandledException($e);
- $this->handler->report($e, $context);
+ $this->handler->report($e, $context, $level);
}
/**
diff --git a/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php
index c93c2e3ba..0f79409b9 100644
--- a/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php
+++ b/tests/Support/Testing/Fakes/ExceptionHandlerFakeTest.php
@@ -12,6 +12,7 @@
use Hypervel\Testbench\TestCase;
use InvalidArgumentException;
use PHPUnit\Framework\ExpectationFailedException;
+use Psr\Log\LogLevel;
use RuntimeException;
class ExceptionHandlerFakeTest extends TestCase
@@ -191,6 +192,19 @@ public function testThrowOnReport(): void
Exceptions::report(new RuntimeException('test exception'));
}
+ public function testUnfakedReportsForwardContextAndLevel(): void
+ {
+ $exception = new InvalidArgumentException('test');
+ $handler = $this->createMock(ExceptionHandler::class);
+ $handler->expects($this->once())->method('report')->with($exception, ['id' => 1], LogLevel::WARNING);
+
+ Exceptions::fake([RuntimeException::class])->setHandler($handler);
+
+ Exceptions::report($exception, ['id' => 1], LogLevel::WARNING);
+
+ Exceptions::assertNothingReported();
+ }
+
public function testThrowFirstReported(): void
{
Exceptions::fake();
diff --git a/tests/Validation/ValidationNotPwnedVerifierTest.php b/tests/Validation/ValidationNotPwnedVerifierTest.php
index 3dc1cb988..2add47a6f 100644
--- a/tests/Validation/ValidationNotPwnedVerifierTest.php
+++ b/tests/Validation/ValidationNotPwnedVerifierTest.php
@@ -146,7 +146,7 @@ public function testDnsDown(): void
$exception = new ConnectionException;
$exceptionHandler = m::mock(ExceptionHandler::class);
- $exceptionHandler->expects('report')->with($exception, []);
+ $exceptionHandler->expects('report')->with($exception, [], null);
$this->app->singleton(ExceptionHandler::class, function () use ($exceptionHandler): ExceptionHandler {
return $exceptionHandler;
});
From 698a23ca6457b0d3a1fd1532dbc4fb5d637b88b0 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:05 +0000
Subject: [PATCH 03/16] Accept enum queue and connection names when pausing
workers
Normalize enum names in pause, pauseFor and resume before building storage keys. Preserve the current queue-first argument order, support zero-backed and unit enums, regenerate the Queue facade and document the accepted arguments.
Upstream:
https://github.com/laravel/framework/pull/61464
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/docs/queues.md | 2 ++
src/queue/src/QueueManager.php | 12 +++++--
src/support/src/Facades/Queue.php | 6 ++--
tests/Queue/QueuePauseResumeTest.php | 48 ++++++++++++++++++++++++++++
4 files changed, 62 insertions(+), 6 deletions(-)
diff --git a/src/docs/queues.md b/src/docs/queues.md
index 3a0a05212..e1c0285ce 100644
--- a/src/docs/queues.md
+++ b/src/docs/queues.md
@@ -3043,6 +3043,8 @@ Queue::getPausedQueues(['emails', 'notifications']);
Queue::resume('emails');
```
+The `pause`, `pauseFor`, and `resume` methods also accept enums for queue and connection names.
+
Queue workers report paused and resumed queues in their console output.
Pausing or resuming a queue dispatches the `Hypervel\Queue\Events\QueuePaused` or `Hypervel\Queue\Events\QueueResumed` event in the process that made the change. Their `connection` and `queue` properties identify the queue, and the `QueuePaused` event's `ttl` property is `null` unless the queue was paused for a limited time. Pausing or resuming every queue with the `--all` option dispatches the `QueuesPaused` or `QueuesResumed` event instead. Running workers dispatch the `WorkerQueuePaused` and `WorkerQueueResumed` events, with `connectionName` and `queue` properties, when they detect that one of their queues has been paused or resumed.
diff --git a/src/queue/src/QueueManager.php b/src/queue/src/QueueManager.php
index ed9a23f0d..0106f8495 100644
--- a/src/queue/src/QueueManager.php
+++ b/src/queue/src/QueueManager.php
@@ -200,8 +200,10 @@ protected function queueRoutesContainer(): Container
/**
* Pause a queue by its name and connection.
*/
- public function pause(string $queue, ?string $connection = null): void
+ public function pause(UnitEnum|string $queue, UnitEnum|string|null $connection = null): void
{
+ $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue;
+ $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection;
$connection ??= $this->getDefaultDriver();
// IMPORTANT: Uses Laravel's key for cross-framework queue interoperability.
@@ -220,8 +222,10 @@ public function pause(string $queue, ?string $connection = null): void
/**
* Pause a queue by its name and connection for a given amount of time.
*/
- public function pauseFor(string $queue, DateInterval|DateTimeInterface|int $ttl, ?string $connection = null): void
+ public function pauseFor(UnitEnum|string $queue, DateInterval|DateTimeInterface|int $ttl, UnitEnum|string|null $connection = null): void
{
+ $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue;
+ $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection;
$connection ??= $this->getDefaultDriver();
// IMPORTANT: Uses Laravel's key for cross-framework queue interoperability.
@@ -258,8 +262,10 @@ public function pauseAll(): void
/**
* Resume a paused queue by its name and connection.
*/
- public function resume(string $queue, ?string $connection = null): void
+ public function resume(UnitEnum|string $queue, UnitEnum|string|null $connection = null): void
{
+ $queue = $queue instanceof UnitEnum ? (string) enum_value($queue) : $queue;
+ $connection = $connection instanceof UnitEnum ? (string) enum_value($connection) : $connection;
$connection ??= $this->getDefaultDriver();
// IMPORTANT: Uses Laravel's key for cross-framework queue interoperability.
diff --git a/src/support/src/Facades/Queue.php b/src/support/src/Facades/Queue.php
index 52df41334..ff7e6d685 100644
--- a/src/support/src/Facades/Queue.php
+++ b/src/support/src/Facades/Queue.php
@@ -28,14 +28,14 @@
* @method static \Closure|null getReleaseCallback(string $driver)
* @method static bool isPaused(string $queue, string|null $connection = null)
* @method static void looping(mixed $callback)
- * @method static void pause(string $queue, string|null $connection = null)
+ * @method static void pause(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null)
* @method static void pauseAll()
- * @method static void pauseFor(string $queue, \DateInterval|\DateTimeInterface|int $ttl, string|null $connection = null)
+ * @method static void pauseFor(\UnitEnum|string $queue, \DateInterval|\DateTimeInterface|int $ttl, \UnitEnum|string|null $connection = null)
* @method static void purge(string|null $name = null)
* @method static \Hypervel\Queue\QueueManager removePoolableDriver(string $driver)
* @method static string|null resolveConnectionFromQueueRoute(object $queueable, null|string|\UnitEnum $queue = null)
* @method static string|null resolveQueueFromQueueRoute(object $queueable)
- * @method static void resume(string $queue, string|null $connection = null)
+ * @method static void resume(\UnitEnum|string $queue, \UnitEnum|string|null $connection = null)
* @method static void resumeAll()
* @method static void route(array|string $class, \UnitEnum|string|null $queue = null, \UnitEnum|string|null $connection = null)
* @method static \Hypervel\Queue\QueueManager setApplication(\Hypervel\Contracts\Container\Container $app)
diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php
index 85244f9aa..01b74a76c 100644
--- a/tests/Queue/QueuePauseResumeTest.php
+++ b/tests/Queue/QueuePauseResumeTest.php
@@ -18,7 +18,9 @@
use Hypervel\Queue\QueueManager;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\Attributes\DataProvider;
use RuntimeException;
+use UnitEnum;
class QueuePauseResumeTest extends TestCase
{
@@ -364,4 +366,50 @@ public function parse(string $queue): array
$this->assertSame(['database', 'notifications'], $parser->parse('database:notifications'));
$this->assertSame(['redis', 'foo:bar'], $parser->parse('redis:foo:bar'));
}
+
+ #[DataProvider('enumQueueProvider')]
+ public function testEnumsAreAccepted(UnitEnum $queue, UnitEnum $connection, string $queueName, string $connectionName): void
+ {
+ $this->manager->pause($queue, $connection);
+ $this->assertTrue($this->manager->isPaused($queueName, $connectionName));
+
+ $this->manager->resume($queue, $connection);
+ $this->assertFalse($this->manager->isPaused($queueName, $connectionName));
+
+ $this->manager->pauseFor($queue, 30, $connection);
+ $this->assertTrue($this->manager->isPaused($queueName, $connectionName));
+ }
+
+ /**
+ * Provide enum queue and connection identifiers.
+ */
+ public static function enumQueueProvider(): array
+ {
+ return [
+ 'string backed' => [PauseQueueName::Emails, PauseQueueConnection::Redis, 'emails', 'redis'],
+ 'integer zero' => [PauseQueueIntegerIdentifier::Zero, PauseQueueIntegerIdentifier::Zero, '0', '0'],
+ 'unit' => [PauseQueueUnitIdentifier::Emails, PauseQueueUnitIdentifier::Redis, 'Emails', 'Redis'],
+ ];
+ }
+}
+
+enum PauseQueueConnection: string
+{
+ case Redis = 'redis';
+}
+
+enum PauseQueueName: string
+{
+ case Emails = 'emails';
+}
+
+enum PauseQueueIntegerIdentifier: int
+{
+ case Zero = 0;
+}
+
+enum PauseQueueUnitIdentifier
+{
+ case Emails;
+ case Redis;
}
From 1c373df7b983673fbc0949b442e7e0c054662c13 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:05 +0000
Subject: [PATCH 04/16] Support pivot closure filters across reads and writes
Build closure filters on the relation's exact connection, including read/write aliases. Record pivot restrictions in their original order so stock and custom pivot writes preserve boolean precedence and parent/morph identity.
Compile closures and subqueries once when registered instead of retaining closures or connections on hydrated pivots. This preserves model serialization and avoids evaluating callbacks again for writes. Update the permission consumers, allow Expression columns in OR-IN filters and omit expression-only defaults from hydrated attributes.
Include upstream closure cases and regressions for serialized pivots, scoped writes, expression hydration, connection selection and predicate order. Document closure usage and the ordered constraint representation. Focused database/permission tests and analysis pass; registration and write costs were benchmarked.
Upstream:
https://github.com/laravel/framework/pull/61150
https://github.com/laravel/framework/pull/61488
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/database/README.md | 1 +
.../src/Eloquent/Relations/BelongsToMany.php | 162 +++++----
.../Eloquent/Relations/Concerns/AsPivot.php | 38 +-
.../Concerns/InteractsWithPivotTable.php | 39 +-
.../src/Eloquent/Relations/MorphToMany.php | 7 +-
src/docs/eloquent-relationships.md | 13 +
src/permission/src/PermissionRegistrar.php | 9 +-
src/permission/src/Traits/HasPermissions.php | 29 +-
...aseEloquentBelongsToManyExpressionTest.php | 17 +-
...uentBelongsToManyWherePivotClosureTest.php | 342 ++++++++++++++++++
.../BelongsToManyPivotEventsTest.php | 114 +++---
.../Relations/MorphToManyPivotEventsTest.php | 25 +-
.../Database/EloquentBelongsToManyTest.php | 53 +++
.../Database/EloquentPivotTest.php | 20 +
14 files changed, 647 insertions(+), 222 deletions(-)
create mode 100644 tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php
diff --git a/src/database/README.md b/src/database/README.md
index 92f5d89ab..bf7cd7b94 100644
--- a/src/database/README.md
+++ b/src/database/README.md
@@ -20,5 +20,6 @@ Documentation: https://hypervel.org/docs/database
- Eloquent models that override `CREATED_AT` or `UPDATED_AT` must declare the compatible `?string` constant type, such as `public const ?string UPDATED_AT = null;`. Laravel's constants are untyped, but omitting the type from an override in Hypervel causes a fatal error.
- Eloquent expression plucks use `Query\Builder::pluckWithColumn()` to retain the returned field name for casts and accessors. Custom query builders overriding `pluck()` must also override `pluckWithColumn()` to customize this path. String-based Eloquent plucks still call `pluck()`.
- Eloquent `updateFrom()` maintains the model's `updated_at` timestamp, like `update()`. Laravel forwards this method without adding a timestamp.
+- `BelongsToMany` records pivot filters in an ordered `$pivotConstraints` list instead of Laravel's `$pivotWheres`, `$pivotWhereIns`, and `$pivotWhereNulls` properties. Relationship extensions should use the `wherePivot*` methods instead of accessing those arrays.
Ported from: https://github.com/laravel/framework
diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php
index 59c84f653..6d5107f57 100644
--- a/src/database/src/Eloquent/Relations/BelongsToMany.php
+++ b/src/database/src/Eloquent/Relations/BelongsToMany.php
@@ -17,6 +17,7 @@
use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary;
use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable;
use Hypervel\Database\MultipleRecordsFoundException;
+use Hypervel\Database\Query\Builder as QueryBuilder;
use Hypervel\Database\Query\Grammars\MySqlGrammar;
use Hypervel\Database\UniqueConstraintViolationException;
use Hypervel\Pagination\Cursor;
@@ -77,32 +78,21 @@ class BelongsToMany extends Relation
/**
* The pivot table columns to retrieve.
*
- * @var array
+ * @var array
*/
protected array $pivotColumns = [];
/**
- * Any pivot table restrictions for where clauses.
- */
- protected array $pivotWheres = [];
-
- /**
- * Any pivot table restrictions for whereIn clauses.
- */
- protected array $pivotWhereIns = [];
-
- /**
- * Any pivot table restrictions for whereNull clauses.
- */
- protected array $pivotWhereNulls = [];
-
- /**
- * Any pivot table restrictions for whereBetween clauses.
+ * The pivot table restrictions in their original order.
+ *
+ * @var list}>
*/
- protected array $pivotWhereBetweens = [];
+ protected array $pivotConstraints = [];
/**
* The default values for the pivot columns.
+ *
+ * @var list
*/
protected array $pivotValues = [];
@@ -355,36 +345,93 @@ public function as(string $accessor): static
/**
* Set a where clause for a pivot table column.
*
- * @param Expression|string $column
+ * @param (Closure(Builder): mixed)|Expression|string $column
* @return $this
*/
- public function wherePivot(mixed $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
+ public function wherePivot(Closure|Expression|string $column, mixed $operator = null, mixed $value = null, string $boolean = 'and'): static
{
- $this->pivotWheres[] = func_get_args();
+ if ($column instanceof Closure) {
+ $pivotQuery = (new ($this->getPivotClass()))
+ // Reuse the relation's held connection, including its read/write alias.
+ ->setConnection($this->getPivotConnection()->getNameWithReadWriteType())
+ ->setTable($this->table)
+ ->newQueryWithoutRelationships();
+
+ $column($pivotQuery);
- return $this->where($this->qualifyPivotColumn($column), $operator, $value, $boolean);
+ return $this->addCompiledPivotConstraint(
+ $this->newPivotStatement()->addNestedWhereQuery($pivotQuery->getQuery(), $boolean)
+ );
+ }
+
+ return $this->addPivotConstraint('where', [$column, $operator, $value, $boolean]);
}
/**
- * Set a "where between" clause for a pivot table column.
+ * Apply and record a pivot restriction for subsequent pivot writes.
*
- * @param Expression|string $column
+ * @param array $arguments
* @return $this
*/
- public function wherePivotBetween(mixed $column, array $values, string $boolean = 'and', bool $not = false): static
+ protected function addPivotConstraint(string $method, array $arguments): static
{
- $this->pivotWhereBetweens[] = func_get_args();
+ foreach ($arguments as $argument) {
+ if ($argument instanceof Closure || $argument instanceof QueryBuilder
+ || $argument instanceof Builder || $argument instanceof Relation) {
+ $arguments[0] = $this->qualifyPivotColumn($arguments[0]);
+
+ return $this->addCompiledPivotConstraint($this->newPivotStatement()->{$method}(...$arguments));
+ }
+ }
- return $this->whereBetween($this->qualifyPivotColumn($column), $values, $boolean, $not);
+ $this->pivotConstraints[] = [$method, $arguments];
+ $arguments[0] = $this->qualifyPivotColumn($arguments[0]);
+
+ $this->query->{$method}(...$arguments);
+
+ return $this;
+ }
+
+ /**
+ * Apply and record an already-built pivot predicate.
+ *
+ * @return $this
+ */
+ protected function addCompiledPivotConstraint(QueryBuilder $query): static
+ {
+ if ($query->wheres === []) {
+ return $this;
+ }
+
+ // Share the evaluated predicate without retaining closures or connections on hydrated pivots.
+ $arguments = [
+ substr($query->getGrammar()->compileWheres($query), strlen('where ')),
+ $query->getRawBindings()['where'],
+ $query->wheres[0]['boolean'],
+ ];
+
+ $this->pivotConstraints[] = ['whereRaw', $arguments];
+ $this->query->whereRaw(...$arguments);
+
+ return $this;
+ }
+
+ /**
+ * Set a "where between" clause for a pivot table column.
+ *
+ * @return $this
+ */
+ public function wherePivotBetween(Expression|string $column, array $values, string $boolean = 'and', bool $not = false): static
+ {
+ return $this->addPivotConstraint('whereBetween', [$column, $values, $boolean, $not]);
}
/**
* Set a "or where between" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function orWherePivotBetween(mixed $column, array $values): static
+ public function orWherePivotBetween(Expression|string $column, array $values): static
{
return $this->wherePivotBetween($column, $values, 'or');
}
@@ -392,10 +439,9 @@ public function orWherePivotBetween(mixed $column, array $values): static
/**
* Set a "where pivot not between" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function wherePivotNotBetween(mixed $column, array $values, string $boolean = 'and'): static
+ public function wherePivotNotBetween(Expression|string $column, array $values, string $boolean = 'and'): static
{
return $this->wherePivotBetween($column, $values, $boolean, true);
}
@@ -403,10 +449,9 @@ public function wherePivotNotBetween(mixed $column, array $values, string $boole
/**
* Set a "or where not between" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function orWherePivotNotBetween(mixed $column, array $values): static
+ public function orWherePivotNotBetween(Expression|string $column, array $values): static
{
return $this->wherePivotBetween($column, $values, 'or', true);
}
@@ -414,23 +459,20 @@ public function orWherePivotNotBetween(mixed $column, array $values): static
/**
* Set a "where in" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function wherePivotIn(mixed $column, mixed $values, string $boolean = 'and', bool $not = false): static
+ public function wherePivotIn(Expression|string $column, mixed $values, string $boolean = 'and', bool $not = false): static
{
- $this->pivotWhereIns[] = func_get_args();
-
- return $this->whereIn($this->qualifyPivotColumn($column), $values, $boolean, $not);
+ return $this->addPivotConstraint('whereIn', [$column, $values, $boolean, $not]);
}
/**
* Set an "or where" clause for a pivot table column.
*
- * @param Expression|string $column
+ * @param (Closure(Builder): mixed)|Expression|string $column
* @return $this
*/
- public function orWherePivot(mixed $column, mixed $operator = null, mixed $value = null): static
+ public function orWherePivot(Closure|Expression|string $column, mixed $operator = null, mixed $value = null): static
{
return $this->wherePivot($column, $operator, $value, 'or');
}
@@ -440,12 +482,12 @@ public function orWherePivot(mixed $column, mixed $operator = null, mixed $value
*
* In addition, new pivot records will receive this value.
*
- * @param array|Expression|string $column
+ * @param array|Expression|string $column
* @return $this
*
* @throws InvalidArgumentException
*/
- public function withPivotValue(mixed $column, mixed $value = null): static
+ public function withPivotValue(array|Expression|string $column, mixed $value = null): static
{
if (is_array($column)) {
foreach ($column as $name => $value) {
@@ -469,7 +511,7 @@ public function withPivotValue(mixed $column, mixed $value = null): static
*
* @return $this
*/
- public function orWherePivotIn(string $column, mixed $values): static
+ public function orWherePivotIn(Expression|string $column, mixed $values): static
{
return $this->wherePivotIn($column, $values, 'or');
}
@@ -477,10 +519,9 @@ public function orWherePivotIn(string $column, mixed $values): static
/**
* Set a "where not in" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function wherePivotNotIn(mixed $column, mixed $values, string $boolean = 'and'): static
+ public function wherePivotNotIn(Expression|string $column, mixed $values, string $boolean = 'and'): static
{
return $this->wherePivotIn($column, $values, $boolean, true);
}
@@ -490,7 +531,7 @@ public function wherePivotNotIn(mixed $column, mixed $values, string $boolean =
*
* @return $this
*/
- public function orWherePivotNotIn(string $column, mixed $values): static
+ public function orWherePivotNotIn(Expression|string $column, mixed $values): static
{
return $this->wherePivotNotIn($column, $values, 'or');
}
@@ -498,23 +539,19 @@ public function orWherePivotNotIn(string $column, mixed $values): static
/**
* Set a "where null" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function wherePivotNull(mixed $column, string $boolean = 'and', bool $not = false): static
+ public function wherePivotNull(Expression|string $column, string $boolean = 'and', bool $not = false): static
{
- $this->pivotWhereNulls[] = func_get_args();
-
- return $this->whereNull($this->qualifyPivotColumn($column), $boolean, $not);
+ return $this->addPivotConstraint('whereNull', [$column, $boolean, $not]);
}
/**
* Set a "where not null" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function wherePivotNotNull(mixed $column, string $boolean = 'and'): static
+ public function wherePivotNotNull(Expression|string $column, string $boolean = 'and'): static
{
return $this->wherePivotNull($column, $boolean, true);
}
@@ -522,10 +559,9 @@ public function wherePivotNotNull(mixed $column, string $boolean = 'and'): stati
/**
* Set a "or where null" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function orWherePivotNull(mixed $column, bool $not = false): static
+ public function orWherePivotNull(Expression|string $column, bool $not = false): static
{
return $this->wherePivotNull($column, 'or', $not);
}
@@ -533,10 +569,9 @@ public function orWherePivotNull(mixed $column, bool $not = false): static
/**
* Set a "or where not null" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function orWherePivotNotNull(mixed $column): static
+ public function orWherePivotNotNull(Expression|string $column): static
{
return $this->orWherePivotNull($column, true);
}
@@ -544,11 +579,10 @@ public function orWherePivotNotNull(mixed $column): static
/**
* Add an "order by" clause for a pivot table column.
*
- * @param Expression|string $column
* @param 'asc'|'desc'|SortDirection $direction
* @return $this
*/
- public function orderByPivot(mixed $column, SortDirection|string $direction = SortDirection::Ascending): static
+ public function orderByPivot(Expression|string $column, SortDirection|string $direction = SortDirection::Ascending): static
{
return $this->orderBy($this->qualifyPivotColumn($column), $direction);
}
@@ -556,10 +590,9 @@ public function orderByPivot(mixed $column, SortDirection|string $direction = So
/**
* Add an "order by desc" clause for a pivot table column.
*
- * @param Expression|string $column
* @return $this
*/
- public function orderByPivotDesc(mixed $column): static
+ public function orderByPivotDesc(Expression|string $column): static
{
return $this->orderBy($this->qualifyPivotColumn($column), SortDirection::Descending);
}
@@ -1564,7 +1597,7 @@ public function getPivotAccessor(): string
/**
* Get the pivot columns for this relationship.
*
- * @return array
+ * @return array
*/
public function getPivotColumns(): array
{
@@ -1574,10 +1607,9 @@ public function getPivotColumns(): array
/**
* Qualify the given column name by the pivot table.
*
- * @param Expression|string $column
- * @return Expression|string
+ * @return ($column is string ? string : Expression)
*/
- public function qualifyPivotColumn(mixed $column): mixed
+ public function qualifyPivotColumn(Expression|string $column): Expression|string
{
if ($this->query->getQuery()->getGrammar()->isExpression($column)) {
return $column;
diff --git a/src/database/src/Eloquent/Relations/Concerns/AsPivot.php b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php
index 3dd2c5a58..d17735720 100644
--- a/src/database/src/Eloquent/Relations/Concerns/AsPivot.php
+++ b/src/database/src/Eloquent/Relations/Concerns/AsPivot.php
@@ -34,12 +34,7 @@ trait AsPivot
/**
* The relation-owned predicates that identify this pivot row.
*
- * @var null|array{
- * wheres: array>,
- * whereIns: array>,
- * whereNulls: array>,
- * whereBetweens: array>
- * }
+ * @var null|list}>
*/
protected ?array $pivotConstraints = null;
@@ -170,20 +165,8 @@ protected function applyPivotConstraints(Builder $query): Builder
}
return $query->where(function (Builder $query): void {
- foreach ($this->pivotConstraints['wheres'] as $arguments) {
- $query->where(...$arguments);
- }
-
- foreach ($this->pivotConstraints['whereIns'] as $arguments) {
- $query->whereIn(...$arguments);
- }
-
- foreach ($this->pivotConstraints['whereNulls'] as $arguments) {
- $query->whereNull(...$arguments);
- }
-
- foreach ($this->pivotConstraints['whereBetweens'] as $arguments) {
- $query->whereBetween(...$arguments);
+ foreach ($this->pivotConstraints as [$method, $arguments]) {
+ $query->{$method}(...$arguments);
}
});
}
@@ -261,19 +244,12 @@ public function setPivotKeys(string $foreignKey, string $relatedKey): static
/**
* Set the relation-owned predicates for the pivot model.
*
- * @param array> $wheres
- * @param array> $whereIns
- * @param array> $whereNulls
- * @param array> $whereBetweens
+ * @param list}> $constraints
* @return $this
*/
- public function setPivotConstraints(
- array $wheres,
- array $whereIns,
- array $whereNulls,
- array $whereBetweens,
- ): static {
- $this->pivotConstraints = compact('wheres', 'whereIns', 'whereNulls', 'whereBetweens');
+ public function setPivotConstraints(array $constraints): static
+ {
+ $this->pivotConstraints = $constraints;
return $this;
}
diff --git a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php
index 6918429d7..6a0d5c40b 100644
--- a/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php
+++ b/src/database/src/Eloquent/Relations/Concerns/InteractsWithPivotTable.php
@@ -521,7 +521,16 @@ protected function getCurrentlyAttachedPivotsForIds(mixed $ids = null): BaseColl
*/
public function newPivot(array $attributes = [], bool $exists = false): Model
{
- $attributes = array_merge(array_column($this->pivotValues, 'value', 'column'), $attributes);
+ $defaults = [];
+
+ foreach ($this->pivotValues as $pivotValue) {
+ // Expression predicates have no attribute name to populate on the model.
+ if (is_string($pivotValue['column'])) {
+ $defaults[$pivotValue['column']] = $pivotValue['value'];
+ }
+ }
+
+ $attributes = array_merge($defaults, $attributes);
/** @var Pivot $pivot */
$pivot = $this->related->newPivot(
@@ -538,12 +547,7 @@ public function newPivot(array $attributes = [], bool $exists = false): Model
->setRelatedModel($this->related);
if ($this->hasPivotConstraints()) {
- $pivot->setPivotConstraints(
- wheres: $this->pivotWheres,
- whereIns: $this->pivotWhereIns,
- whereNulls: $this->pivotWhereNulls,
- whereBetweens: $this->pivotWhereBetweens,
- );
+ $pivot->setPivotConstraints($this->pivotConstraints);
}
return $pivot;
@@ -590,20 +594,8 @@ public function newPivotQuery(): QueryBuilder
if ($this->hasPivotConstraints()) {
$query->where(function (QueryBuilder $query): void {
- foreach ($this->pivotWheres as $arguments) {
- $query->where(...$arguments);
- }
-
- foreach ($this->pivotWhereIns as $arguments) {
- $query->whereIn(...$arguments);
- }
-
- foreach ($this->pivotWhereNulls as $arguments) {
- $query->whereNull(...$arguments);
- }
-
- foreach ($this->pivotWhereBetweens as $arguments) {
- $query->whereBetween(...$arguments);
+ foreach ($this->pivotConstraints as [$method, $arguments]) {
+ $query->{$method}(...$arguments);
}
});
}
@@ -616,10 +608,7 @@ public function newPivotQuery(): QueryBuilder
*/
protected function hasPivotConstraints(): bool
{
- return $this->pivotWheres !== []
- || $this->pivotWhereIns !== []
- || $this->pivotWhereNulls !== []
- || $this->pivotWhereBetweens !== [];
+ return $this->pivotConstraints !== [];
}
/**
diff --git a/src/database/src/Eloquent/Relations/MorphToMany.php b/src/database/src/Eloquent/Relations/MorphToMany.php
index 107bdb12a..9327aa1f1 100644
--- a/src/database/src/Eloquent/Relations/MorphToMany.php
+++ b/src/database/src/Eloquent/Relations/MorphToMany.php
@@ -144,12 +144,7 @@ public function newPivot(array $attributes = [], bool $exists = false): Model
->setMorphClass($this->morphClass);
if ($this->hasPivotConstraints()) {
- $pivot->setPivotConstraints(
- wheres: $this->pivotWheres,
- whereIns: $this->pivotWhereIns,
- whereNulls: $this->pivotWhereNulls,
- whereBetweens: $this->pivotWhereBetweens,
- );
+ $pivot->setPivotConstraints($this->pivotConstraints);
}
return $pivot;
diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md
index dd3a8c36e..08281f8b0 100644
--- a/src/docs/eloquent-relationships.md
+++ b/src/docs/eloquent-relationships.md
@@ -943,6 +943,19 @@ return $this->belongsToMany(Podcast::class)
->wherePivotNotNull('expired_at');
```
+You may pass a closure to `wherePivot` or `orWherePivot` to group conditions or call scopes on a [custom pivot model](#defining-custom-intermediate-table-models). The closure receives an Eloquent builder for the pivot model:
+
+```php
+use Hypervel\Database\Eloquent\Builder;
+
+return $this->belongsToMany(Role::class)
+ ->wherePivot(function (Builder $query) {
+ $query->where('approved', 1)->where('priority', '>', 2);
+ });
+```
+
+Qualify column names inside the closure if the related table has columns with the same names. Pivot filters also constrain `detach`, `sync`, and `updateExistingPivot` operations.
+
The `wherePivot` adds a where clause constraint to the query, but does not add the specified value when creating new models via the defined relationship. If you need to both query and create relationships with a particular pivot value, you may use the `withPivotValue` method:
```php
diff --git a/src/permission/src/PermissionRegistrar.php b/src/permission/src/PermissionRegistrar.php
index c156f18b7..fa28a9663 100644
--- a/src/permission/src/PermissionRegistrar.php
+++ b/src/permission/src/PermissionRegistrar.php
@@ -2249,16 +2249,13 @@ private function getHydratedPermissionRoleCollection(
->setRelatedModel($role);
if ($context->partition) {
- $pivot->setPivotConstraints(
- wheres: [[
+ $pivot->setPivotConstraints([
+ ['where', [
$context->partition->column,
'=',
$context->partition->value,
]],
- whereIns: [],
- whereNulls: [],
- whereBetweens: [],
- );
+ ]);
}
$role->setRelation('pivot', $pivot);
diff --git a/src/permission/src/Traits/HasPermissions.php b/src/permission/src/Traits/HasPermissions.php
index cbb2a5189..d10354b61 100644
--- a/src/permission/src/Traits/HasPermissions.php
+++ b/src/permission/src/Traits/HasPermissions.php
@@ -497,32 +497,26 @@ protected function getCachedDirectPermissions(): Collection
->setMorphType(Config::MORPH_TYPE)
->setMorphClass($model->getMorphClass());
- $pivotWheres = [];
- $pivotWhereNulls = [];
+ $pivotConstraints = [];
if ($context->partition) {
- $pivotWheres[] = [
+ $pivotConstraints[] = ['where', [
$context->partition->column,
'=',
$context->partition->value,
- ];
+ ]];
}
if ($context->teamScoped) {
if ($context->team === null) {
- $pivotWhereNulls[] = [$registrar->teamsKey];
+ $pivotConstraints[] = ['whereNull', [$registrar->teamsKey]];
} else {
- $pivotWheres[] = [$registrar->teamsKey, '=', $context->team];
+ $pivotConstraints[] = ['where', [$registrar->teamsKey, '=', $context->team]];
}
}
- if ($pivotWheres !== [] || $pivotWhereNulls !== []) {
- $morphPivot->setPivotConstraints(
- wheres: $pivotWheres,
- whereIns: [],
- whereNulls: $pivotWhereNulls,
- whereBetweens: [],
- );
+ if ($pivotConstraints !== []) {
+ $morphPivot->setPivotConstraints($pivotConstraints);
}
$permission->setRelation('pivot', $morphPivot);
@@ -1985,12 +1979,9 @@ protected function permissionWithRolePivot(
->setRelatedModel($permission);
if ($partition) {
- $pivot->setPivotConstraints(
- wheres: [[$partition->column, '=', $partition->value]],
- whereIns: [],
- whereNulls: [],
- whereBetweens: [],
- );
+ $pivot->setPivotConstraints([
+ ['where', [$partition->column, '=', $partition->value]],
+ ]);
}
$permission->setRelation('pivot', $pivot);
diff --git a/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php b/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php
index 75fe2ed6e..8e147cb47 100644
--- a/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php
+++ b/tests/Database/DatabaseEloquentBelongsToManyExpressionTest.php
@@ -13,6 +13,7 @@
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Database\Schema\Builder;
use Hypervel\Tests\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
class DatabaseEloquentBelongsToManyExpressionTest extends TestCase
{
@@ -49,14 +50,20 @@ public function testAmbiguousColumnsExpression(): void
$this->assertEquals(2, $tags->first()->getKey());
}
- public function testQualifiedColumnExpression(): void
+ #[TestWith(['wherePivotNotIn', ['2_t2']])]
+ #[TestWith(['orWherePivotNotIn', ['1_t1', '2_t2']])]
+ #[TestWith(['orWherePivotIn', ['3_t3']])]
+ public function testQualifiedColumnExpression(string $method, array $values): void
{
$this->seedData();
- $tags = Post::findOrFail(2)
- ->tags()
- ->wherePivotNotIn(new Expression("taggables.tag_id || '_' || taggables.type"), ['2_t2'])
- ->get();
+ $relation = Post::findOrFail(2)->tags();
+
+ if (str_starts_with($method, 'or')) {
+ $relation->wherePivot('tag_id', 0);
+ }
+
+ $tags = $relation->{$method}(new Expression("taggables.tag_id || '_' || taggables.type"), $values)->get();
$this->assertCount(1, $tags);
$this->assertEquals(3, $tags->first()->getKey());
diff --git a/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php b/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php
new file mode 100644
index 000000000..2e0cc3e74
--- /dev/null
+++ b/tests/Database/DatabaseEloquentBelongsToManyWherePivotClosureTest.php
@@ -0,0 +1,342 @@
+database = new DB;
+
+ $db->addConnection([
+ 'driver' => 'sqlite',
+ 'database' => ':memory:',
+ ]);
+
+ $db->bootEloquent();
+ $db->setAsGlobal();
+
+ $this->createSchema();
+ }
+
+ /**
+ * Create the test tables.
+ */
+ protected function createSchema(): void
+ {
+ $this->schema()->create('users', function (Blueprint $table): void {
+ $table->id();
+ $table->string('name');
+ });
+
+ $this->schema()->create('projects', function (Blueprint $table): void {
+ $table->id();
+ $table->string('title');
+ });
+
+ $this->schema()->create('project_user', function (Blueprint $table): void {
+ $table->unsignedBigInteger('project_id');
+ $table->unsignedBigInteger('user_id');
+ $table->string('role')->default('member');
+ $table->boolean('muted')->default(false);
+ });
+ }
+
+ /**
+ * Drop the test tables.
+ */
+ protected function tearDown(): void
+ {
+ $this->schema()->drop('project_user');
+ $this->schema()->drop('projects');
+ $this->schema()->drop('users');
+
+ parent::tearDown();
+ }
+
+ public function testWherePivotWithClosureCallsPivotModelScope(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $active = WherePivotClosureUser::create(['name' => 'Active User']);
+ $muted = WherePivotClosureUser::create(['name' => 'Muted User']);
+
+ $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'admin']);
+ $project->subscribers()->attach($muted->id, ['muted' => true, 'role' => 'member']);
+
+ $results = $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->active();
+ })->get();
+
+ $this->assertCount(1, $results);
+ $this->assertEquals($active->id, $results->first()->id);
+ }
+
+ public function testOrWherePivotWithClosureCallsPivotModelScope(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $admin = WherePivotClosureUser::create(['name' => 'Admin']);
+ $active = WherePivotClosureUser::create(['name' => 'Active']);
+ $mutedMember = WherePivotClosureUser::create(['name' => 'Muted Member']);
+
+ $project->subscribers()->attach($admin->id, ['muted' => true, 'role' => 'admin']);
+ $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'member']);
+ $project->subscribers()->attach($mutedMember->id, ['muted' => true, 'role' => 'member']);
+
+ $results = $project->subscribers()
+ ->wherePivot('role', 'admin')
+ ->orWherePivot(function (Builder $query): void {
+ $query->active();
+ })
+ ->get();
+
+ $this->assertCount(2, $results);
+ $this->assertTrue($results->contains('id', $admin->id));
+ $this->assertTrue($results->contains('id', $active->id));
+ }
+
+ public function testWherePivotClosureWithMultipleScopeConditions(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $activeAdmin = WherePivotClosureUser::create(['name' => 'Active Admin']);
+ $activeMember = WherePivotClosureUser::create(['name' => 'Active Member']);
+ $mutedAdmin = WherePivotClosureUser::create(['name' => 'Muted Admin']);
+
+ $project->subscribers()->attach($activeAdmin->id, ['muted' => false, 'role' => 'admin']);
+ $project->subscribers()->attach($activeMember->id, ['muted' => false, 'role' => 'member']);
+ $project->subscribers()->attach($mutedAdmin->id, ['muted' => true, 'role' => 'admin']);
+
+ $results = $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->active()->admins();
+ })->get();
+
+ $this->assertCount(1, $results);
+ $this->assertEquals($activeAdmin->id, $results->first()->id);
+ }
+
+ public function testWherePivotClosureWithInlineWhere(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $user1 = WherePivotClosureUser::create(['name' => 'User 1']);
+ $user2 = WherePivotClosureUser::create(['name' => 'User 2']);
+
+ $project->subscribers()->attach($user1->id, ['muted' => false, 'role' => 'admin']);
+ $project->subscribers()->attach($user2->id, ['muted' => false, 'role' => 'member']);
+
+ $results = $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->where('role', 'admin');
+ })->get();
+
+ $this->assertCount(1, $results);
+ $this->assertEquals($user1->id, $results->first()->id);
+ }
+
+ public function testWherePivotWithClosureScopesDetach(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $active = WherePivotClosureUser::create(['name' => 'Active User']);
+ $muted = WherePivotClosureUser::create(['name' => 'Muted User']);
+
+ $project->subscribers()->attach($active->id, ['muted' => false]);
+ $project->subscribers()->attach($muted->id, ['muted' => true]);
+
+ $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->active();
+ })->detach();
+
+ $this->assertCount(1, $project->subscribers()->get());
+ $this->assertTrue($project->subscribers()->get()->contains('id', $muted->id));
+ }
+
+ public function testWherePivotWithClosureScopesSync(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $active = WherePivotClosureUser::create(['name' => 'Active User']);
+ $muted = WherePivotClosureUser::create(['name' => 'Muted User']);
+
+ $project->subscribers()->attach($active->id, ['muted' => false]);
+ $project->subscribers()->attach($muted->id, ['muted' => true]);
+
+ $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->active();
+ })->sync([]);
+
+ $this->assertTrue($project->subscribers()->get()->contains('id', $muted->id));
+ $this->assertFalse($project->subscribers()->get()->contains('id', $active->id));
+ }
+
+ public function testWherePivotWithClosureScopesUpdateExistingPivot(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $active = WherePivotClosureUser::create(['name' => 'Active User']);
+ $muted = WherePivotClosureUser::create(['name' => 'Muted User']);
+
+ $project->subscribers()->attach($active->id, ['muted' => false, 'role' => 'member']);
+ $project->subscribers()->attach($muted->id, ['muted' => true, 'role' => 'member']);
+
+ $affected = $project->subscribers()->wherePivot(function (Builder $query): void {
+ $query->active();
+ })->updateExistingPivot($muted->id, ['role' => 'admin']);
+
+ $this->assertSame(0, $affected);
+ $this->assertSame('member', $project->subscribers()->find($muted->id)->pivot->role);
+ }
+
+ public function testClosureUsesTheRelationsReadConnection(): void
+ {
+ $this->database->addConnection(['driver' => 'sqlite', 'database' => ':memory:'], 'tenant');
+ $project = (new WherePivotClosureProject)->setConnection('tenant::read');
+ $project->id = 1;
+ $relation = $project->subscribers();
+ $connection = $relation->getQuery()->getConnection();
+
+ $this->assertNotSame($this->database->getConnection('tenant'), $connection);
+
+ $relation->wherePivot(function (Builder $query) use ($connection): void {
+ $this->assertSame($connection, $query->getConnection());
+ $query->where('muted', false);
+ });
+ }
+
+ public function testPivotClosureWorksWithEagerLoadingAndExistenceQueries(): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Active project']);
+ $mutedProject = WherePivotClosureProject::create(['title' => 'Muted project']);
+ $user = WherePivotClosureUser::create(['name' => 'Subscriber']);
+ $project->subscribers()->attach($user->id, ['muted' => false]);
+ $mutedProject->subscribers()->attach($user->id, ['muted' => true]);
+
+ $projects = WherePivotClosureProject::with('activeSubscribers')
+ ->withCount('activeSubscribers')
+ ->whereHas('activeSubscribers')
+ ->get();
+
+ $this->assertSame([$project->id], $projects->modelKeys());
+ $this->assertSame([$user->id], $projects->first()->activeSubscribers->modelKeys());
+ $this->assertSame(1, $projects->first()->active_subscribers_count);
+ }
+
+ #[TestWith(['wherePivot'])]
+ #[TestWith(['wherePivotIn'])]
+ public function testSubqueryIsEvaluatedOnceForReadsAndWrites(string $method): void
+ {
+ $project = WherePivotClosureProject::create(['title' => 'Project 1']);
+ $admin = WherePivotClosureUser::create(['name' => 'Admin']);
+ $member = WherePivotClosureUser::create(['name' => 'Member']);
+ $project->subscribers()->attach($admin->id, ['role' => 'admin']);
+ $project->subscribers()->attach($member->id, ['role' => 'member']);
+ $calls = 0;
+
+ $relation = $project->subscribers()->{$method}('role', function (QueryBuilder $query) use (&$calls): void {
+ $query->selectRaw('?', [++$calls === 1 ? 'admin' : 'member']);
+ });
+
+ $this->assertSame([$admin->id], $relation->get()->modelKeys());
+ $this->assertSame(1, $relation->detach());
+ $this->assertSame(1, $calls);
+ $this->assertSame([$member->id], $project->subscribers()->get()->modelKeys());
+ }
+
+ /**
+ * Get the test connection.
+ */
+ protected function connection(): ConnectionInterface
+ {
+ return Eloquent::getConnectionResolver()->connection();
+ }
+
+ /**
+ * Get the schema builder.
+ */
+ protected function schema(): SchemaBuilder
+ {
+ return $this->connection()->getSchemaBuilder();
+ }
+}
+
+class WherePivotClosureProject extends Eloquent
+{
+ protected ?string $table = 'projects';
+
+ protected array $guarded = [];
+
+ public bool $timestamps = false;
+
+ /**
+ * Get the project subscribers.
+ *
+ * @return BelongsToMany
+ */
+ public function subscribers(): BelongsToMany
+ {
+ return $this->belongsToMany(WherePivotClosureUser::class, 'project_user', 'project_id', 'user_id')
+ ->using(WherePivotClosureSubscription::class)
+ ->withPivot(['role', 'muted']);
+ }
+
+ /**
+ * Get active project subscribers.
+ *
+ * @return BelongsToMany
+ */
+ public function activeSubscribers(): BelongsToMany
+ {
+ return $this->subscribers()->wherePivot(fn (Builder $query): Builder => $query->active());
+ }
+}
+
+class WherePivotClosureUser extends Eloquent
+{
+ protected ?string $table = 'users';
+
+ protected array $guarded = [];
+
+ public bool $timestamps = false;
+}
+
+class WherePivotClosureSubscription extends Pivot
+{
+ protected ?string $table = 'project_user';
+
+ /**
+ * Restrict the query to active subscriptions.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('muted', false);
+ }
+
+ /**
+ * Restrict the query to administrators.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeAdmins(Builder $query): Builder
+ {
+ return $query->where('role', 'admin');
+ }
+}
diff --git a/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php
index c81729b2e..e8d97d4be 100644
--- a/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php
+++ b/tests/Database/Eloquent/Relations/BelongsToManyPivotEventsTest.php
@@ -4,14 +4,17 @@
namespace Hypervel\Tests\Database\Eloquent\Relations;
+use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\Relations\BelongsToMany;
use Hypervel\Database\Eloquent\Relations\Pivot;
+use Hypervel\Database\Query\Builder as QueryBuilder;
use Hypervel\Foundation\Testing\RefreshDatabase;
use Hypervel\Support\ClassInvoker;
use Hypervel\Support\Facades\DB;
use Hypervel\Testbench\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
/**
* Tests that pivot model events fire when using a custom pivot class via ->using().
@@ -148,45 +151,34 @@ public function testDetachWithoutCustomPivotDoesNotFireEvents(): void
$this->assertEquals([], PivotEventsTestCollaborator::$eventsCalled);
}
- public function testStockDetachKeepsPivotOrPredicatesInsideTheParentIdentity(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDetachPreservesPivotPredicateOrderAndParentIdentity(bool $customPivot): void
{
$user = PivotEventsTestUser::forceCreate(['name' => 'Test User']);
$otherUser = PivotEventsTestUser::forceCreate(['name' => 'Other User']);
$role = PivotEventsTestRole::forceCreate(['name' => 'Admin']);
- $user->rolesWithoutPivot()->attach($role->id, ['is_active' => true]);
- $otherUser->rolesWithoutPivot()->attach($role->id, ['is_active' => false]);
+ $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 1, 'is_active' => false]);
+ $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 2, 'priority' => 5, 'is_active' => false]);
+ $user->rolesWithoutPivot()->attach($role->id, ['scope_id' => 3, 'priority' => 5, 'is_active' => true]);
+ $otherUser->rolesWithoutPivot()->attach($role->id, ['priority' => 5, 'is_active' => true]);
- $deleted = $user->rolesWithBooleanScope()->detach($role->id);
+ $relation = $user->rolesWithoutPivot()
+ ->wherePivot('scope_id', 1)
+ ->orWherePivotIn('priority', [5])
+ ->wherePivot('is_active', true);
- $this->assertSame(1, $deleted);
- $this->assertDatabaseMissing('pivot_events_role_user', [
- 'user_id' => $user->id,
- 'role_id' => $role->id,
- ]);
- $this->assertDatabaseHas('pivot_events_role_user', [
- 'user_id' => $otherUser->id,
- 'role_id' => $role->id,
- ]);
- }
-
- public function testCustomDetachKeepsPivotOrPredicatesInsideTheParentIdentity(): void
- {
- $user = PivotEventsTestUser::forceCreate(['name' => 'Test User']);
- $otherUser = PivotEventsTestUser::forceCreate(['name' => 'Other User']);
- $role = PivotEventsTestRole::forceCreate(['name' => 'Admin']);
-
- $user->rolesWithoutPivot()->attach($role->id, ['is_active' => true]);
- $otherUser->rolesWithoutPivot()->attach($role->id, ['is_active' => false]);
-
- $deleted = $user->rolesWithCustomBooleanScope()->detach($role->id);
+ if ($customPivot) {
+ $relation->using(PivotEventsTestCollaborator::class);
+ }
- $this->assertSame(1, $deleted);
- $this->assertSame(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled);
- $this->assertDatabaseMissing('pivot_events_role_user', [
- 'user_id' => $user->id,
- 'role_id' => $role->id,
- ]);
+ $this->assertSame(2, $relation->detach($role->id));
+ $this->assertSame([2], DB::table('pivot_events_role_user')->where('user_id', $user->id)->pluck('scope_id')->all());
+ $this->assertSame(
+ $customPivot ? ['deleting', 'deleted', 'deleting', 'deleted'] : [],
+ PivotEventsTestCollaborator::$eventsCalled,
+ );
$this->assertDatabaseHas('pivot_events_role_user', [
'user_id' => $otherUser->id,
'role_id' => $role->id,
@@ -308,12 +300,13 @@ public function testCustomPivotWritesBypassMassAssignmentFilteringInStrictMode()
]);
}
- public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(): void
+ #[DataProvider('pivotScopeProvider')]
+ public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints(string $scope): void
{
$user = PivotEventsTestUser::forceCreate(['name' => 'Test User']);
$role = PivotEventsTestRole::forceCreate(['name' => 'Admin']);
- $user->rolesInScopeOne()->attach($role->id, ['is_active' => true]);
+ $user->rolesInScopeOne($scope)->attach($role->id, ['is_active' => true]);
DB::table('pivot_events_role_user')->insert([
'user_id' => $user->id,
'role_id' => $role->id,
@@ -321,7 +314,7 @@ public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints():
'is_active' => true,
]);
- $pivot = $user->rolesInScopeOne()->firstOrFail()->pivot;
+ $pivot = unserialize(serialize($user->rolesInScopeOne($scope)->firstOrFail()))->pivot;
$pivot->is_active = false;
$this->assertTrue($pivot->save());
@@ -351,12 +344,13 @@ public function testHydratedStockPivotSaveAndDeleteRetainRelationConstraints():
]);
}
- public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void
+ #[DataProvider('pivotScopeProvider')]
+ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(string $scope): void
{
$user = PivotEventsTestUser::forceCreate(['name' => 'Test User']);
$role = PivotEventsTestRole::forceCreate(['name' => 'Admin']);
- $user->rolesWithScopedPivot()->attach($role->id, ['is_active' => false]);
+ $user->rolesWithScopedPivot($scope)->attach($role->id, ['is_active' => false]);
DB::table('pivot_events_role_user')->insert([
'user_id' => $user->id,
'role_id' => $role->id,
@@ -366,7 +360,7 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void
PivotEventsTestCollaborator::$eventsCalled = [];
- $this->assertSame(1, $user->rolesWithScopedPivot()->updateExistingPivot(
+ $this->assertSame(1, $user->rolesWithScopedPivot($scope)->updateExistingPivot(
$role->id,
['is_active' => true],
));
@@ -389,7 +383,7 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void
PivotEventsTestCollaborator::$eventsCalled = [];
- $this->assertSame(1, $user->rolesWithScopedPivot()->detach($role->id));
+ $this->assertSame(1, $user->rolesWithScopedPivot($scope)->detach($role->id));
$this->assertSame(['deleting', 'deleted'], PivotEventsTestCollaborator::$eventsCalled);
$this->assertDatabaseMissing('pivot_events_role_user', [
'user_id' => $user->id,
@@ -403,6 +397,14 @@ public function testCustomPivotUpdateAndDetachRetainRelationConstraints(): void
]);
}
+ /**
+ * Provide scalar, closure and subquery pivot filters.
+ */
+ public static function pivotScopeProvider(): array
+ {
+ return [['scalar'], ['closure'], ['subquery']];
+ }
+
public function testPrimaryKeyPivotKeepsNativeIdentityWhenAConstraintColumnChanges(): void
{
$user = PivotEventsTestUser::forceCreate(['name' => 'Test User']);
@@ -539,39 +541,29 @@ public function rolesWithoutPivot(): BelongsToMany
}
/**
+ * Get roles limited to the first scope.
+ *
* @return BelongsToMany
*/
- public function rolesWithBooleanScope(): BelongsToMany
- {
- return $this->rolesWithoutPivot()
- ->wherePivot('is_active', true)
- ->orWherePivot('is_active', false);
- }
-
- /**
- * @return BelongsToMany
- */
- public function rolesWithCustomBooleanScope(): BelongsToMany
+ public function rolesInScopeOne(string $scope = 'scalar'): BelongsToMany
{
- return $this->rolesWithBooleanScope()->using(PivotEventsTestCollaborator::class);
- }
+ $relation = $this->rolesWithoutPivot()->withPivot('scope_id');
- /**
- * @return BelongsToMany
- */
- public function rolesInScopeOne(): BelongsToMany
- {
- return $this->rolesWithoutPivot()
- ->withPivot('scope_id')
- ->withPivotValue('scope_id', 1);
+ return match ($scope) {
+ 'scalar' => $relation->withPivotValue('scope_id', 1),
+ 'closure' => $relation->wherePivot(fn (Builder $query): Builder => $query->where('scope_id', 1)),
+ 'subquery' => $relation->wherePivotIn('scope_id', fn (QueryBuilder $query): QueryBuilder => $query->selectRaw('?', [1])),
+ };
}
/**
+ * Get scoped roles using a custom pivot.
+ *
* @return BelongsToMany
*/
- public function rolesWithScopedPivot(): BelongsToMany
+ public function rolesWithScopedPivot(string $scope = 'scalar'): BelongsToMany
{
- return $this->rolesInScopeOne()->using(PivotEventsTestCollaborator::class);
+ return $this->rolesInScopeOne($scope)->using(PivotEventsTestCollaborator::class);
}
/**
diff --git a/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php
index 10f8f1d99..7491d7766 100644
--- a/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php
+++ b/tests/Database/Eloquent/Relations/MorphToManyPivotEventsTest.php
@@ -4,11 +4,13 @@
namespace Hypervel\Tests\Database\Eloquent\Relations;
+use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\Relations\MorphPivot;
use Hypervel\Database\Eloquent\Relations\MorphToMany;
use Hypervel\Foundation\Testing\RefreshDatabase;
use Hypervel\Testbench\TestCase;
+use PHPUnit\Framework\Attributes\TestWith;
/**
* Tests that pivot model events fire when using a custom pivot class via ->using()
@@ -254,7 +256,9 @@ public function testToggleFiresEventsForAttachAndDetach(): void
// Tests for morph type constraint in delete
// =========================================================================
- public function testDetachOnlyDeletesForCorrectMorphType(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testDetachOnlyDeletesForCorrectMorphType(bool $hydrated): void
{
// Create a post and a video, both with the same tag
$post = MorphPivotEventsTestPost::forceCreate(['title' => 'Test Post']);
@@ -262,12 +266,17 @@ public function testDetachOnlyDeletesForCorrectMorphType(): void
$tag = MorphPivotEventsTestTag::forceCreate(['name' => 'PHP']);
$post->tagsWithPivot()->attach($tag->id);
+ $post->tagsWithPivot()->attach($tag->id, ['scope_id' => 2]);
$video->tagsWithPivot()->attach($tag->id);
MorphPivotEventsTestTaggable::$eventsCalled = [];
- // Detach from post only
- $deleted = $post->tagsWithPivot()->detach($tag->id);
+ $relation = $post->tagsWithPivot()
+ ->wherePivot(fn (Builder $query): Builder => $query->where('scope_id', 1));
+
+ $deleted = $hydrated
+ ? unserialize(serialize($relation->firstOrFail()))->pivot->delete()
+ : $relation->detach($tag->id);
$this->assertSame(1, $deleted);
@@ -278,11 +287,19 @@ public function testDetachOnlyDeletesForCorrectMorphType(): void
'tag_id' => $tag->id,
]);
- // Post should not have the tag
+ $this->assertDatabaseHas('pivot_events_taggables', [
+ 'taggable_id' => $post->id,
+ 'taggable_type' => MorphPivotEventsTestPost::class,
+ 'tag_id' => $tag->id,
+ 'scope_id' => 2,
+ ]);
+
+ // Only the selected post scope should lose the tag.
$this->assertDatabaseMissing('pivot_events_taggables', [
'taggable_id' => $post->id,
'taggable_type' => MorphPivotEventsTestPost::class,
'tag_id' => $tag->id,
+ 'scope_id' => 1,
]);
}
}
diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php
index 5c44e9284..7befddb73 100644
--- a/tests/Integration/Database/EloquentBelongsToManyTest.php
+++ b/tests/Integration/Database/EloquentBelongsToManyTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Integration\Database\EloquentBelongsToManyTest;
+use Hypervel\Database\Eloquent\Builder;
use Hypervel\Database\Eloquent\Collection;
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\ModelNotFoundException;
@@ -1091,6 +1092,47 @@ public function testWherePivotOnString()
$this->assertEquals($relationTag->getAttributes(), $tag->getAttributes());
}
+ public function testWherePivotWithClosure(): void
+ {
+ $tag1 = Tag::create(['name' => Str::random()])->fresh();
+ $tag2 = Tag::create(['name' => Str::random()])->fresh();
+ $post = Post::create(['title' => Str::random()]);
+
+ DB::table('posts_tags')->insert([
+ ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'],
+ ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'],
+ ]);
+
+ $tags = $post->tagsWithCustomExtraPivot()->wherePivot(function (Builder $query): void {
+ $query->active();
+ })->get();
+
+ $this->assertCount(1, $tags);
+ $this->assertEquals($tag1->id, $tags->first()->id);
+ }
+
+ public function testOrWherePivotWithClosure(): void
+ {
+ $tag1 = Tag::create(['name' => Str::random()])->fresh();
+ $tag2 = Tag::create(['name' => Str::random()])->fresh();
+ $tag3 = Tag::create(['name' => Str::random()])->fresh();
+ $post = Post::create(['title' => Str::random()]);
+
+ DB::table('posts_tags')->insert([
+ ['post_id' => $post->id, 'tag_id' => $tag1->id, 'flag' => 'foo'],
+ ['post_id' => $post->id, 'tag_id' => $tag2->id, 'flag' => 'bar'],
+ ['post_id' => $post->id, 'tag_id' => $tag3->id, 'flag' => 'baz'],
+ ]);
+
+ $tags = $post->tagsWithCustomExtraPivot()->wherePivot('flag', 'bar')->orWherePivot(function (Builder $query): void {
+ $query->active();
+ })->get();
+
+ $this->assertCount(2, $tags);
+ $this->assertTrue($tags->contains('id', $tag1->id));
+ $this->assertTrue($tags->contains('id', $tag2->id));
+ }
+
public function testFirstWhere()
{
$tag = Tag::create(['name' => 'foo'])->fresh();
@@ -1704,6 +1746,17 @@ public function getCreatedAtAttribute(mixed $value): string
{
return CarbonImmutable::parse($value)->format('U');
}
+
+ /**
+ * Restrict the query to active tags.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('flag', 'foo');
+ }
}
class TagWithGlobalScope extends Model
diff --git a/tests/Integration/Database/EloquentPivotTest.php b/tests/Integration/Database/EloquentPivotTest.php
index 88cf55b23..89f6262a9 100644
--- a/tests/Integration/Database/EloquentPivotTest.php
+++ b/tests/Integration/Database/EloquentPivotTest.php
@@ -8,6 +8,7 @@
use Hypervel\Database\Eloquent\Model;
use Hypervel\Database\Eloquent\Relations\BelongsToMany;
use Hypervel\Database\Eloquent\Relations\Pivot;
+use Hypervel\Database\Query\Expression;
use Hypervel\Database\Schema\Blueprint;
use Hypervel\Support\Facades\Schema;
use Hypervel\Tests\Integration\Database\DatabaseTestCase;
@@ -89,6 +90,25 @@ public function testPivotValuesCanBeSetFromRelationDefinition()
$this->assertSame('inactive', $user->inactiveSubscriptions->first()->pivot->status);
}
+ public function testPivotValueExpressionsAllowHydratingMatchingRows(): void
+ {
+ $user = PivotTestUser::forceCreate(['email' => 'taylor@hypervel.com']);
+ $active = PivotTestProject::forceCreate(['name' => 'Active Project']);
+ $inactive = PivotTestProject::forceCreate(['name' => 'Inactive Project']);
+
+ $user->activeSubscriptions()->attach($active);
+ $user->inactiveSubscriptions()->attach($inactive);
+
+ $projects = $user->belongsToMany(PivotTestProject::class, 'subscriptions', 'user_id', 'project_id')
+ ->withPivot('status')
+ ->withPivotValue(new Expression('upper(subscriptions.status)'), 'ACTIVE')
+ ->get();
+
+ $this->assertSame([$active->id], $projects->modelKeys());
+ $this->assertSame('active', $projects->sole()->pivot->status);
+ $this->assertFalse($projects->sole()->pivot->isDirty());
+ }
+
#[DataProvider('compoundKeyColumns')]
public function testCompoundPivotSelectionRejectsMissingKeys(string $missingColumn): void
{
From e1ec58ddb9cac0f43c86559f7721e80ee3ae8160 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:05 +0000
Subject: [PATCH 05/16] Align Eloquent key filters and release eager-load
builders
Consolidate key predicates through whereKey with boolean and negation arguments, preserving binary/Stringable keys and adding closure and query subqueries. Align OR helpers and subclass tests with the current upstream implementation, including the intervening revert and replacement.
Make the three eager-load constraint closures static so query builders are released without waiting for cyclic garbage collection. Add SQL/binding cases and verify builder release with garbage collection disabled; document subquery key filters.
Upstream:
https://github.com/laravel/framework/pull/61154
https://github.com/laravel/framework/pull/61236
https://github.com/laravel/framework/pull/61242
https://github.com/laravel/framework/pull/61395
https://github.com/laravel/framework/pull/61496
https://github.com/laravel/framework/pull/61264
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/database/src/Eloquent/Builder.php | 46 ++--
.../Concerns/QueriesRelationships.php | 4 +-
src/docs/eloquent.md | 2 +-
.../Database/DatabaseEloquentBuilderTest.php | 209 ++++++++++++------
4 files changed, 158 insertions(+), 103 deletions(-)
diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php
index 30db75470..7ed161b53 100644
--- a/src/database/src/Eloquent/Builder.php
+++ b/src/database/src/Eloquent/Builder.php
@@ -260,18 +260,26 @@ public function removedScopes(): array
/**
* Add a where clause on the primary key to the query.
+ *
+ * @return $this
*/
- public function whereKey(mixed $id): static
+ public function whereKey(mixed $id, string $boolean = 'and', bool $not = false): static
{
if ($id instanceof Model) {
$id = $id->getKey();
}
+ if ($id instanceof Closure || $id instanceof QueryBuilder || $id instanceof self || $id instanceof Relation) {
+ $this->query->whereIn($this->model->getQualifiedKeyName(), $id, $boolean, $not);
+
+ return $this;
+ }
+
if (is_array($id) || $id instanceof Arrayable) {
if (in_array($this->model->getKeyType(), ['int', 'integer'], true)) {
- $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id);
+ $this->query->whereIntegerInRaw($this->model->getQualifiedKeyName(), $id, $boolean, $not);
} else {
- $this->query->whereIn($this->model->getQualifiedKeyName(), $id);
+ $this->query->whereIn($this->model->getQualifiedKeyName(), $id, $boolean, $not);
}
return $this;
@@ -281,33 +289,17 @@ public function whereKey(mixed $id): static
$id = (string) $id;
}
- return $this->where($this->model->getQualifiedKeyName(), '=', $id);
+ return $this->where($this->model->getQualifiedKeyName(), $not ? '!=' : '=', $id, $boolean);
}
/**
* Add a where clause on the primary key to the query.
+ *
+ * @return $this
*/
- public function whereKeyNot(mixed $id): static
+ public function whereKeyNot(mixed $id, string $boolean = 'and'): static
{
- if ($id instanceof Model) {
- $id = $id->getKey();
- }
-
- if (is_array($id) || $id instanceof Arrayable) {
- if (in_array($this->model->getKeyType(), ['int', 'integer'], true)) {
- $this->query->whereIntegerNotInRaw($this->model->getQualifiedKeyName(), $id);
- } else {
- $this->query->whereNotIn($this->model->getQualifiedKeyName(), $id);
- }
-
- return $this;
- }
-
- if ($id !== null && $this->model->getKeyType() === 'string' && ! $id instanceof BinaryParameter) {
- $id = (string) $id;
- }
-
- return $this->where($this->model->getQualifiedKeyName(), '!=', $id);
+ return $this->whereKey($id, $boolean, true);
}
/**
@@ -317,7 +309,7 @@ public function whereKeyNot(mixed $id): static
*/
public function orWhereKey(mixed $id): static
{
- return $this->where(fn (self $query): self => $query->whereKey($id), null, null, 'or');
+ return $this->whereKey($id, 'or');
}
/**
@@ -327,7 +319,7 @@ public function orWhereKey(mixed $id): static
*/
public function orWhereKeyNot(mixed $id): static
{
- return $this->where(fn (self $query): self => $query->whereKeyNot($id), null, null, 'or');
+ return $this->whereKeyNot($id, 'or');
}
/**
@@ -1779,7 +1771,7 @@ protected function prepareNestedWithRelationships(array $relations, string $pref
*/
protected function combineConstraints(array $constraints): Closure
{
- return function ($builder) use ($constraints) {
+ return static function ($builder) use ($constraints) {
foreach ($constraints as $constraint) {
$builder = $constraint($builder) ?? $builder;
}
diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php
index fe28f96bd..0d47fe167 100644
--- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php
+++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php
@@ -163,7 +163,7 @@ public function whereHas(Relation|string $relation, ?Closure $callback = null, s
public function withWhereHas(string $relation, ?Closure $callback = null, string $operator = '>=', ExpressionContract|int $count = 1): static
{
return $this->whereHas(Str::before($relation, ':'), $callback, $operator, $count)
- ->with($callback ? [$relation => fn ($query) => $callback($query)] : $relation);
+ ->with($callback ? [$relation => static fn ($query) => $callback($query)] : $relation);
}
/**
@@ -423,7 +423,7 @@ public function withWhereRelation(string $relation, Closure|string|array|Express
{
return $this->whereRelation($relation, $column, $operator, $value)
->with([
- $relation => fn ($query) => $column instanceof Closure
+ $relation => static fn ($query) => $column instanceof Closure
? $column($query)
: $query->where($column, $operator, $value),
]);
diff --git a/src/docs/eloquent.md b/src/docs/eloquent.md
index 65bad440f..204a45220 100644
--- a/src/docs/eloquent.md
+++ b/src/docs/eloquent.md
@@ -500,7 +500,7 @@ $flights = Flight::where('active', 1)
> [!NOTE]
> Since Eloquent models are query builders, you should review all of the methods provided by Hypervel's [query builder](/docs/{{version}}/queries). You may use any of these methods when writing your Eloquent queries.
-The `whereKey` and `whereKeyNot` methods constrain a query using the model's primary key. Both accept a single key or multiple keys. To join these conditions with `or`, use `orWhereKey` or `orWhereKeyNot`:
+The `whereKey` and `whereKeyNot` methods constrain a query using the model's primary key. Both accept a single key, multiple keys, or a subquery selecting keys. To join these conditions with `or`, use `orWhereKey` or `orWhereKeyNot`:
```php
$flights = Flight::where('active', 1)->orWhereKey([1, 2])->get();
diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php
index 9252e46fe..256d06e52 100755
--- a/tests/Database/DatabaseEloquentBuilderTest.php
+++ b/tests/Database/DatabaseEloquentBuilderTest.php
@@ -35,6 +35,7 @@
use PHPUnit\Framework\Attributes\TestWith;
use stdClass;
use Stringable;
+use WeakReference;
class DatabaseEloquentBuilderTest extends TestCase
{
@@ -44,7 +45,7 @@ public function testFindMethod(): void
$model = $this->getMockModel();
$builder->setModel($model);
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$expectedModel = m::mock(Model::class);
$builder->expects('first')->with(['column'])->andReturn($expectedModel);
@@ -64,7 +65,7 @@ public function testFindMethodsUseModelPrimaryKey(string $method): void
$builder->setModel($model);
$target = (new Stub)->forceFill(['id' => 1, 'other_id' => 2]);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1);
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and');
$builder->expects('first')->with(['column'])->andReturn($target);
$this->assertSame($target, $builder->{$method}($target, ['column']));
@@ -76,7 +77,7 @@ public function testFindOrFailThrowsForMissingModelArgument(): void
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1);
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and');
$builder->expects('first')->with(['column'])->andReturn(null);
$this->expectExceptionObject((new ModelNotFoundException)->setModel(get_class($model), 1));
@@ -90,7 +91,7 @@ public function testFindSoleMethod(): void
$model = $this->getMockModel();
$builder->setModel($model);
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$expectedModel = m::mock(Model::class);
$builder->expects('sole')->with(['column'])->andReturn($expectedModel);
@@ -105,7 +106,7 @@ public function testFindManyMethod(): void
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
$builder->setModel($model);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', ['one', 'two']);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', ['one', 'two'], 'and', false);
$expectedCollection = new Collection(['baz']);
$builder->expects('get')->with(['column'])->andReturn($expectedCollection);
@@ -147,7 +148,7 @@ public function testFindOrNewMethodModelFound(): void
$builder = m::mock(Builder::class . '[first]', [$this->getMockQueryBuilder()]);
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$builder->expects('first')->with(['column'])->andReturn($expectedModel);
$expected = $model->findOrNew('bar', ['column']);
@@ -163,7 +164,7 @@ public function testFindOrNewMethodModelNotFound(): void
$builder = m::mock(Builder::class . '[first]', [$this->getMockQueryBuilder()]);
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$builder->expects('first')->with(['column'])->andReturn(null);
$result = $model->findOrNew('bar', ['column']);
@@ -180,7 +181,7 @@ public function testFindOrFailMethodThrowsModelNotFoundException(): void
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$builder->expects('first')->with(['column'])->andReturn(null);
$builder->findOrFail('bar', ['column']);
}
@@ -213,7 +214,7 @@ public function testFindOrFailMethodWithManyThrowsModelNotFoundException(): void
$builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]);
$builder->setModel($model);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false);
$builder->expects('get')->with(['column'])->andReturn(new Collection([$model]));
$builder->findOrFail([1, 2], ['column']);
}
@@ -228,7 +229,7 @@ public function testFindOrFailMethodWithManyUsingCollectionThrowsModelNotFoundEx
$builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]);
$builder->setModel($model);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false);
$builder->expects('get')->with(['column'])->andReturn(new Collection([$model]));
$builder->findOrFail(new Collection([1, 2]), ['column']);
}
@@ -239,8 +240,8 @@ public function testFindOrMethod(): void
$model = $this->getMockModel();
$model->expects('getKeyType')->times(3)->andReturn('int');
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1)->times(2);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 2);
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 1, 'and')->times(2);
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 2, 'and');
$builder->expects('first')->andReturn($model);
$builder->expects('first')->with(['column'])->andReturn($model);
$builder->expects('first')->andReturn(null);
@@ -258,8 +259,8 @@ public function testFindOrMethodWithMany(): void
$model1->expects('getKeyType')->times(3)->andReturn('int');
$model2->shouldReceive('getKeyType')->andReturn('int');
$builder->setModel($model1);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->times(2);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false)->times(2);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3], 'and', false);
$builder->expects('get')->andReturn(new Collection([$model1, $model2]));
$builder->expects('get')->with(['column'])->andReturn(new Collection([$model1, $model2]));
// Multiple IDs return a collection, so an empty result does not invoke the callback.
@@ -289,8 +290,8 @@ public function testFindOrMethodWithManyUsingCollection(): void
$model1->expects('getKeyType')->times(3)->andReturn('int');
$model2->shouldReceive('getKeyType')->andReturn('int');
$builder->setModel($model1);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2])->times(2);
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false)->times(2);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2, 3], 'and', false);
$builder->expects('get')->andReturn(new Collection([$model1, $model2]));
$builder->expects('get')->with(['column'])->andReturn(new Collection([$model1, $model2]));
// Multiple IDs return a collection, so an empty result does not invoke the callback.
@@ -327,7 +328,7 @@ public function testFindWithMany(): void
$builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]);
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false);
$builder->setModel($model);
$expectedCollection = new Collection(['baz']);
$builder->expects('get')->with(['column'])->andReturn($expectedCollection);
@@ -342,7 +343,7 @@ public function testFindWithManyUsingCollection(): void
$builder = m::mock(Builder::class . '[get]', [$this->getMockQueryBuilder()]);
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2]);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with('foo_table.foo', [1, 2], 'and', false);
$builder->setModel($model);
$expectedCollection = new Collection(['baz']);
$builder->expects('get')->with(['column'])->andReturn($expectedCollection);
@@ -441,7 +442,7 @@ public function testValueOrFailMethodWithModelNotFoundThrowsModelNotFoundExcepti
$model = $this->getMockModel();
$model->expects('getKeyType')->andReturn('int');
$builder->setModel($model);
- $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar');
+ $builder->getQuery()->expects('where')->with('foo_table.foo', '=', 'bar', 'and');
$builder->expects('first')->with(['column'])->andReturn(null);
$builder->whereKey('bar')->valueOrFail('column');
}
@@ -1036,6 +1037,36 @@ public function testGetRelationThrowsException()
$builder->getRelation('invalid');
}
+ #[TestWith(['with'])]
+ #[TestWith(['withWhereHas'])]
+ #[TestWith(['withWhereRelation'])]
+ public function testEagerLoadConstraintsDoNotRetainTheBuilder(string $method): void
+ {
+ $garbageCollectionEnabled = gc_enabled();
+ gc_disable();
+
+ try {
+ $builder = ModelParentStub::query();
+
+ match ($method) {
+ 'with' => $builder->with('foo'),
+ 'withWhereHas' => $builder->withWhereHas('foo', static fn (Builder $query): Builder => $query->where('active', true)),
+ 'withWhereRelation' => $builder->withWhereRelation('foo', 'active', true),
+ };
+
+ $reference = WeakReference::create($builder);
+ unset($builder);
+
+ $this->assertNull($reference->get());
+ } finally {
+ if ($garbageCollectionEnabled) {
+ gc_enable();
+ }
+
+ gc_collect_cycles();
+ }
+ }
+
public function testEagerLoadParsingSetsProperRelationships()
{
$builder = $this->getBuilder();
@@ -2765,7 +2796,7 @@ public function testWhereMorphedToClassUsesIntegerAliasForBothPolarities(): void
$this->assertSame(['0'], $negativeBuilder->getBindings());
}
- public function testWhereKeyMethodWithInt()
+ public function testWhereKeyMethodWithInt(): void
{
$model = $this->getMockModel();
$builder = $this->getBuilder()->setModel($model);
@@ -2774,12 +2805,12 @@ public function testWhereKeyMethodWithInt()
$int = 1;
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('where')->with($keyName, '=', $int);
+ $builder->getQuery()->expects('where')->with($keyName, '=', $int, 'and');
$builder->whereKey($int);
}
- public function testWhereKeyMethodWithStringZero()
+ public function testWhereKeyMethodWithStringZero(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
@@ -2787,20 +2818,20 @@ public function testWhereKeyMethodWithStringZero()
$int = 0;
- $builder->getQuery()->expects('where')->with($keyName, '=', (string) $int);
+ $builder->getQuery()->expects('where')->with($keyName, '=', (string) $int, 'and');
$builder->whereKey($int);
}
- public function testWhereKeyMethodWithStringNull()
+ public function testWhereKeyMethodWithStringNull(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function ($argument) {
+ $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function (mixed $argument): bool {
return $argument === null;
- }));
+ }), 'and');
$builder->whereKey(null);
}
@@ -2814,7 +2845,7 @@ public function testWhereKeyMethodWithArray(): void
$array = [1, 2, 3];
- $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array, 'and', false);
$builder->whereKey($array);
}
@@ -2828,20 +2859,45 @@ public function testWhereKeyMethodWithCollection(): void
$collection = new Collection([1, 2, 3]);
- $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection, 'and', false);
$builder->whereKey($collection);
}
- public function testWhereKeyMethodWithModel()
+ public function testWhereKeyMethodWithClosure(): void
+ {
+ $model = new Stub;
+ $this->mockConnectionForModel($model, 'SQLite');
+
+ $query = $model->newQuery()->whereKey(function (BaseBuilder $query): void {
+ $query->select('id')->from('users')->where('active', true);
+ });
+
+ $this->assertSame('select * from "table" where "table"."id" in (select "id" from "users" where "active" = ?)', $query->toSql());
+ $this->assertEquals([true], $query->getBindings());
+ }
+
+ public function testWhereKeyMethodWithSubquery(): void
+ {
+ $model = new Stub;
+ $this->mockConnectionForModel($model, 'SQLite');
+
+ $subquery = $model->newQuery()->select('id')->where('active', true);
+ $query = $model->newQuery()->whereKey($subquery);
+
+ $this->assertSame('select * from "table" where "table"."id" in (select "id" from "table" where "active" = ?)', $query->toSql());
+ $this->assertEquals([true], $query->getBindings());
+ }
+
+ public function testWhereKeyMethodWithModel(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function ($argument) {
+ $builder->getQuery()->expects('where')->with($keyName, '=', m::on(function (mixed $argument): bool {
return $argument === '1';
- }));
+ }), 'and');
$builder->whereKey(new class extends Model {
protected array $attributes = ['id' => 1];
@@ -2854,7 +2910,7 @@ public function testWhereKeyMethodWithBinaryParameter(): void
$builder = $this->getBuilder()->setModel($model);
$binary = new BinaryParameter("\0binary-key");
- $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', $binary);
+ $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', $binary, 'and');
$builder->whereKey($binary);
}
@@ -2870,12 +2926,12 @@ public function __toString(): string
}
};
- $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', 'stringable-key');
+ $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '=', 'stringable-key', 'and');
$builder->whereKey($identifier);
}
- public function testWhereKeyNotMethodWithStringZero()
+ public function testWhereKeyNotMethodWithStringZero(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
@@ -2883,25 +2939,25 @@ public function testWhereKeyNotMethodWithStringZero()
$int = 0;
- $builder->getQuery()->expects('where')->with($keyName, '!=', (string) $int);
+ $builder->getQuery()->expects('where')->with($keyName, '!=', (string) $int, 'and');
$builder->whereKeyNot($int);
}
- public function testWhereKeyNotMethodWithStringNull()
+ public function testWhereKeyNotMethodWithStringNull(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) {
+ $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool {
return $argument === null;
- }));
+ }), 'and');
$builder->whereKeyNot(null);
}
- public function testWhereKeyNotMethodWithInt()
+ public function testWhereKeyNotMethodWithInt(): void
{
$model = $this->getMockModel();
$builder = $this->getBuilder()->setModel($model);
@@ -2910,7 +2966,7 @@ public function testWhereKeyNotMethodWithInt()
$int = 1;
$model->expects('getKeyType')->andReturn('int');
- $builder->getQuery()->expects('where')->with($keyName, '!=', $int);
+ $builder->getQuery()->expects('where')->with($keyName, '!=', $int, 'and');
$builder->whereKeyNot($int);
}
@@ -2924,7 +2980,7 @@ public function testWhereKeyNotMethodWithArray(): void
$array = [1, 2, 3];
- $builder->getQuery()->expects('whereIntegerNotInRaw')->with($keyName, $array);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $array, 'and', true);
$builder->whereKeyNot($array);
}
@@ -2938,20 +2994,32 @@ public function testWhereKeyNotMethodWithCollection(): void
$collection = new Collection([1, 2, 3]);
- $builder->getQuery()->expects('whereIntegerNotInRaw')->with($keyName, $collection);
+ $builder->getQuery()->expects('whereIntegerInRaw')->with($keyName, $collection, 'and', true);
$builder->whereKeyNot($collection);
}
- public function testWhereKeyNotMethodWithModel()
+ public function testWhereKeyNotMethodWithSubquery(): void
+ {
+ $model = new Stub;
+ $this->mockConnectionForModel($model, 'SQLite');
+
+ $subquery = $model->newQuery()->select('id')->where('active', true);
+ $query = $model->newQuery()->whereKeyNot($subquery);
+
+ $this->assertSame('select * from "table" where "table"."id" not in (select "id" from "table" where "active" = ?)', $query->toSql());
+ $this->assertEquals([true], $query->getBindings());
+ }
+
+ public function testWhereKeyNotMethodWithModel(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) {
+ $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool {
return $argument === '1';
- }));
+ }), 'and');
$builder->whereKeyNot(new class extends Model {
protected array $attributes = ['id' => 1];
@@ -2964,7 +3032,7 @@ public function testWhereKeyNotMethodWithBinaryParameter(): void
$builder = $this->getBuilder()->setModel($model);
$binary = new BinaryParameter("\0binary-key");
- $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', $binary);
+ $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', $binary, 'and');
$builder->whereKeyNot($binary);
}
@@ -2980,7 +3048,7 @@ public function __toString(): string
}
};
- $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', 'stringable-key');
+ $builder->getQuery()->expects('where')->with($model->getQualifiedKeyName(), '!=', 'stringable-key', 'and');
$builder->whereKeyNot($identifier);
}
@@ -2992,7 +3060,7 @@ public function testOrWhereKeyMethodWithInt(): void
$query = $model->newQuery()->whereKey(1)->orWhereKey(2);
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" = ?)', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" = ?', $query->toSql());
$this->assertEquals([1, 2], $query->getBindings());
}
@@ -3003,7 +3071,7 @@ public function testOrWhereKeyMethodWithArray(): void
$query = $model->newQuery()->whereKey(1)->orWhereKey([2, 3]);
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" in (2, 3))', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" in (2, 3)', $query->toSql());
$this->assertEquals([1], $query->getBindings());
}
@@ -3014,7 +3082,7 @@ public function testOrWhereKeyMethodWithCollection(): void
$query = $model->newQuery()->whereKey(1)->orWhereKey(new Collection([2, 3]));
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" in (2, 3))', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" in (2, 3)', $query->toSql());
$this->assertEquals([1], $query->getBindings());
}
@@ -3025,7 +3093,7 @@ public function testOrWhereKeyNotMethodWithInt(): void
$query = $model->newQuery()->whereKey(1)->orWhereKeyNot(2);
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" != ?)', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" != ?', $query->toSql());
$this->assertEquals([1, 2], $query->getBindings());
}
@@ -3036,7 +3104,7 @@ public function testOrWhereKeyNotMethodWithArray(): void
$query = $model->newQuery()->whereKey(1)->orWhereKeyNot([2, 3]);
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" not in (2, 3))', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" not in (2, 3)', $query->toSql());
$this->assertEquals([1], $query->getBindings());
}
@@ -3047,7 +3115,7 @@ public function testOrWhereKeyNotMethodWithCollection(): void
$query = $model->newQuery()->whereKey(1)->orWhereKeyNot(new Collection([2, 3]));
- $this->assertSame('select * from "table" where "table"."id" = ? or ("table"."id" not in (2, 3))', $query->toSql());
+ $this->assertSame('select * from "table" where "table"."id" = ? or "table"."id" not in (2, 3)', $query->toSql());
$this->assertEquals([1], $query->getBindings());
}
@@ -3058,34 +3126,34 @@ public function testOrWhereKeyMethodsHonorWhereKeyOverrides(): void
$query = $model->newQuery()->whereKey(1)->orWhereKey(2)->orWhereKeyNot(3);
- $this->assertSame('select * from "table" where ("tenant_id" = ? and "local_id" = ?) or (("tenant_id" = ? and "local_id" = ?)) or (not ("tenant_id" = ? and "local_id" = ?))', $query->toSql());
+ $this->assertSame('select * from "table" where ("tenant_id" = ? and "local_id" = ?) or ("tenant_id" = ? and "local_id" = ?) or not ("tenant_id" = ? and "local_id" = ?)', $query->toSql());
$this->assertEquals([1, 1, 2, 2, 3, 3], $query->getBindings());
}
- public function testExceptMethodWithModel()
+ public function testExceptMethodWithModel(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function ($argument) {
+ $builder->getQuery()->expects('where')->with($keyName, '!=', m::on(function (mixed $argument): bool {
return $argument === '1';
- }));
+ }), 'and');
$builder->except(new class extends Model {
protected array $attributes = ['id' => 1];
});
}
- public function testExceptMethodWithCollectionOfModel()
+ public function testExceptMethodWithCollectionOfModel(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('whereNotIn')->with($keyName, m::on(function ($argument) {
+ $builder->getQuery()->expects('whereIn')->with($keyName, m::on(function (mixed $argument): bool {
return $argument === [1, 2];
- }));
+ }), 'and', true);
$models = new Collection([
new class extends Model {
@@ -3099,15 +3167,15 @@ public function testExceptMethodWithCollectionOfModel()
$builder->except($models);
}
- public function testExceptMethodWithArrayOfModel()
+ public function testExceptMethodWithArrayOfModel(): void
{
$model = new StubStringPrimaryKey;
$builder = $this->getBuilder()->setModel($model);
$keyName = $model->getQualifiedKeyName();
- $builder->getQuery()->expects('whereNotIn')->with($keyName, m::on(function ($argument) {
+ $builder->getQuery()->expects('whereIn')->with($keyName, m::on(function (mixed $argument): bool {
return $argument === [1, 2];
- }));
+ }), 'and', true);
$models = [
new class extends Model {
@@ -4144,17 +4212,12 @@ class WhereKeyOverrideBuilder extends Builder
/**
* Add a where clause on both key columns.
*/
- public function whereKey(mixed $id): static
+ public function whereKey(mixed $id, string $boolean = 'and', bool $not = false): static
{
- return $this->where(fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id));
- }
-
- /**
- * Add a where not clause on both key columns.
- */
- public function whereKeyNot(mixed $id): static
- {
- return $this->whereNot(fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id));
+ return $this->where(
+ fn (Builder $query): Builder => $query->where('tenant_id', '=', $id)->where('local_id', '=', $id),
+ boolean: $not ? $boolean . ' not' : $boolean,
+ );
}
}
From 7ad97806b4590097c8812057fbab31e4e601c7b5 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:05 +0000
Subject: [PATCH 06/16] Expose the related model class on relations
Add Relation::getRelatedClass with its model generic and use it at all four through-relationship construction sites. Preserve the existing relation behavior while avoiding repeated getRelated class extraction.
Upstream:
https://github.com/laravel/framework/pull/61222
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
.../src/Eloquent/PendingHasThroughRelationship.php | 8 ++++----
src/database/src/Eloquent/Relations/Relation.php | 10 ++++++++++
2 files changed, 14 insertions(+), 4 deletions(-)
diff --git a/src/database/src/Eloquent/PendingHasThroughRelationship.php b/src/database/src/Eloquent/PendingHasThroughRelationship.php
index 4bc946fb1..fddb19dc6 100644
--- a/src/database/src/Eloquent/PendingHasThroughRelationship.php
+++ b/src/database/src/Eloquent/PendingHasThroughRelationship.php
@@ -77,8 +77,8 @@ public function has(callable|string $callback): mixed
if ($distantRelation instanceof HasMany || $this->localRelationship instanceof HasMany) {
$returnedRelation = $this->rootModel->hasManyThrough(
- $distantRelation->getRelated()::class,
- $this->localRelationship->getRelated()::class,
+ $distantRelation->getRelatedClass(),
+ $this->localRelationship->getRelatedClass(),
$this->localRelationship->getForeignKeyName(),
$distantRelation->getForeignKeyName(),
$this->localRelationship->getLocalKeyName(),
@@ -86,8 +86,8 @@ public function has(callable|string $callback): mixed
);
} else {
$returnedRelation = $this->rootModel->hasOneThrough(
- $distantRelation->getRelated()::class,
- $this->localRelationship->getRelated()::class,
+ $distantRelation->getRelatedClass(),
+ $this->localRelationship->getRelatedClass(),
$this->localRelationship->getForeignKeyName(),
$distantRelation->getForeignKeyName(),
$this->localRelationship->getLocalKeyName(),
diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php
index e170408bf..467b11f5c 100644
--- a/src/database/src/Eloquent/Relations/Relation.php
+++ b/src/database/src/Eloquent/Relations/Relation.php
@@ -422,6 +422,16 @@ public function getRelated(): Model
return $this->related;
}
+ /**
+ * Get the class name of the related model.
+ *
+ * @return class-string
+ */
+ public function getRelatedClass(): string
+ {
+ return $this->related::class;
+ }
+
/**
* Get the name of the "created at" column.
*/
From 17f78c854609298aad5c36c64ca9897ee0688aef Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 07/16] Improve process pool iteration, timeout errors and fake
stopping
Make invoked pools and pool results iterable without changing their keys or cleanup. Add the idle-timeout exception subtype and configured timeout accessor, retaining the process result and base exception compatibility.
Return empty output from quiet results so failed commands can still throw their intended process exception. Stop fake callback delivery at the shared helper, including wait and ID access, and return the configured exit code. Preserve terminal cleanup after callback failures and align the public process contract.
Port the upstream cases, consolidate overlapping fake tests and document the public behavior. Process checks pass with blocking timeout cases enabled.
Upstream:
https://github.com/laravel/framework/pull/61184
https://github.com/laravel/framework/pull/61182
https://github.com/laravel/framework/pull/61227
https://github.com/laravel/framework/pull/61266
https://github.com/laravel/framework/pull/61410
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/contracts/src/Process/InvokedProcess.php | 10 +-
src/docs/processes.md | 14 +
.../ProcessIdleTimedOutException.php | 9 +
.../Exceptions/ProcessTimedOutException.php | 24 ++
src/process/src/FakeInvokedProcess.php | 38 ++-
src/process/src/InvokedProcess.php | 6 +-
src/process/src/InvokedProcessPool.php | 18 +-
src/process/src/PendingProcess.php | 2 +-
src/process/src/ProcessPoolResults.php | 18 +-
src/process/src/ProcessResult.php | 8 +
tests/Process/ProcessTest.php | 321 +++++++++++++++---
11 files changed, 397 insertions(+), 71 deletions(-)
create mode 100644 src/process/src/Exceptions/ProcessIdleTimedOutException.php
diff --git a/src/contracts/src/Process/InvokedProcess.php b/src/contracts/src/Process/InvokedProcess.php
index 4a77ca50d..0519465e0 100644
--- a/src/contracts/src/Process/InvokedProcess.php
+++ b/src/contracts/src/Process/InvokedProcess.php
@@ -21,11 +21,6 @@ public function command(): string;
*/
public function signal(int $signal): static;
- /**
- * Stop the process if it is still running.
- */
- public function stop(float $timeout = 10, ?int $signal = null): ?int;
-
/**
* Determine if the process is still running.
*/
@@ -65,4 +60,9 @@ public function wait(?callable $output = null): ProcessResult;
* Wait until the given callback returns true.
*/
public function waitUntil(?callable $output = null): ProcessResult;
+
+ /**
+ * Stop the process if it is still running.
+ */
+ public function stop(float $timeout = 10, ?int $signal = null): ?int;
}
diff --git a/src/docs/processes.md b/src/docs/processes.md
index 5b8ea1444..e4c564a5f 100644
--- a/src/docs/processes.md
+++ b/src/docs/processes.md
@@ -146,6 +146,8 @@ The `idleTimeout` method may be used to specify the maximum number of seconds th
$result = Process::timeout(60)->idleTimeout(30)->run('bash import.sh');
```
+An idle timeout throws `Hypervel\Process\Exceptions\ProcessIdleTimedOutException`, a subclass of `ProcessTimedOutException`. Both exceptions provide an `exceededTimeout` method that returns the configured limit in seconds, and a `result` property containing the process output collected before the timeout.
+
#### Environment Variables
@@ -238,6 +240,8 @@ use Hypervel\Support\Facades\Process;
$result = Process::quietly()->run('bash import.sh');
```
+The result's `output` and `errorOutput` methods return empty strings. You may still call `throw` to throw an exception when the process fails.
+
### Pipelines
@@ -499,6 +503,14 @@ $results = $pool->wait();
echo $results[0]->output();
```
+You may also iterate over a started pool or its results using `foreach`. Both preserve the keys assigned to the processes:
+
+```php
+foreach ($results as $key => $result) {
+ echo $result->output();
+}
+```
+
The process pool results object also offers `successful` and `failed` methods which may be used to determine if the pool succeeded or failed. You may use the `collect` method to retrieve the results as a collection:
```php
@@ -736,6 +748,8 @@ Process::fake([
Let's dig into the example above. The `id` method may be used to specify the process ID returned by the fake process. Using the `output` and `errorOutput` methods, we may specify multiple lines of output that will be returned in sequence. The `exitCode` method may be used to specify the final exit code of the fake process. Finally, the `runsFor` method may be used to specify how many times the `running` method should return `true`. The `iterations` method may also be used as an alias for `runsFor`.
+If your code stops a fake process, the process will no longer be running and the `stop` method will return the fake process's exit code.
+
If your code sends a signal to an asynchronous process, you may use the `hasReceivedSignal` method to assert that the fake process received the signal:
```php
diff --git a/src/process/src/Exceptions/ProcessIdleTimedOutException.php b/src/process/src/Exceptions/ProcessIdleTimedOutException.php
new file mode 100644
index 000000000..34716056e
--- /dev/null
+++ b/src/process/src/Exceptions/ProcessIdleTimedOutException.php
@@ -0,0 +1,9 @@
+result = $result;
+ $this->original = $original;
parent::__construct($original->getMessage(), $original->getCode(), $original);
}
+
+ /**
+ * Create a new exception instance for the type of timeout that occurred.
+ */
+ public static function make(SymfonyTimeoutException $original, ProcessResult $result): ProcessTimedOutException
+ {
+ return $original->isIdleTimeout()
+ ? new ProcessIdleTimedOutException($original, $result)
+ : new ProcessTimedOutException($original, $result);
+ }
+
+ /**
+ * Get the number of seconds the process was allowed to run before timing out.
+ */
+ public function exceededTimeout(): ?float
+ {
+ return $this->original->getExceededTimeout();
+ }
}
diff --git a/src/process/src/FakeInvokedProcess.php b/src/process/src/FakeInvokedProcess.php
index b2590a4f3..ae21c3989 100644
--- a/src/process/src/FakeInvokedProcess.php
+++ b/src/process/src/FakeInvokedProcess.php
@@ -24,14 +24,14 @@ class FakeInvokedProcess implements InvokedProcessContract
protected ?int $remainingRunIterations = null;
/**
- * The general output handler callback.
+ * Indicates whether the process has been stopped.
*/
- protected ?Closure $outputHandler = null;
+ protected bool $stopped = false;
/**
- * Indicates that the output handler has failed.
+ * The general output handler callback.
*/
- protected bool $outputHandlerFailed = false;
+ protected ?Closure $outputHandler = null;
/**
* The current output's index.
@@ -84,16 +84,6 @@ public function signal(int $signal): static
return $this;
}
- /**
- * Stop the process if it is still running.
- */
- public function stop(float $timeout = 10, ?int $signal = null): ?int
- {
- $this->remainingRunIterations = 0;
-
- return null;
- }
-
/**
* Determine if the process has received the given signal.
*/
@@ -107,6 +97,10 @@ public function hasReceivedSignal(int $signal): bool
*/
public function running(): bool
{
+ if ($this->stopped) {
+ return false;
+ }
+
$this->invokeOutputHandlerWithNextLineOfOutput();
$this->remainingRunIterations = is_null($this->remainingRunIterations)
@@ -133,7 +127,7 @@ protected function invokeOutputHandlerWithNextLineOfOutput(): array|false
{
$outputHandler = $this->outputHandler;
- if ($outputHandler === null || $this->outputHandlerFailed) {
+ if ($outputHandler === null || $this->stopped) {
return false;
}
@@ -171,8 +165,7 @@ protected function callOutputHandler(Closure $outputHandler, string $type, strin
$outputHandler($type, $buffer);
} catch (Throwable $exception) {
// Match the real process's terminal stop and suppress delivery after callback failure.
- $this->outputHandlerFailed = true;
- $this->remainingRunIterations = 0;
+ $this->stop();
throw $exception;
}
@@ -310,6 +303,17 @@ public function waitUntil(?callable $output = null): ProcessResultContract
}
}
+ /**
+ * Stop the process if it is still running.
+ */
+ public function stop(float $timeout = 10, ?int $signal = null): ?int
+ {
+ $this->stopped = true;
+ $this->remainingRunIterations = 0;
+
+ return $this->process->exitCode;
+ }
+
/**
* Get the ultimate process result that will be returned by this "process".
*/
diff --git a/src/process/src/InvokedProcess.php b/src/process/src/InvokedProcess.php
index 676e98aab..bb64fa733 100644
--- a/src/process/src/InvokedProcess.php
+++ b/src/process/src/InvokedProcess.php
@@ -110,7 +110,7 @@ public function ensureNotTimedOut(): void
try {
$this->process->checkTimeout();
} catch (SymfonyTimeoutException $e) {
- throw new ProcessTimedOutException($e, new ProcessResult($this->process));
+ throw ProcessTimedOutException::make($e, new ProcessResult($this->process));
}
}
@@ -126,7 +126,7 @@ public function wait(?callable $output = null): ProcessResultContract
return new ProcessResult($this->process);
} catch (SymfonyTimeoutException $e) {
- throw new ProcessTimedOutException($e, new ProcessResult($this->process));
+ throw ProcessTimedOutException::make($e, new ProcessResult($this->process));
}
}
@@ -146,7 +146,7 @@ public function waitUntil(?callable $output = null): ProcessResultContract
return new ProcessResult($this->process);
} catch (SymfonyTimeoutException $e) {
- throw new ProcessTimedOutException($e, new ProcessResult($this->process));
+ throw ProcessTimedOutException::make($e, new ProcessResult($this->process));
}
}
diff --git a/src/process/src/InvokedProcessPool.php b/src/process/src/InvokedProcessPool.php
index c6b3f7427..f417a2801 100644
--- a/src/process/src/InvokedProcessPool.php
+++ b/src/process/src/InvokedProcessPool.php
@@ -4,12 +4,18 @@
namespace Hypervel\Process;
+use ArrayIterator;
use Countable;
use Hypervel\Contracts\Process\InvokedProcess;
use Hypervel\Support\Collection;
+use IteratorAggregate;
use Throwable;
+use Traversable;
-class InvokedProcessPool implements Countable
+/**
+ * @implements IteratorAggregate
+ */
+class InvokedProcessPool implements Countable, IteratorAggregate
{
/**
* Create a new invoked process pool.
@@ -111,4 +117,14 @@ public function count(): int
{
return count($this->invokedProcesses);
}
+
+ /**
+ * Get an iterator for the invoked processes.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->invokedProcesses);
+ }
}
diff --git a/src/process/src/PendingProcess.php b/src/process/src/PendingProcess.php
index 6c91a2b55..bee3ed0f8 100644
--- a/src/process/src/PendingProcess.php
+++ b/src/process/src/PendingProcess.php
@@ -227,7 +227,7 @@ public function run(array|string|null $command = null, ?callable $output = null)
return new ProcessResult(tap($process)->run($this->guardProcessOutput($process, $output))); // @phpstan-ignore method.notFound (tap proxy __call)
} catch (SymfonyTimeoutException $e) {
- throw new ProcessTimedOutException($e, new ProcessResult($process));
+ throw ProcessTimedOutException::make($e, new ProcessResult($process));
}
}
diff --git a/src/process/src/ProcessPoolResults.php b/src/process/src/ProcessPoolResults.php
index 45e4da052..ad4288899 100644
--- a/src/process/src/ProcessPoolResults.php
+++ b/src/process/src/ProcessPoolResults.php
@@ -5,10 +5,16 @@
namespace Hypervel\Process;
use ArrayAccess;
+use ArrayIterator;
use Hypervel\Contracts\Process\ProcessResult as ProcessResultContract;
use Hypervel\Support\Collection;
+use IteratorAggregate;
+use Traversable;
-class ProcessPoolResults implements ArrayAccess
+/**
+ * @implements IteratorAggregate
+ */
+class ProcessPoolResults implements ArrayAccess, IteratorAggregate
{
/**
* Create a new process pool result set.
@@ -74,4 +80,14 @@ public function offsetUnset(mixed $offset): void
{
unset($this->results[$offset]);
}
+
+ /**
+ * Get an iterator for the results.
+ *
+ * @return ArrayIterator
+ */
+ public function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->results);
+ }
}
diff --git a/src/process/src/ProcessResult.php b/src/process/src/ProcessResult.php
index 5860fc277..16bd5f75a 100644
--- a/src/process/src/ProcessResult.php
+++ b/src/process/src/ProcessResult.php
@@ -56,6 +56,10 @@ public function exitCode(): ?int
*/
public function output(): string
{
+ if ($this->process->isOutputDisabled()) {
+ return '';
+ }
+
return $this->process->getOutput();
}
@@ -72,6 +76,10 @@ public function seeInOutput(string $output): bool
*/
public function errorOutput(): string
{
+ if ($this->process->isOutputDisabled()) {
+ return '';
+ }
+
return $this->process->getErrorOutput();
}
diff --git a/tests/Process/ProcessTest.php b/tests/Process/ProcessTest.php
index 08962c463..e9b19dd31 100644
--- a/tests/Process/ProcessTest.php
+++ b/tests/Process/ProcessTest.php
@@ -8,6 +8,7 @@
use Hypervel\Contracts\Process\InvokedProcess as InvokedProcessContract;
use Hypervel\Contracts\Process\ProcessResult;
use Hypervel\Process\Exceptions\ProcessFailedException;
+use Hypervel\Process\Exceptions\ProcessIdleTimedOutException;
use Hypervel\Process\Exceptions\ProcessTimedOutException;
use Hypervel\Process\Factory;
use Hypervel\Process\FakeInvokedProcess;
@@ -16,11 +17,13 @@
use Hypervel\Process\InvokedProcessPool;
use Hypervel\Process\PendingProcess;
use Hypervel\Process\Pool;
+use Hypervel\Support\Collection;
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use OutOfBoundsException;
use PHPUnit\Framework\AssertionFailedError;
use PHPUnit\Framework\Attributes\RequiresOperatingSystem;
+use PHPUnit\Framework\Attributes\TestWith;
use RuntimeException;
use Symfony\Component\Process\Process as SymfonyProcess;
use Throwable;
@@ -305,6 +308,48 @@ public function testProcessPoolCanReceiveOutputForEachProcessViaStartMethod(): v
$this->assertStringContainsString('ProcessTest.php', $poolResults[1]->output());
}
+ public function testInvokedProcessPoolCanBeIterated(): void
+ {
+ $factory = new Factory;
+
+ $pool = $factory->pool(function (Pool $pool): array {
+ return [
+ $pool->as('first')->path(__DIR__)->command($this->ls()),
+ $pool->as('second')->path(__DIR__)->command($this->ls()),
+ ];
+ })->start();
+
+ $keys = [];
+
+ foreach ($pool as $key => $process) {
+ $keys[] = $key;
+ }
+
+ $pool->wait();
+
+ $this->assertSame(['first', 'second'], $keys);
+ }
+
+ public function testProcessPoolResultsCanBeIterated(): void
+ {
+ $factory = new Factory;
+
+ $results = $factory->pool(function (Pool $pool): array {
+ return [
+ $pool->as('first')->path(__DIR__)->command($this->ls()),
+ $pool->as('second')->path(__DIR__)->command($this->ls()),
+ ];
+ })->wait();
+
+ $iterated = [];
+
+ foreach ($results as $key => $result) {
+ $iterated[$key] = $result->successful();
+ }
+
+ $this->assertSame(['first' => true, 'second' => true], $iterated);
+ }
+
public function testProcessPoolResultsCanBeEvaluatedByName(): void
{
$factory = new Factory;
@@ -465,24 +510,6 @@ public function testRealInvokedProcessCanBeStoppedThroughContract(): void
}
}
- public function testFakeInvokedProcessCanBeStoppedThroughContract(): void
- {
- $factory = new Factory;
- $factory->fake([
- '*' => $factory->describe()->runsFor(iterations: 10),
- ]);
- $process = $factory->start('sleep 60');
-
- $process->ensureNotTimedOut();
- $this->assertTrue($process->running());
- $this->assertNotNull($process->id());
-
- $this->stopProcess($process);
-
- $this->assertFalse($process->running());
- $this->assertNull($process->id());
- }
-
public function testBasicProcessFake()
{
$factory = new Factory;
@@ -849,6 +876,61 @@ public function testRealProcessesCanHaveErrorOutput()
$this->assertSame("Hello World\n", $result->errorOutput());
}
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testQuietProcessesReturnEmptyOutput(): void
+ {
+ $factory = new Factory;
+ $result = $factory->quietly()->path(__DIR__)->run('echo "Hello World"; echo "Hello World" >&2; exit 1;');
+
+ $this->assertFalse($result->successful());
+ $this->assertSame(1, $result->exitCode());
+ $this->assertSame('', $result->output());
+ $this->assertSame('', $result->errorOutput());
+ $this->assertFalse($result->seeInOutput('Hello World'));
+ $this->assertFalse($result->seeInErrorOutput('Hello World'));
+ }
+
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testQuietProcessesCanThrow(): void
+ {
+ $factory = new Factory;
+ $result = $factory->quietly()->path(__DIR__)->run('echo "Hello World" >&2; exit 1;');
+
+ try {
+ $result->throw();
+
+ $this->fail('A ProcessFailedException was not thrown.');
+ } catch (ProcessFailedException $e) {
+ $this->assertSame(
+ <<<'EOT'
+ The command "echo "Hello World" >&2; exit 1;" failed.
+
+ Exit Code: 1
+ EOT,
+ $e->getMessage()
+ );
+
+ $this->assertSame(1, $e->getCode());
+ $this->assertSame($result, $e->result);
+ }
+ }
+
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testQuietProcessesInPoolsCanThrow(): void
+ {
+ $this->expectException(ProcessFailedException::class);
+
+ $factory = new Factory;
+
+ $results = $factory->concurrently(fn (Pool $pool): array => [
+ $pool->quietly()->path(__DIR__)->command('exit 1;'),
+ ]);
+
+ $this->assertTrue($results->failed());
+
+ $results[0]->throw();
+ }
+
public function testFakeProcessesCanThrowWithoutOutput()
{
$this->expectException(ProcessFailedException::class);
@@ -1010,6 +1092,74 @@ public function testATimeoutCanBeSetWithACarbonInterval()
$result->throw();
}
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testGeneralTimeoutsThrowTheBaseException(): void
+ {
+ if (! env('RUN_BLOCKING_TESTS', false)) {
+ $this->markTestSkipped('Skip blocking tests');
+ }
+
+ $factory = new Factory;
+
+ try {
+ $factory->timeout(1)->path(__DIR__)->run('sleep 2;');
+
+ $this->fail('The process did not time out.');
+ } catch (ProcessTimedOutException $e) {
+ $this->assertNotInstanceOf(ProcessIdleTimedOutException::class, $e);
+ $this->assertSame(1.0, $e->exceededTimeout());
+ }
+ }
+
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testIdleTimeoutsThrowTheIdleException(): void
+ {
+ if (! env('RUN_BLOCKING_TESTS', false)) {
+ $this->markTestSkipped('Skip blocking tests');
+ }
+
+ $factory = new Factory;
+
+ try {
+ $factory->timeout(10)->idleTimeout(1)->path(__DIR__)->run('sleep 5;');
+
+ $this->fail('The process did not time out.');
+ } catch (ProcessIdleTimedOutException $e) {
+ $this->assertSame(1.0, $e->exceededTimeout());
+ }
+ }
+
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testIdleTimeoutsAreStillCaughtByTheBaseException(): void
+ {
+ if (! env('RUN_BLOCKING_TESTS', false)) {
+ $this->markTestSkipped('Skip blocking tests');
+ }
+
+ $this->expectException(ProcessTimedOutException::class);
+
+ $factory = new Factory;
+ $factory->timeout(10)->idleTimeout(1)->path(__DIR__)->run('sleep 5;');
+ }
+
+ #[RequiresOperatingSystem('Linux|Darwin')]
+ public function testTimedOutProcessesStillExposeTheirResult(): void
+ {
+ if (! env('RUN_BLOCKING_TESTS', false)) {
+ $this->markTestSkipped('Skip blocking tests');
+ }
+
+ $factory = new Factory;
+
+ try {
+ $factory->timeout(1)->path(__DIR__)->run('echo "Hello World"; sleep 2;');
+
+ $this->fail('The process did not time out.');
+ } catch (ProcessTimedOutException $e) {
+ $this->assertStringContainsString('Hello World', $e->result->output());
+ }
+ }
+
#[RequiresOperatingSystem('Linux|Darwin')]
public function testRealProcessesCanThrowIfTrue()
{
@@ -1527,6 +1677,116 @@ public function testFakeInvokedProcessWaitFollowedByWaitUntil()
$this->assertEmpty($waitUntilCallbacks);
}
+ public function testFakeInvokedProcessCanBeStopped(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake(function () use ($factory): FakeProcessDescription {
+ return $factory->describe()
+ ->output('STARTED')
+ ->exitCode(143)
+ ->runsFor(iterations: 10);
+ });
+
+ $process = $factory->start('sleep 100');
+
+ $this->assertTrue($process->running());
+ $this->assertNotNull($process->id());
+ $this->assertSame(143, $process->stop());
+ $this->assertFalse($process->running());
+ $this->assertNull($process->id());
+ }
+
+ public function testFakeInvokedProcessStopsInvokingOutputHandlerOnceStopped(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake(function () use ($factory): FakeProcessDescription {
+ return $factory->describe()
+ ->output('FIRST')
+ ->output('SECOND')
+ ->output('THIRD')
+ ->runsFor(iterations: 10);
+ });
+
+ $output = [];
+
+ $process = $factory->start('sleep 100', function (string $type, string $buffer) use (&$output): void {
+ $output[] = $buffer;
+ });
+
+ while ($process->running()) {
+ $process->stop();
+ }
+
+ $process->id();
+ $process->wait();
+
+ $this->assertSame(["FIRST\n"], $output);
+ }
+
+ #[TestWith(['waitUntil'])]
+ #[TestWith(['wait'])]
+ public function testFakeInvokedProcessStopsInvokingOutputHandlerWhenStoppedFromWithinIt(string $method): void
+ {
+ $factory = new Factory;
+
+ $factory->fake(function () use ($factory): FakeProcessDescription {
+ return $factory->describe()
+ ->output('FIRST')
+ ->output('SECOND')
+ ->output('THIRD')
+ ->runsFor(iterations: 10);
+ });
+
+ $output = [];
+
+ $process = $factory->start('sleep 100');
+
+ $process->{$method}(function (string $type, string $buffer) use (&$output, $process): bool {
+ $output[] = $buffer;
+
+ $process->stop();
+
+ return false;
+ });
+
+ $this->assertSame(["FIRST\n"], $output);
+ }
+
+ public function testFakeInvokedProcessPoolCanBeStopped(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake(function () use ($factory): FakeProcessDescription {
+ return $factory->describe()->runsFor(iterations: 10);
+ });
+
+ $pool = $factory->pool(function (Pool $pool): array {
+ return [
+ $pool->command('sleep 100'),
+ $pool->command('sleep 100'),
+ ];
+ })->start();
+
+ $this->assertCount(2, $pool->running());
+ $this->assertInstanceOf(Collection::class, $pool->stop());
+ $this->assertCount(0, $pool->running());
+ }
+
+ public function testFakeInvokedProcessNeverTimesOut(): void
+ {
+ $factory = new Factory;
+
+ $factory->fake(function () use ($factory): FakeProcessDescription {
+ return $factory->describe()->runsFor(iterations: 10);
+ });
+
+ $process = $factory->timeout(1)->start('sleep 100');
+
+ $this->assertNull($process->ensureNotTimedOut());
+ }
+
public function testBasicFakeAssertions(): void
{
$factory = new Factory;
@@ -1667,31 +1927,6 @@ public function testProcessWithMultipleEnvironmentVariablesAndSequences()
}, 2);
}
- public function testFakedPoolCanBeStopped()
- {
- $factory = new Factory;
-
- $factory->fake([
- '*' => $factory->describe()
- ->output('output')
- ->runsFor(iterations: 10),
- ]);
-
- $pool = $factory->pool(function ($pool) {
- return [
- $pool->command('ls -la'),
- $pool->command('cat foo'),
- ];
- })->start();
-
- $this->assertCount(2, $pool->running());
-
- $result = $pool->stop();
-
- $this->assertInstanceOf(\Hypervel\Support\Collection::class, $result);
- $this->assertCount(0, $pool->running());
- }
-
public function testFakeInvokedProcessCommand()
{
$factory = new Factory;
From 3385ebb04eb6a485eb497bd410466df6c1d971b2 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 08/16] Test supported MySQL releases and nightly database
updates
Exercise MySQL 5.7, 8.4 and 9.7 in the regular database matrix. Add scheduled innovation MySQL and current MariaDB jobs, restrict scheduled execution to this repository and keep failures visible. Remove unused blocking-process flags from workflows that never run those tests.
Move the timeout probe into a UNION filter so it exercises query interruption on MySQL 5.7. Limit nested-set diagnostics to their documented MySQL 8 minimum. Validate the workflows with actionlint and exercise the affected tests against isolated MySQL 5.7/9.7 and MariaDB services.
Upstream:
https://github.com/laravel/framework/pull/61218
https://github.com/laravel/framework/pull/61239
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
.github/workflows/databases-nightly.yml | 113 ++++++++++++++++++
.github/workflows/databases.yml | 72 +++++++++--
.github/workflows/engine.yml | 3 -
.github/workflows/grpc.yml | 3 -
.github/workflows/redis.yml | 3 -
.github/workflows/reverb.yml | 3 -
.github/workflows/scout.yml | 3 -
.../Database/QueryTimeoutTestCase.php | 6 +-
.../Database/MySql/NestedSetDatabaseTest.php | 2 +-
9 files changed, 180 insertions(+), 28 deletions(-)
create mode 100644 .github/workflows/databases-nightly.yml
diff --git a/.github/workflows/databases-nightly.yml b/.github/workflows/databases-nightly.yml
new file mode 100644
index 000000000..c33839957
--- /dev/null
+++ b/.github/workflows/databases-nightly.yml
@@ -0,0 +1,113 @@
+name: databases-nightly
+
+on:
+ schedule:
+ - cron: '0 0 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ mysql_innovation:
+ if: github.repository == 'hypervel/components'
+
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ services:
+ mysql:
+ image: mysql:innovation
+ env:
+ MYSQL_ROOT_PASSWORD: password
+ MYSQL_DATABASE: testing
+ options: >-
+ --health-cmd "mysqladmin ping -h localhost"
+ --health-start-period 30s
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ container:
+ image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2
+
+ name: MySQL Innovation
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Log trigger context
+ uses: ./.github/actions/log-trigger-context
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: /root/.composer/cache
+ key: composer-8.4-${{ hashFiles('composer.lock') }}
+ restore-keys: composer-8.4-
+
+ - name: Install dependencies
+ run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o
+
+ - name: Execute integration tests
+ env:
+ DB_HOST: mysql
+ DB_PORT: 3306
+ DB_DATABASE: testing
+ DB_USERNAME: root
+ DB_PASSWORD: password
+ run: bin/run-database-tests.sh mysql
+
+ mariadb:
+ if: github.repository == 'hypervel/components'
+
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ services:
+ mariadb:
+ image: quay.io/mariadb-foundation/mariadb-devel:verylatest
+ env:
+ MARIADB_ROOT_PASSWORD: password
+ MARIADB_DATABASE: testing
+ options: >-
+ --health-cmd "healthcheck.sh --connect --innodb_initialized"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ container:
+ image: ghcr.io/hypervel/components-ci:php8.4-swoole6.2.2
+
+ name: MariaDB Very Latest
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Log trigger context
+ uses: ./.github/actions/log-trigger-context
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: /root/.composer/cache
+ key: composer-8.4-${{ hashFiles('composer.lock') }}
+ restore-keys: composer-8.4-
+
+ - name: Install dependencies
+ run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o
+
+ - name: Execute integration tests
+ env:
+ DB_HOST: mariadb
+ DB_PORT: 3306
+ DB_DATABASE: testing
+ DB_USERNAME: root
+ DB_PASSWORD: password
+ run: bin/run-database-tests.sh mariadb
diff --git a/.github/workflows/databases.yml b/.github/workflows/databases.yml
index 4ff434cef..6344831fb 100644
--- a/.github/workflows/databases.yml
+++ b/.github/workflows/databases.yml
@@ -9,22 +9,75 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
- mysql_8:
+ mysql_57:
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ services:
+ mysql:
+ image: mysql:5.7
+ env:
+ MYSQL_ROOT_PASSWORD: password
+ MYSQL_DATABASE: testing
+ options: >-
+ --health-cmd "mysqladmin ping -h localhost"
+ --health-start-period 30s
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+
+ container:
+ image: ghcr.io/hypervel/components-ci:php${{ matrix.php }}-swoole6.2.2
+
+ strategy:
+ fail-fast: false
+ matrix:
+ php: ["8.4", "8.5"]
+
+ name: MySQL 5.7 (PHP ${{ matrix.php }})
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Log trigger context
+ uses: ./.github/actions/log-trigger-context
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: /root/.composer/cache
+ key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }}
+ restore-keys: composer-${{ matrix.php }}-
+
+ - name: Install dependencies
+ run: COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o
+
+ - name: Execute integration tests
+ env:
+ DB_HOST: mysql
+ DB_PORT: 3306
+ DB_DATABASE: testing
+ DB_USERNAME: root
+ DB_PASSWORD: password
+ run: bin/run-database-tests.sh mysql
+
+ mysql_84:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
mysql:
- image: mysql:8.0
+ image: mysql:8.4
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
options: >-
--health-cmd "mysqladmin ping -h localhost"
+ --health-start-period 30s
--health-interval 10s
--health-timeout 5s
--health-retries 5
@@ -37,7 +90,7 @@ jobs:
matrix:
php: ["8.4", "8.5"]
- name: MySQL 8.0 (PHP ${{ matrix.php }})
+ name: MySQL 8.4 (PHP ${{ matrix.php }})
steps:
- name: Checkout code
@@ -67,18 +120,19 @@ jobs:
DB_PASSWORD: password
run: bin/run-database-tests.sh mysql
- mysql_9:
+ mysql_97:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
mysql:
- image: mysql:9.0
+ image: mysql:9.7
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
options: >-
--health-cmd "mysqladmin ping -h localhost"
+ --health-start-period 30s
--health-interval 10s
--health-timeout 5s
--health-retries 5
@@ -91,7 +145,7 @@ jobs:
matrix:
php: ["8.4", "8.5"]
- name: MySQL 9.0 (PHP ${{ matrix.php }})
+ name: MySQL 9.7 (PHP ${{ matrix.php }})
steps:
- name: Checkout code
diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml
index 1522027c9..3483290d7 100644
--- a/.github/workflows/engine.yml
+++ b/.github/workflows/engine.yml
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
engine:
runs-on: ubuntu-latest
diff --git a/.github/workflows/grpc.yml b/.github/workflows/grpc.yml
index 9850f9d0f..f97b9440e 100644
--- a/.github/workflows/grpc.yml
+++ b/.github/workflows/grpc.yml
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
grpc:
runs-on: ubuntu-latest
diff --git a/.github/workflows/redis.yml b/.github/workflows/redis.yml
index c1bd4468b..b79fcc3df 100644
--- a/.github/workflows/redis.yml
+++ b/.github/workflows/redis.yml
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
redis_8:
runs-on: ubuntu-latest
diff --git a/.github/workflows/reverb.yml b/.github/workflows/reverb.yml
index e5c86b24b..a338df3bf 100644
--- a/.github/workflows/reverb.yml
+++ b/.github/workflows/reverb.yml
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
reverb:
runs-on: ubuntu-latest
diff --git a/.github/workflows/scout.yml b/.github/workflows/scout.yml
index 4ff20f43c..c09cf6888 100644
--- a/.github/workflows/scout.yml
+++ b/.github/workflows/scout.yml
@@ -9,9 +9,6 @@ on:
permissions:
contents: read
-env:
- RUN_BLOCKING_TESTS: true
-
jobs:
meilisearch:
runs-on: ubuntu-latest
diff --git a/tests/Integration/Database/QueryTimeoutTestCase.php b/tests/Integration/Database/QueryTimeoutTestCase.php
index bf60eb775..c432c7738 100644
--- a/tests/Integration/Database/QueryTimeoutTestCase.php
+++ b/tests/Integration/Database/QueryTimeoutTestCase.php
@@ -54,9 +54,9 @@ public function testTimeoutInterruptsExistsSelect(): void
public function testTimeoutInterruptsUnionSelect(): void
{
$this->assertQueryTimesOut(
- fn () => DB::query()
- ->selectRaw('1 as value')
- ->unionAll(DB::query()->selectRaw('SLEEP(2) as value'))
+ fn () => DB::table('query_timeout_probes')
+ ->select('id')
+ ->unionAll(DB::table('query_timeout_probes')->select('id')->whereRaw('SLEEP(2) = 0'))
->timeout(1)
->get()
);
diff --git a/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php b/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php
index 8ba65abce..45f926652 100644
--- a/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php
+++ b/tests/Integration/NestedSet/Database/MySql/NestedSetDatabaseTest.php
@@ -7,7 +7,7 @@
use Hypervel\Testbench\Attributes\RequiresDatabase;
use Hypervel\Tests\Integration\NestedSet\Database\NestedSetDatabaseTestCase;
-#[RequiresDatabase('mysql')]
+#[RequiresDatabase('mysql', '>=8.0')]
class NestedSetDatabaseTest extends NestedSetDatabaseTestCase
{
}
From 9297a2e075c43e4d01681b1683fa8221d08db77a Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 09/16] Restore the test-case state cleanup extension point
Provide protected flushState after application destruction and lifecycle-field cleanup, moving the existing exception-handler reset into its base implementation. Keep global cleanup owned by the shared subscriber and preserve the earliest teardown failure.
Rename the conflicting hash-field capability reset, cover cleanup ordering and failures, and document when to use this hook versus shared TestState registration. Lifecycle checks, Testbench contracts and analysis pass.
Upstream:
https://github.com/laravel/framework/pull/61288
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/docs/testing.md | 11 +++++
.../InteractsWithTestCaseLifecycle.php | 21 ++++++---
.../Concerns/RequiresHashFieldExpiration.php | 6 ++-
.../InteractsWithTestCaseLifecycleTest.php | 47 +++++++++++++++++++
.../RequiresHashFieldExpirationTest.php | 2 +-
5 files changed, 78 insertions(+), 9 deletions(-)
diff --git a/src/docs/testing.md b/src/docs/testing.md
index 3b50f96fe..5d8932f6f 100644
--- a/src/docs/testing.md
+++ b/src/docs/testing.md
@@ -272,6 +272,17 @@ Do not call `AfterEachTestCleanup::forgetCallbacks()` from ordinary application
To remove a specific callback that your test registered, call `AfterEachTestCleanup::forget($name)` instead.
+For static state owned by a particular application base test case, you may override its protected `flushState` method. Call `parent::flushState()` in your override. This hook runs after the test application is destroyed, so it must not resolve container services. It runs only for tests that boot the application; use the shared `TestState` registration above for cleanup that must also run after `#[UnitTest]` methods:
+
+```php
+protected function flushState(): void
+{
+ parent::flushState();
+
+ InvoiceNumbers::flushState();
+}
+```
+
### Macro State
diff --git a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
index 3876a866c..833e3859f 100644
--- a/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
+++ b/src/foundation/src/Testing/Concerns/InteractsWithTestCaseLifecycle.php
@@ -146,23 +146,32 @@ protected function tearDownTheTestEnvironment(): void
}
}
- try {
- HandleExceptions::flushState($this);
- } catch (Throwable $throwable) {
- $exception ??= $throwable;
- }
-
$this->app = null;
$this->afterApplicationCreatedCallbacks = [];
$this->beforeApplicationDestroyedCallbacks = [];
$this->callbackException = null;
$this->setUpHasRun = false;
+ try {
+ $this->flushState();
+ } catch (Throwable $throwable) {
+ $exception ??= $throwable;
+ }
+
if ($exception !== null) {
throw $exception;
}
}
+ /**
+ * Reset static state between test executions.
+ */
+ protected function flushState(): void
+ {
+ // Other framework static state is reset by AfterEachTestSubscriber.
+ HandleExceptions::flushState($this);
+ }
+
/**
* Boot the testing helper traits.
*
diff --git a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php
index 972a2ac4d..272003993 100644
--- a/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php
+++ b/src/foundation/src/Testing/Concerns/RequiresHashFieldExpiration.php
@@ -113,9 +113,11 @@ protected function detectedServerInfo(): array
}
/**
- * Flush all static state.
+ * Flush the memoized hash field expiration support check.
+ *
+ * Tests only. Resets the capability result shared by tests in this class.
*/
- public static function flushState(): void
+ public static function flushHashFieldExpirationState(): void
{
self::$hashFieldExpirationSupported = null;
self::$hashFieldExpirationSkipReason = '';
diff --git a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php
index c2cdf4c34..f0a206177 100644
--- a/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php
+++ b/tests/Foundation/Testing/Concerns/InteractsWithTestCaseLifecycleTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Foundation\Testing\Concerns;
+use Closure;
use Hypervel\Contracts\ConnectionPool\Connection as PoolConnection;
use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Database\Pool\PoolManager;
@@ -31,11 +32,13 @@ public function testLazyDatabaseTraitIsBootedOnceThroughRefreshDatabaseDependenc
public function testFoundationTeardownAttemptsEveryPhaseAndPreservesTheEarliestFailure(): void
{
$steps = [];
+ $applicationAtFlush = null;
$callbackException = new RuntimeException('callback failed');
$databaseException = new RuntimeException('database cleanup failed');
$poolException = new RuntimeException('pool cleanup failed');
$parallelException = new RuntimeException('parallel cleanup failed');
$applicationException = new RuntimeException('application cleanup failed');
+ $flushException = new RuntimeException('state cleanup failed');
$staticProperties = [];
@@ -104,6 +107,12 @@ function () use (&$steps, $callbackException): never {
$steps[] = 'callback:second';
});
$testCase->markSetupAsRun();
+ $testCase->registerFlushStateCallback(function () use (&$steps, &$applicationAtFlush, $flushException, $testCase): never {
+ $steps[] = 'flushState';
+ $applicationAtFlush = $testCase->lifecycleState()['app'];
+
+ throw $flushException;
+ });
try {
$testCase->tearDownEnvironment();
@@ -119,7 +128,9 @@ function () use (&$steps, $callbackException): never {
'pool',
'parallel',
'application',
+ 'flushState',
], $steps);
+ $this->assertNull($applicationAtFlush);
$this->assertSame([
'app' => null,
'afterCallbacks' => [],
@@ -136,6 +147,22 @@ function () use (&$steps, $callbackException): never {
}
}
+ public function testFoundationTeardownReportsStateCleanupFailure(): void
+ {
+ $flushException = new RuntimeException('state cleanup failed');
+ $testCase = new FoundationLifecycleTestCaseFixture('testPlaceholder');
+ $testCase->registerFlushStateCallback(static function () use ($flushException): never {
+ throw $flushException;
+ });
+
+ try {
+ $testCase->tearDownEnvironment();
+ $this->fail('Expected teardown to rethrow the state cleanup failure.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame($flushException, $exception);
+ }
+ }
+
public function testFoundationTeardownRunsDestructionCallbacksWithoutAnApplication(): void
{
$steps = [];
@@ -173,6 +200,8 @@ function () use (&$steps, $callbackException): never {
class FoundationLifecycleTestCaseFixture extends FoundationTestCase
{
+ protected ?Closure $flushStateCallback = null;
+
public function testPlaceholder(): void
{
}
@@ -212,6 +241,24 @@ public function lifecycleState(): array
'setUpHasRun' => $this->setUpHasRun,
];
}
+
+ /**
+ * Register the callback used to observe state cleanup.
+ */
+ public function registerFlushStateCallback(callable $callback): void
+ {
+ $this->flushStateCallback = Closure::fromCallable($callback);
+ }
+
+ /**
+ * Reset static state between test executions.
+ */
+ protected function flushState(): void
+ {
+ parent::flushState();
+
+ $this->flushStateCallback?->__invoke();
+ }
}
class FoundationLazyDatabaseTraitsTestCaseFixture extends FoundationTestCase
diff --git a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php
index 2a7a8278a..437f78a54 100644
--- a/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php
+++ b/tests/Foundation/Testing/Concerns/RequiresHashFieldExpirationTest.php
@@ -16,7 +16,7 @@ protected function setUp(): void
// Reset the trait's memoized static state on the subject class
// so each test starts with a clean slate.
- RequiresHashFieldExpirationTestSubject::flushState();
+ RequiresHashFieldExpirationTestSubject::flushHashFieldExpirationState();
}
public function testSkipsWhenPhpredisBelowMinimum(): void
From 060f562c1ccf93b54627124ae3daf13d137a9b04 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 10/16] Use strict membership for in_array and doesnt_contain
validation
Compare in_array and doesnt_contain values strictly and document their type-sensitive behavior. Retain contains behavior from the current upstream target.
Port the remaining scalar in-rule cases, remove its obsolete documented difference, and consolidate contains/doesnt_contain tests under upstream file names. Preserve distinct existing assertions while restoring missing rule-formatting cases and adding strict-membership coverage. The validation suite and analysis pass.
Upstream:
https://github.com/laravel/framework/pull/61146
https://github.com/laravel/framework/pull/61315
https://github.com/laravel/framework/pull/61319
https://github.com/laravel/framework/pull/61318
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/docs/validation.md | 4 +-
src/validation/README.md | 1 -
.../src/Concerns/ValidatesAttributes.php | 4 +-
.../Validation/ValidationInArrayRuleTest.php | 29 +++++
tests/Validation/ValidationInRuleTest.php | 19 ++++
...est.php => ValidationRuleContainsTest.php} | 89 ++++++---------
...hp => ValidationRuleDoesntContainTest.php} | 103 ++++++++----------
7 files changed, 128 insertions(+), 121 deletions(-)
create mode 100644 tests/Validation/ValidationInArrayRuleTest.php
rename tests/Validation/{ValidationContainsRuleTest.php => ValidationRuleContainsTest.php} (62%)
rename tests/Validation/{ValidationDoesntContainRuleTest.php => ValidationRuleDoesntContainTest.php} (65%)
diff --git a/src/docs/validation.md b/src/docs/validation.md
index a6b700ad1..d6cd29b2a 100644
--- a/src/docs/validation.md
+++ b/src/docs/validation.md
@@ -1863,6 +1863,8 @@ Validator::make($data, [
]);
```
+Comparisons are strict. String and fluent rules encode parameters as strings, so the integer `1` does not match the parameter `'1'`.
+
#### current_password
@@ -2341,7 +2343,7 @@ Validator::make($input, [
#### in_array:_anotherfield_.*
-The field under validation must exist in _anotherfield_'s values.
+The field under validation must exist in _anotherfield_'s values. Comparisons are strict, so both the value and its type must match.
#### in_array_keys:_value_.*
diff --git a/src/validation/README.md b/src/validation/README.md
index 31103543f..6badc8881 100644
--- a/src/validation/README.md
+++ b/src/validation/README.md
@@ -8,7 +8,6 @@ Documentation: https://hypervel.org/docs/validation
## Differences From Laravel
- String rule parameters use standard CSV quoting with literal backslashes. See [rule parameters](https://hypervel.org/docs/validation#rule-parameters).
-- Scalar `in` and `not_in` rules compare the submitted value with the rule's literal values as strings. Numeric strings are not loosely coerced.
- The `encoding` rule rejects numbers, booleans, and non-nullable `null`; it does not coerce scalar values to strings.
- Date comparison rules allow a referenced field to be missing or `null` unless it is also required. Unparseable date strings and invalid referenced values fail validation instead of being compared with `null`.
- Rule keys may escape a literal asterisk as `\*`, matching the existing `\.` literal-dot syntax.
diff --git a/src/validation/src/Concerns/ValidatesAttributes.php b/src/validation/src/Concerns/ValidatesAttributes.php
index ea0081ed6..0f496647a 100644
--- a/src/validation/src/Concerns/ValidatesAttributes.php
+++ b/src/validation/src/Concerns/ValidatesAttributes.php
@@ -562,7 +562,7 @@ public function validateDoesntContain(string $attribute, mixed $value, mixed $pa
// Keep this loop to avoid an extra callback per item.
foreach ($parameters as $parameter) {
- if (in_array($parameter, $value)) {
+ if (in_array($parameter, $value, true)) {
return false;
}
}
@@ -1527,7 +1527,7 @@ public function validateInArray(string $attribute, mixed $value, mixed $paramete
return Str::is($parameters[0], $key);
});
- return in_array($value, $otherValues);
+ return in_array($value, $otherValues, true);
}
/**
diff --git a/tests/Validation/ValidationInArrayRuleTest.php b/tests/Validation/ValidationInArrayRuleTest.php
new file mode 100644
index 000000000..c5e12246b
--- /dev/null
+++ b/tests/Validation/ValidationInArrayRuleTest.php
@@ -0,0 +1,29 @@
+ $value, 'other' => [$otherValue]], ['value' => 'in_array:other.*']);
+
+ $this->assertSame($expectation, $v->passes());
+ }
+}
diff --git a/tests/Validation/ValidationInRuleTest.php b/tests/Validation/ValidationInRuleTest.php
index ba117d5c2..8691e1e42 100644
--- a/tests/Validation/ValidationInRuleTest.php
+++ b/tests/Validation/ValidationInRuleTest.php
@@ -14,6 +14,7 @@
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\In;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
class ValidationInRuleTest extends TestCase
{
@@ -92,4 +93,22 @@ public function testInRuleValidation()
$v = new Validator($trans, ['x' => 'foo'], ['x' => ['required', Rule::in('foo', 'bar')]]);
$this->assertTrue($v->passes());
}
+
+ #[TestWith([' 1', false])]
+ #[TestWith(['1 ', false])]
+ #[TestWith(["\t1", false])]
+ #[TestWith(["1\n", false])]
+ #[TestWith(['01', false])]
+ #[TestWith(['+1', false])]
+ #[TestWith(['1.0', false])]
+ #[TestWith(['1e0', false])]
+ #[TestWith(['1', true])]
+ public function testInRuleIsNotLoosyBypassed(mixed $value, bool $expectation): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator($trans, ['x' => $value], ['x' => ['in:1,2,3']]);
+
+ $this->assertSame($expectation, $v->passes());
+ }
}
diff --git a/tests/Validation/ValidationContainsRuleTest.php b/tests/Validation/ValidationRuleContainsTest.php
similarity index 62%
rename from tests/Validation/ValidationContainsRuleTest.php
rename to tests/Validation/ValidationRuleContainsTest.php
index 3fba8c322..2fa084b63 100644
--- a/tests/Validation/ValidationContainsRuleTest.php
+++ b/tests/Validation/ValidationRuleContainsTest.php
@@ -5,9 +5,9 @@
namespace Hypervel\Tests\Validation;
use Hypervel\Tests\TestCase;
+use Hypervel\Tests\Validation\Fixtures\ArrayKeys;
+use Hypervel\Tests\Validation\Fixtures\ArrayKeysBacked;
use Hypervel\Tests\Validation\Fixtures\IntegerStatus;
-use Hypervel\Tests\Validation\Fixtures\PureEnum;
-use Hypervel\Tests\Validation\Fixtures\StringStatus;
use Hypervel\Tests\Validation\Fixtures\Values;
use Hypervel\Translation\ArrayLoader;
use Hypervel\Translation\Translator;
@@ -15,85 +15,54 @@
use Hypervel\Validation\Rules\Contains;
use Hypervel\Validation\Validator;
-class ValidationContainsRuleTest extends TestCase
+class ValidationRuleContainsTest extends TestCase
{
- public function testItCorrectlyFormatsAStringVersionOfTheRule()
+ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void
{
- $rule = new Contains(['foo', 'bar']);
-
- $this->assertSame('contains:"foo","bar"', (string) $rule);
-
- $rule = new Contains(collect(['foo', 'bar']));
+ $rule = Rule::contains('Taylor');
+ $this->assertSame('contains:"Taylor"', (string) $rule);
- $this->assertSame('contains:"foo","bar"', (string) $rule);
+ $rule = Rule::contains('Taylor', 'Abigail');
+ $this->assertSame('contains:"Taylor","Abigail"', (string) $rule);
- $rule = new Contains(['value with "quotes"']);
+ $rule = Rule::contains(['Taylor', 'Abigail']);
+ $this->assertSame('contains:"Taylor","Abigail"', (string) $rule);
- $this->assertSame('contains:"value with ""quotes"""', (string) $rule);
+ $rule = Rule::contains(collect(['Taylor', 'Abigail']));
+ $this->assertSame('contains:"Taylor","Abigail"', (string) $rule);
- $rule = Rule::contains(['foo', 'bar']);
+ $rule = Rule::contains([ArrayKeys::key_1, ArrayKeys::key_2]);
+ $this->assertSame('contains:"key_1","key_2"', (string) $rule);
- $this->assertSame('contains:"foo","bar"', (string) $rule);
+ $rule = Rule::contains([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2]);
+ $this->assertSame('contains:"key_1","key_2"', (string) $rule);
- $rule = Rule::contains(collect([1, 2, 3]));
+ $rule = Rule::contains(['Taylor', 'Taylor']);
+ $this->assertSame('contains:"Taylor","Taylor"', (string) $rule);
+ $rule = Rule::contains([1, 2, 3]);
$this->assertSame('contains:"1","2","3"', (string) $rule);
- $rule = Rule::contains(new Values);
+ $rule = Rule::contains(['"foo"', '"bar"', '"baz"']);
+ $this->assertSame('contains:"""foo""","""bar""","""baz"""', (string) $rule);
+ $rule = Rule::contains(new Values);
$this->assertSame('contains:"1","2","3","4"', (string) $rule);
- $rule = Rule::contains('foo', 'bar', 'baz');
+ $rule = new Contains(['foo', 'bar']);
+ $this->assertSame('contains:"foo","bar"', (string) $rule);
- $this->assertSame('contains:"foo","bar","baz"', (string) $rule);
+ $rule = new Contains(collect(['foo', 'bar']));
+ $this->assertSame('contains:"foo","bar"', (string) $rule);
$rule = new Contains('foo', 'bar', 'baz');
-
$this->assertSame('contains:"foo","bar","baz"', (string) $rule);
- $rule = Rule::contains([StringStatus::Done]);
-
- $this->assertSame('contains:"done"', (string) $rule);
-
$rule = Rule::contains([IntegerStatus::Done]);
-
$this->assertSame('contains:"2"', (string) $rule);
-
- $rule = Rule::contains([PureEnum::one]);
-
- $this->assertSame('contains:"one"', (string) $rule);
- }
-
- public function testContainsRuleValidation()
- {
- $trans = new Translator(new ArrayLoader, 'en');
-
- // Array contains the required value
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo')]);
- $this->assertTrue($v->passes());
-
- // Array contains multiple required values
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::contains('foo', 'bar')]);
- $this->assertTrue($v->passes());
-
- // Array missing a required value
- $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('baz')]);
- $this->assertFalse($v->passes());
-
- // Array missing one of multiple required values
- $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => Rule::contains('foo', 'qux')]);
- $this->assertFalse($v->passes());
-
- // Non-array value fails
- $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::contains('foo')]);
- $this->assertFalse($v->passes());
-
- // Combined with other rules
- $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::contains('foo')]]);
- $this->assertTrue($v->passes());
}
- public function testContainsValidation()
+ public function testContainsValidation(): void
{
$trans = new Translator(new ArrayLoader, 'en');
@@ -136,5 +105,9 @@ public function testContainsValidation()
// Test with nullable field
$v = new Validator($trans, ['roles' => null], ['roles' => ['nullable', Rule::contains('admin')]]);
$this->assertTrue($v->passes());
+
+ // Combined with other rules
+ $v = new Validator($trans, ['roles' => ['admin', 'user']], ['roles' => ['required', 'array', Rule::contains('admin')]]);
+ $this->assertTrue($v->passes());
}
}
diff --git a/tests/Validation/ValidationDoesntContainRuleTest.php b/tests/Validation/ValidationRuleDoesntContainTest.php
similarity index 65%
rename from tests/Validation/ValidationDoesntContainRuleTest.php
rename to tests/Validation/ValidationRuleDoesntContainTest.php
index 5d00acf87..6058d99fd 100644
--- a/tests/Validation/ValidationDoesntContainRuleTest.php
+++ b/tests/Validation/ValidationRuleDoesntContainTest.php
@@ -5,95 +5,65 @@
namespace Hypervel\Tests\Validation;
use Hypervel\Tests\TestCase;
+use Hypervel\Tests\Validation\Fixtures\ArrayKeys;
+use Hypervel\Tests\Validation\Fixtures\ArrayKeysBacked;
use Hypervel\Tests\Validation\Fixtures\IntegerStatus;
-use Hypervel\Tests\Validation\Fixtures\PureEnum;
-use Hypervel\Tests\Validation\Fixtures\StringStatus;
use Hypervel\Tests\Validation\Fixtures\Values;
use Hypervel\Translation\ArrayLoader;
use Hypervel\Translation\Translator;
use Hypervel\Validation\Rule;
use Hypervel\Validation\Rules\DoesntContain;
use Hypervel\Validation\Validator;
+use PHPUnit\Framework\Attributes\TestWith;
-class ValidationDoesntContainRuleTest extends TestCase
+class ValidationRuleDoesntContainTest extends TestCase
{
- public function testItCorrectlyFormatsAStringVersionOfTheRule()
+ public function testItCorrectlyFormatsAStringVersionOfTheRule(): void
{
- $rule = new DoesntContain(['foo', 'bar']);
-
- $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule);
+ $rule = Rule::doesntContain('Taylor');
+ $this->assertSame('doesnt_contain:"Taylor"', (string) $rule);
- $rule = new DoesntContain(collect(['foo', 'bar']));
-
- $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule);
+ $rule = Rule::doesntContain('Taylor', 'Abigail');
+ $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule);
- $rule = new DoesntContain(['value with "quotes"']);
+ $rule = Rule::doesntContain(['Taylor', 'Abigail']);
+ $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule);
- $this->assertSame('doesnt_contain:"value with ""quotes"""', (string) $rule);
+ $rule = Rule::doesntContain(collect(['Taylor', 'Abigail']));
+ $this->assertSame('doesnt_contain:"Taylor","Abigail"', (string) $rule);
- $rule = Rule::doesntContain(['foo', 'bar']);
+ $rule = Rule::doesntContain([ArrayKeys::key_1, ArrayKeys::key_2]);
+ $this->assertSame('doesnt_contain:"key_1","key_2"', (string) $rule);
- $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule);
+ $rule = Rule::doesntContain([ArrayKeysBacked::Key1, ArrayKeysBacked::Key2]);
+ $this->assertSame('doesnt_contain:"key_1","key_2"', (string) $rule);
- $rule = Rule::doesntContain(collect([1, 2, 3]));
+ $rule = Rule::doesntContain(['Taylor', 'Taylor']);
+ $this->assertSame('doesnt_contain:"Taylor","Taylor"', (string) $rule);
+ $rule = Rule::doesntContain([1, 2, 3]);
$this->assertSame('doesnt_contain:"1","2","3"', (string) $rule);
- $rule = Rule::doesntContain(new Values);
+ $rule = Rule::doesntContain(['"foo"', '"bar"', '"baz"']);
+ $this->assertSame('doesnt_contain:"""foo""","""bar""","""baz"""', (string) $rule);
+ $rule = Rule::doesntContain(new Values);
$this->assertSame('doesnt_contain:"1","2","3","4"', (string) $rule);
- $rule = Rule::doesntContain('foo', 'bar', 'baz');
+ $rule = new DoesntContain(['foo', 'bar']);
+ $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule);
- $this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule);
+ $rule = new DoesntContain(collect(['foo', 'bar']));
+ $this->assertSame('doesnt_contain:"foo","bar"', (string) $rule);
$rule = new DoesntContain('foo', 'bar', 'baz');
-
$this->assertSame('doesnt_contain:"foo","bar","baz"', (string) $rule);
- $rule = Rule::doesntContain([StringStatus::Done]);
-
- $this->assertSame('doesnt_contain:"done"', (string) $rule);
-
$rule = Rule::doesntContain([IntegerStatus::Done]);
-
$this->assertSame('doesnt_contain:"2"', (string) $rule);
-
- $rule = Rule::doesntContain([PureEnum::one]);
-
- $this->assertSame('doesnt_contain:"one"', (string) $rule);
- }
-
- public function testDoesntContainRuleValidation()
- {
- $trans = new Translator(new ArrayLoader, 'en');
-
- // Array doesn't contain the forbidden value
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux')]);
- $this->assertTrue($v->passes());
-
- // Array doesn't contain any of the forbidden values
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'quux')]);
- $this->assertTrue($v->passes());
-
- // Array contains a forbidden value
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('foo')]);
- $this->assertFalse($v->passes());
-
- // Array contains one of the forbidden values
- $v = new Validator($trans, ['x' => ['foo', 'bar', 'baz']], ['x' => Rule::doesntContain('qux', 'bar')]);
- $this->assertFalse($v->passes());
-
- // Non-array value fails
- $v = new Validator($trans, ['x' => 'foo'], ['x' => Rule::doesntContain('foo')]);
- $this->assertFalse($v->passes());
-
- // Combined with other rules
- $v = new Validator($trans, ['x' => ['foo', 'bar']], ['x' => ['required', 'array', Rule::doesntContain('baz')]]);
- $this->assertTrue($v->passes());
}
- public function testDoesntContainValidation()
+ public function testDoesntContainValidation(): void
{
$trans = new Translator(new ArrayLoader, 'en');
@@ -136,9 +106,24 @@ public function testDoesntContainValidation()
// Test with nullable field
$v = new Validator($trans, ['roles' => null], ['roles' => ['nullable', Rule::doesntContain('admin')]]);
$this->assertTrue($v->passes());
+
+ // Combined with other rules
+ $v = new Validator($trans, ['roles' => ['guest', 'user']], ['roles' => ['required', 'array', Rule::doesntContain('admin')]]);
+ $this->assertTrue($v->passes());
+ }
+
+ #[TestWith([['0e123'], true])]
+ #[TestWith([['0'], false])]
+ public function testDoesntContainRuleDoesNotUseLooseComparisons(array $value, bool $expectation): void
+ {
+ $trans = new Translator(new ArrayLoader, 'en');
+
+ $v = new Validator($trans, ['x' => $value], ['x' => ['doesnt_contain:0']]);
+
+ $this->assertSame($expectation, $v->passes());
}
- public function testDoesntContainMessageFormatsValues()
+ public function testDoesntContainMessageFormatsValues(): void
{
$trans = new Translator(new ArrayLoader, 'en');
$trans->addLines(['validation.doesnt_contain' => ':attribute must not contain :values.'], 'en');
From 4463a71f52981caa66b6bd12d6d0f360ec1bdcdd Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 11/16] Treat asterisks literally when modifying URI query
parameters
Use Arr::set for query merges and replacements so asterisks identify literal keys. Make pushOntoQuery read the same dotted segments it writes, avoiding both wildcard expansion and the exact-dotted-key preference of Arr::get.
Cover upstream merge cases, repeated list appends and collisions between literal dotted keys and nested parameters. Clarify query-key semantics in the helper documentation. The URI tests, formatting and full analysis pass.
Upstream:
https://github.com/laravel/framework/pull/61312
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/docs/helpers.md | 4 +++-
src/support/src/Uri.php | 11 ++++++---
tests/Support/SupportUriTest.php | 41 ++++++++++++++++++++++++++++++++
3 files changed, 52 insertions(+), 4 deletions(-)
diff --git a/src/docs/helpers.md b/src/docs/helpers.md
index 3af4256b5..1a8083152 100644
--- a/src/docs/helpers.md
+++ b/src/docs/helpers.md
@@ -3981,13 +3981,15 @@ The `Uri` class offers several methods that may be used to manipulate a URI's qu
$uri = $uri->withQuery(['sort' => 'name']);
```
+Query keys support dot notation for nested parameters. Asterisks in keys are treated literally, not as wildcards.
+
The `withQueryIfMissing` method may be used to merge additional query string parameters into the existing query string if the given keys do not already exist in the query string:
```php
$uri = $uri->withQueryIfMissing(['page' => 1]);
```
-The `replaceQuery` method may be used to complete replace the existing query string with a new one:
+The `replaceQuery` method may be used to completely replace the existing query string with a new one:
```php
$uri = $uri->replaceQuery(['page' => 1]);
diff --git a/src/support/src/Uri.php b/src/support/src/Uri.php
index 0906e3a46..7a155f477 100644
--- a/src/support/src/Uri.php
+++ b/src/support/src/Uri.php
@@ -249,7 +249,7 @@ public function withQuery(array $query, bool $merge = true): static
$mergedQuery = $this->query()->all();
foreach ($query as $key => $value) {
- data_set($mergedQuery, $key, $value);
+ Arr::set($mergedQuery, $key, $value);
}
$newQuery = $mergedQuery;
@@ -257,7 +257,7 @@ public function withQuery(array $query, bool $merge = true): static
$newQuery = [];
foreach ($query as $key => $value) {
- data_set($newQuery, $key, $value);
+ Arr::set($newQuery, $key, $value);
}
}
@@ -285,7 +285,12 @@ public function withQueryIfMissing(array $query): static
*/
public function pushOntoQuery(string $key, mixed $value): static
{
- $currentValue = data_get($this->query()->all(), $key);
+ $currentValue = $this->query()->all();
+
+ // Follow the same dotted path as withQuery without expanding wildcards.
+ foreach (explode('.', $key) as $segment) {
+ $currentValue = is_array($currentValue) ? ($currentValue[$segment] ?? null) : null;
+ }
$values = Arr::wrap($value);
diff --git a/tests/Support/SupportUriTest.php b/tests/Support/SupportUriTest.php
index 72d029625..5dc936474 100644
--- a/tests/Support/SupportUriTest.php
+++ b/tests/Support/SupportUriTest.php
@@ -216,6 +216,47 @@ public function testQueryStringsWithDotsCanBeReplacedOrMergedConsistently(): voi
$this->assertSame('foo[bar]=zab', $uri->replaceQuery(['foo.bar' => 'zab'])->query()->decode());
}
+ public function testQueryStringsWithAsterisksAreTreatedAsLiteralKeys(): void
+ {
+ $uri = Uri::of('https://hypervel.org/?role=user&tenant=10');
+
+ $this->assertEquals(['role' => 'user', 'tenant' => '10', '*' => 'admin'], $uri->withQuery(['*' => 'admin'])->query()->all());
+
+ $uri = Uri::of('https://hypervel.org/');
+
+ $this->assertEquals(['*' => 'admin'], $uri->withQuery(['*' => 'admin'])->query()->all());
+
+ $uri = Uri::of('https://hypervel.org/?filter[name]=taylor&filter[role]=user');
+
+ $this->assertEquals(['filter' => ['name' => 'taylor', 'role' => 'user', '*' => 'masked']], $uri->withQuery(['filter.*' => 'masked'])->query()->all());
+
+ $uri = Uri::of('https://hypervel.org/?role=user');
+
+ $this->assertEquals(['*' => 'admin'], $uri->withQuery(['*' => 'admin'], merge: false)->query()->all());
+ }
+
+ public function testWithQueryIfMissingTreatsAsterisksAsLiteralKeys(): void
+ {
+ $uri = Uri::of('https://hypervel.org/?role=user&tenant=10');
+
+ $this->assertEquals(['role' => 'user', 'tenant' => '10', '*' => 'admin'], $uri->withQueryIfMissing(['*' => 'admin'])->query()->all());
+ }
+
+ public function testPushOntoQueryReadsTheSameLiteralSegmentsItWrites(): void
+ {
+ $uri = Uri::of('https://hypervel.org/?page=1');
+
+ $this->assertSame(['page' => '1', '*' => ['first', 'second']], $uri->pushOntoQuery('*', 'first')->pushOntoQuery('*', 'second')->query()->all());
+
+ $uri = Uri::of('https://hypervel.org/?filter[name]=taylor');
+
+ $this->assertSame(['filter' => ['name' => 'taylor', '*' => ['first', 'second']]], $uri->pushOntoQuery('filter.*', 'first')->pushOntoQuery('filter.*', 'second')->query()->all());
+
+ $uri = Uri::of('https://hypervel.org/?filter.name=literal&filter[name]=nested');
+
+ $this->assertSame(['filter.name' => 'literal', 'filter' => ['name' => ['nested', 'next']]], $uri->pushOntoQuery('filter.name', 'next')->query()->all());
+ }
+
public function testDecodingTheEntireUri(): void
{
$uri = Uri::of('https://hypervel.org/docs/11.x/installation')->withQuery(['tags' => ['first', 'second']]);
From 4cc6703a58e20314890ee15a7f45748f48f7d718 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:06 +0000
Subject: [PATCH 12/16] Resolve JSON:API includes through declared resources
and batch loading
Resolve each requested relationship through the actual resource declaration at that level. Prepare missing relationships by model class, exact connection and relationship before serializing attributes, including collection roots. Preserve resolver callbacks, public overrides, included-resource ordering and existing identity handling.
Default attributes omit reserved fields and declared relationship names. Hide them on a cloned model before serialization so discarded relationship trees are not traversed and shared model visibility stays unchanged. Explicit resource attribute definitions retain control.
Cover query counts across roots and nested includes, connection isolation, callback timing, output ordering, declaration selection and default serialization. Document the public behavior and Laravel porting differences. JSON:API tests and full source/type analysis pass.
Upstream:
https://github.com/laravel/framework/pull/61322
https://github.com/laravel/framework/pull/61323
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
src/docs/eloquent-resources.md | 5 +-
src/docs/porting-from-laravel.md | 6 +
src/http/README.md | 2 +
.../JsonApi/AnonymousResourceCollection.php | 4 +
.../Concerns/ResolvesJsonApiElements.php | 133 +++++++++++++++---
.../src/Resources/JsonApi/JsonApiResource.php | 23 +++
.../Resources/JsonApi/JsonApiResourceTest.php | 65 +++++++++
.../JsonApi/JsonApiCollectionTest.php | 36 ++++-
.../JsonApiRelationshipConnectionsTest.php | 81 +++++++++++
.../Resources/JsonApi/JsonApiResourceTest.php | 50 ++++++-
10 files changed, 381 insertions(+), 24 deletions(-)
create mode 100644 tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php
diff --git a/src/docs/eloquent-resources.md b/src/docs/eloquent-resources.md
index c83e0b9f7..20906ca56 100644
--- a/src/docs/eloquent-resources.md
+++ b/src/docs/eloquent-resources.md
@@ -1042,7 +1042,7 @@ JsonApiResource::configure(
### Defining Attributes
-There are two ways to define which attributes are included in your JSON:API resource.
+By default, a resource uses its model's serialized attributes, excluding `id`, `type`, and declared relationship names. There are two ways to explicitly define which attributes are included in your JSON:API resource.
The simplest approach is to define an `$attributes` property on your resource. You may list attribute names as values, which will be read directly from the underlying model:
@@ -1128,6 +1128,7 @@ public function toRelationships(Request $request): array
Using closures gives you more control over the relationship payload, while still only resolving the relationship when the client requests it.
+
#### Including Relationships
Clients may request related resources using the `include` query parameter:
@@ -1188,6 +1189,8 @@ Nested relationships may be included using dot notation:
GET /api/posts/1?include=comments.author
```
+Each nested relationship must be declared by its resource. Hypervel batches requested relationships across the collection and loads them before attribute callbacks run, so callbacks such as `whenLoaded()` can use them without issuing additional queries.
+
#### Relationship Depth
diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md
index 3da5ad74f..018e57550 100644
--- a/src/docs/porting-from-laravel.md
+++ b/src/docs/porting-from-laravel.md
@@ -26,6 +26,7 @@
- [Scheduling](#scheduling)
- [Maintenance Mode](#maintenance-mode)
- [HTTP Client and Concurrency](#http-client-and-concurrency)
+ - [JSON:API Resources](#jsonapi-resources)
- [CSRF Protection](#csrf-protection)
- [Scout](#scout)
- [JSON Schema](#json-schema)
@@ -509,6 +510,11 @@ For concurrent HTTP requests, replace Laravel's `Http::pool` and `Http::batch` p
Hypervel's `Concurrency` facade provides `coroutine`, `process`, and `sync` drivers. Laravel's `fork` driver is not available because coroutines are Hypervel's native lightweight execution model. Use the default `coroutine` driver for normal concurrent application work and reserve `process` for work that requires operating system process isolation. See the [concurrency documentation](/docs/{{version}}/concurrency#choosing-a-driver).
+
+### JSON:API Resources
+
+[Requested relationships](/docs/{{version}}/eloquent-resources#including-jsonapi-relationships) load before attribute callbacks, so a `whenLoaded()` attribute can appear when the client requests that relationship. [Default attributes](/docs/{{version}}/eloquent-resources#defining-jsonapi-attributes) omit `id`, `type`, and declared relationship names; explicit attribute definitions remain under your control.
+
### CSRF Protection
diff --git a/src/http/README.md b/src/http/README.md
index a14c0110e..5d85a2d27 100644
--- a/src/http/README.md
+++ b/src/http/README.md
@@ -15,6 +15,8 @@ Laravel's deprecated `Request::get()` method is intentionally not ported because
Hypervel creates requests from Swoole's server request instead of `Request::capture()`. Responses are also emitted by Hypervel's server bridge rather than by calling `Response::sendContent()` directly.
+JSON:API resources load requested relationships before attribute callbacks run; default attributes exclude reserved fields and declared relationship names. See the [porting guide](https://hypervel.org/docs/porting-from-laravel#jsonapi-resources).
+
Laravel's `Http::pool()` and `Http::batch()` APIs are intentionally not ported. They are built around Guzzle promise concurrency, while Hypervel uses Swoole coroutine-native concurrency through `parallel`, `Hypervel\Coroutine\Parallel`, and `defer`. See the [concurrent requests documentation](https://hypervel.org/docs/http-client#concurrent-requests).
`TrustHosts` fails closed when no trusted host patterns resolve. If the middleware is enabled and no resolver, `at()` list, or valid `app.url` host provides a trusted pattern, Hypervel rejects all hosts using a never-matching sentinel. Laravel and Symfony leave the trusted host list empty in this case, which accepts every host. Configure a valid `app.url`, `TrustHosts::at()`, or `TrustHosts::resolveHostsUsing()` when enabling the middleware.
diff --git a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
index 256bec8c2..ab5bcd86b 100644
--- a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
+++ b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
@@ -23,6 +23,8 @@ class AnonymousResourceCollection extends BaseAnonymousResourceCollection
#[Override]
public function with(Request $request): array
{
+ JsonApiResource::prepareResourceRelationships($this->collection, $this->resolveJsonApiRequestFrom($request));
+
return array_filter([
'included' => $this->collection
->map(fn ($resource) => $resource->resolveIncludedResourceObjects($request))
@@ -43,6 +45,8 @@ public function with(Request $request): array
#[Override]
public function toAttributes(Request $request): array|Arrayable|JsonSerializable
{
+ JsonApiResource::prepareResourceRelationships($this->collection, $this->resolveJsonApiRequestFrom($request));
+
return $this->collection
->map(fn ($resource) => $resource->resolveResourceData($request))
->all();
diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
index 67098d53a..48b90b92d 100644
--- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
+++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
@@ -42,6 +42,13 @@ trait ResolvesJsonApiElements
*/
protected bool $includesPreviouslyLoadedRelationships = false;
+ /**
+ * The requested relationships for the resource.
+ *
+ * @var null|array
+ */
+ protected ?array $requestedRelationships = null;
+
/**
* Cached loaded relationships map.
*
@@ -77,6 +84,8 @@ public static function maxRelationshipDepth(int $depth): void
*/
protected function resolveResourceObject(JsonApiRequest $request): array
{
+ static::prepareResourceRelationships(new Collection([$this]), $request);
+
$resourceType = $this->resolveResourceType($request);
return [
@@ -201,15 +210,7 @@ protected function compileResourceRelationships(JsonApiRequest $request): void
return;
}
- $sparseIncluded = match (true) {
- $this->includesPreviouslyLoadedRelationships => array_keys($this->resource->getRelations()),
- default => $request->sparseIncluded(),
- };
-
- $resourceRelationships = (new Collection($this->toRelationships($request)))
- ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value))
- ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver])
- ->only($sparseIncluded);
+ $resourceRelationships = $this->getResourceRelationships($request);
$resourceRelationshipKeys = $resourceRelationships->keys();
@@ -221,12 +222,6 @@ protected function compileResourceRelationships(JsonApiRequest $request): void
foreach ($resourceRelationships as $relationName => $relationResolver) {
$relatedModels = $relationResolver->handle($this->resource);
- if (! is_null($relatedModels) && $this->includesPreviouslyLoadedRelationships === false) {
- if (! empty($relations = $request->sparseIncluded($relationName))) {
- $relatedModels->loadMissing($relations);
- }
- }
-
yield from $this->compileResourceRelationshipUsingResolver(
$request,
$this->resource,
@@ -248,6 +243,7 @@ protected function compileResourceRelationshipUsingResolver(
): Generator {
$relationName = $relationResolver->relationName;
$resourceClass = $relationResolver->resourceClass();
+ $requestedRelationships = $this->requestedResourceRelationships($request, $relationName);
// Relationship is a collection of models...
if ($relatedModels instanceof Collection) {
@@ -263,13 +259,15 @@ protected function compileResourceRelationshipUsingResolver(
$isUnique = ! $relationship instanceof BelongsToMany;
- yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique) {
+ yield $relationName => ['data' => $relatedModels->map(function ($relatedModel) use ($request, $resourceClass, $isUnique, $requestedRelationships) {
$relatedResource = rescue(fn () => $relatedModel->toResource($resourceClass), new JsonApiResource($relatedModel));
if (! $relatedResource instanceof JsonApiResource) {
$relatedResource = new JsonApiResource($relatedResource->resource);
}
+ $relatedResource->requestedRelationships = $requestedRelationships;
+
return transform(
[$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)],
function ($uniqueKey) use ($relatedResource, $isUnique) {
@@ -307,6 +305,8 @@ function ($uniqueKey) use ($relatedResource, $isUnique) {
$relatedResource = new JsonApiResource($relatedResource->resource);
}
+ $relatedResource->requestedRelationships = $requestedRelationships;
+
yield $relationName => ['data' => transform(
[$relatedResource->resolveResourceType($request), $relatedResource->resolveResourceIdentifier($request)],
function ($uniqueKey) use ($relatedResource) {
@@ -320,6 +320,105 @@ function ($uniqueKey) use ($relatedResource) {
)];
}
+ /**
+ * Get the requested relationships for this resource or one of its relationships.
+ */
+ protected function requestedResourceRelationships(JsonApiRequest $request, ?string $relationName = null): array
+ {
+ if (is_null($this->requestedRelationships)) {
+ if ($this->includesPreviouslyLoadedRelationships) {
+ return is_null($relationName) ? array_keys($this->resource->getRelations()) : [];
+ }
+
+ return $request->sparseIncluded($relationName) ?? [];
+ }
+
+ if (is_null($relationName)) {
+ $requested = (new Collection($this->requestedRelationships))
+ ->map(fn ($relationship) => explode('.', $relationship, 2)[0]);
+
+ if ($this->includesPreviouslyLoadedRelationships) {
+ $requested->push(...array_keys($this->resource->getRelations()));
+ }
+
+ return $requested->unique()->values()->all();
+ }
+
+ return (new Collection($this->requestedRelationships))
+ ->filter(fn ($relationship) => str_starts_with($relationship, $relationName . '.'))
+ ->map(fn ($relationship) => substr($relationship, strlen($relationName) + 1))
+ ->values()
+ ->all();
+ }
+
+ /**
+ * Get the declared resolvers for the requested resource relationships.
+ *
+ * @return Collection
+ */
+ protected function getResourceRelationships(JsonApiRequest $request): Collection
+ {
+ return (new Collection($this->toRelationships($request)))
+ ->transform(fn ($value, $key) => is_int($key) ? new RelationResolver($value) : new RelationResolver($key, $value))
+ ->mapWithKeys(fn ($relationResolver) => [$relationResolver->relationName => $relationResolver])
+ ->only($this->requestedResourceRelationships($request));
+ }
+
+ /**
+ * Load requested relationships in batches before serializing their attributes.
+ *
+ * @internal
+ * @param Collection $resources
+ */
+ public static function prepareResourceRelationships(Collection $resources, JsonApiRequest $request): void
+ {
+ while ($resources->isNotEmpty()) {
+ $groups = [];
+
+ foreach ($resources as $resource) {
+ if (! $resource->resource instanceof Model || $resource->loadedRelationshipsMap !== null) {
+ continue;
+ }
+
+ $model = $resource->resource;
+
+ foreach ($resource->getResourceRelationships($request)->keys() as $relationship) {
+ if ($model->relationLoaded($relationship)) {
+ continue;
+ }
+
+ // Eager loading uses the first model's class and connection for the whole group.
+ $key = serialize([$model::class, $model->getConnectionName(), $relationship]);
+ $groups[$key] ??= [$model->newCollection(), $relationship];
+ $groups[$key][0]->push($model);
+ }
+ }
+
+ foreach ($groups as [$models, $relationship]) {
+ $models->loadMissing($relationship);
+ }
+
+ $next = new Collection;
+
+ foreach ($resources as $resource) {
+ if (! $resource->resource instanceof Model) {
+ continue;
+ }
+
+ $resource->compileResourceRelationships($request);
+
+ foreach ($resource->loadedRelationshipsMap ?? [] as [$relatedResource]) {
+ // Only requested tails advance; already-loaded cycles belong to the emission walk.
+ if (! empty($relatedResource->requestedRelationships)) {
+ $next->push($relatedResource->includePreviouslyLoadedRelationships());
+ }
+ }
+ }
+
+ $resources = $next;
+ }
+ }
+
/**
* Resolve `included` for the resource.
*/
@@ -329,7 +428,7 @@ public function resolveIncludedResourceObjects(JsonApiRequest $request): Collect
return new Collection;
}
- $this->compileResourceRelationships($request);
+ static::prepareResourceRelationships(new Collection([$this]), $request);
$relations = new Collection;
$index = 0;
diff --git a/src/http/src/Resources/JsonApi/JsonApiResource.php b/src/http/src/Resources/JsonApi/JsonApiResource.php
index a314bdbfc..b7c8f3962 100644
--- a/src/http/src/Resources/JsonApi/JsonApiResource.php
+++ b/src/http/src/Resources/JsonApi/JsonApiResource.php
@@ -6,10 +6,12 @@
use BadMethodCallException;
use Hypervel\Contracts\Support\Arrayable;
+use Hypervel\Database\Eloquent\Model;
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Http\Resources\Json\JsonResource;
use Hypervel\Support\Arr;
+use Hypervel\Support\Collection;
use JsonSerializable;
use Override;
@@ -85,6 +87,27 @@ public function toAttributes(Request $request): array|Arrayable|JsonSerializable
return $this->toArray($request);
}
+ /**
+ * Transform the resource into its default attributes.
+ */
+ #[Override]
+ public function toArray(Request $request): array|Arrayable|JsonSerializable
+ {
+ $relationshipNames = (new Collection($this->toRelationships($request)))
+ ->map(fn ($value, $key) => is_int($key) ? $value : $key)
+ ->all();
+ $excluded = ['id', 'type', ...$relationshipNames];
+
+ if ($this->resource instanceof Model) {
+ // Filter before traversing relations without changing the shared model's visibility.
+ return (clone $this->resource)->makeHidden($excluded)->toArray();
+ }
+
+ return (new Collection(parent::toArray($request)))
+ ->except($excluded)
+ ->all();
+ }
+
/**
* Get the resource's relationships.
*/
diff --git a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php
index c508215f8..9bc8030de 100644
--- a/tests/Http/Resources/JsonApi/JsonApiResourceTest.php
+++ b/tests/Http/Resources/JsonApi/JsonApiResourceTest.php
@@ -15,6 +15,8 @@
use Hypervel\Tests\TestCase;
use Override;
use PHPUnit\Framework\Attributes\DataProvider;
+use PHPUnit\Framework\Attributes\TestWith;
+use RuntimeException;
class JsonApiResourceTest extends TestCase
{
@@ -112,6 +114,69 @@ public function testEmptySparseFieldsetOmitsResourceAttributes(): void
], $resource->resolveResourceData($request));
}
+ public function testDefaultAttributesExcludeReservedAndDeclaredRelationshipNames(): void
+ {
+ $child = new class extends JsonApiResourceTestModel {
+ /**
+ * Reject serialization of a discarded relationship.
+ */
+ #[Override]
+ public function toArray(): array
+ {
+ throw new RuntimeException('The declared relationship should not be serialized as an attribute.');
+ }
+ };
+
+ $model = (new JsonApiResourceTestModel)->forceFill([
+ 'id' => 1,
+ 'type' => 'record',
+ 'name' => 'Example',
+ 'secret' => 'Hidden',
+ ])->makeHidden('secret')->setRelation('childRecords', $child);
+
+ $resource = new class($model) extends JsonApiResource {
+ protected array $relationships = ['childRecords'];
+ };
+
+ $this->assertSame(['name' => 'Example'], $resource->toAttributes(new JsonApiRequest));
+ $this->assertSame(['secret'], $model->getHidden());
+
+ $authored = new class([]) extends JsonApiResource {
+ /**
+ * Supply an explicitly authored attribute array.
+ */
+ #[Override]
+ public function toArray(Request $request): array
+ {
+ return ['id' => 'authored'];
+ }
+ };
+
+ $this->assertSame(['id' => 'authored'], $authored->toAttributes(new JsonApiRequest));
+ }
+
+ #[TestWith([['comments', 'unlisted'], ['comments']])]
+ #[TestWith([['unlisted'], []])]
+ #[TestWith([['comments.author'], ['comments']])]
+ public function testRelativeRelationshipSelectionIsRestrictedToResourceDeclarations(array $requested, array $expected): void
+ {
+ $resource = new class([]) extends JsonApiResource {
+ protected array $relationships = ['comments'];
+
+ /**
+ * Inspect declaration selection without invoking model methods.
+ */
+ public function selectedRelationships(array $requested): array
+ {
+ $this->requestedRelationships = $requested;
+
+ return $this->getResourceRelationships(new JsonApiRequest)->keys()->all();
+ }
+ };
+
+ $this->assertSame($expected, $resource->selectedRelationships($requested));
+ }
+
public function testNullRelationshipKeyCannotBecomeAnEmptyResourceIdentifier(): void
{
$parent = (new JsonApiResourceParentModel)->forceFill(['id' => 1]);
diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
index e6aff8163..6499d08d4 100644
--- a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
+++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Integration\Http\Resources\JsonApi;
+use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team;
@@ -96,6 +97,8 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
'user_id' => $user->getKey(),
]);
+ $this->expectsDatabaseQueryCount(5);
+
$this->getJson('/users?' . http_build_query(['include' => 'profile,posts,teams']))
->assertHeader('Content-type', 'application/vnd.api+json')
->assertJsonPath(
@@ -174,7 +177,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
'id' => (string) $team->getKey(),
'type' => 'teams',
'attributes' => [
- 'id' => $team->getKey(),
'user_id' => $team->user_id,
'name' => 'Hypervel Team',
'personal_team' => true,
@@ -191,7 +193,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
'id' => (string) $team->getKey(),
'type' => 'teams',
'attributes' => [
- 'id' => $team->getKey(),
'user_id' => $team->user_id,
'name' => 'Hypervel Team',
'personal_team' => true,
@@ -207,4 +208,35 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
]
);
}
+
+ public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingIncludedResources(): void
+ {
+ $users = User::factory()->count(5)->create();
+ $expectedIncluded = [];
+
+ foreach ($users as $user) {
+ $posts = Post::factory()->count(2)->create(['user_id' => $user->getKey()]);
+ $comments = $posts->map(fn (Post $post): Comment => Comment::factory()->create([
+ 'content' => 'public',
+ 'post_id' => $post->getKey(),
+ 'user_id' => $user->getKey(),
+ ]));
+
+ array_push(
+ $expectedIncluded,
+ ...$posts->map(fn (Post $post): string => 'posts:' . $post->getKey())->all(),
+ ...$comments->map(fn (Comment $comment): string => 'comments:' . $comment->getKey())->all(),
+ ...['users:' . $user->getKey()],
+ );
+ }
+
+ $this->expectsDatabaseQueryCount(5);
+
+ $response = $this->getJson('/users?include=posts.comments.commenter')->assertOk();
+
+ $this->assertSame($expectedIncluded, array_map(
+ fn (array $resource): string => $resource['type'] . ':' . $resource['id'],
+ $response->json('included'),
+ ));
+ }
}
diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php
new file mode 100644
index 000000000..949a5e71f
--- /dev/null
+++ b/tests/Integration/Http/Resources/JsonApi/JsonApiRelationshipConnectionsTest.php
@@ -0,0 +1,81 @@
+directory = ParallelTesting::tempDir('JsonApiRelationshipConnectionsTest');
+ $filesystem = new Filesystem;
+ $filesystem->deleteDirectory($this->directory);
+ $filesystem->ensureDirectoryExists($this->directory);
+
+ foreach (['first', 'second'] as $connection) {
+ $database = $this->directory . '/' . $connection . '.sqlite';
+ $pdo = new PDO('sqlite:' . $database);
+ $pdo->exec('create table users (id integer primary key, name text, email text)');
+ $pdo->prepare('insert into users (id, name, email) values (1, ?, ?)')
+ ->execute([$connection, $connection . '@example.com']);
+
+ $app->make('config')->set('database.connections.' . $connection, [
+ 'driver' => 'sqlite',
+ 'database' => $database,
+ ]);
+ }
+ }
+
+ /**
+ * Remove the isolated databases after their connections have been released.
+ */
+ #[Override]
+ protected function tearDown(): void
+ {
+ try {
+ parent::tearDown();
+ } finally {
+ (new Filesystem)->deleteDirectory($this->directory);
+ }
+ }
+
+ public function testNestedRelationshipBatchesPreserveEachModelsConnection(): void
+ {
+ $first = (new Comment)->setConnection('first')->forceFill([
+ 'id' => 1, 'user_id' => 1, 'content' => 'First comment',
+ ]);
+ $second = (new Comment)->setConnection('second')->forceFill([
+ 'id' => 2, 'user_id' => 1, 'content' => 'Second comment',
+ ]);
+ $user = (new User)->setConnection('first')->forceFill([
+ 'id' => 99, 'name' => 'Parent', 'email' => 'parent@example.com',
+ ])->setRelation('comments', $first->newCollection([$first, $second]));
+
+ (new UserResource($user))->resolve(JsonApiRequest::create('/?include=comments.commenter'));
+
+ $this->assertSame('first', $first->getRelation('commenter')->name);
+ $this->assertSame('second', $second->getRelation('commenter')->name);
+ $this->assertSame('first', $first->getRelation('commenter')->getConnectionName());
+ $this->assertSame('second', $second->getRelation('commenter')->getConnectionName());
+ }
+}
diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
index c62309cfc..258f6afbb 100644
--- a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
+++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
@@ -4,6 +4,8 @@
namespace Hypervel\Tests\Integration\Http\Resources\JsonApi;
+use Hypervel\Http\Request;
+use Hypervel\Http\Resources\JsonApi\JsonApiRequest;
use Hypervel\Http\Resources\JsonApi\JsonApiResource;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post;
@@ -11,6 +13,9 @@
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\User;
+use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\UserResource;
+use Override;
+use PHPUnit\Framework\Attributes\TestWith;
class JsonApiResourceTest extends TestCase
{
@@ -173,7 +178,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
'id' => (string) $team->getKey(),
'type' => 'teams',
'attributes' => [
- 'id' => $team->getKey(),
'user_id' => $team->user_id,
'name' => 'Hypervel Team',
'personal_team' => true,
@@ -190,7 +194,6 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
'id' => (string) $team->getKey(),
'type' => 'teams',
'attributes' => [
- 'id' => $team->getKey(),
'user_id' => $team->user_id,
'name' => 'Hypervel Team',
'personal_team' => true,
@@ -458,7 +461,13 @@ public function testItCanResolveNestedRelationshipThroughClosureReturningResourc
));
}
- public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRelationships(): void
+ // Upstream testItIgnoresNestedIncludesThatAreNotResourceRelationships and
+ // testItIgnoresDeeplyNestedIncludesThatAreNotResourceRelationships are replaced by
+ // testRelativeRelationshipSelectionIsRestrictedToResourceDeclarations in the unit JsonApiResourceTest.
+
+ #[TestWith(['posts.comments', 3])]
+ #[TestWith(['posts.comments.commenter', 4])]
+ public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRelationships(string $include, int $queryCount): void
{
$user = User::factory()->create();
@@ -476,13 +485,46 @@ public function testItResolvesEachRelationshipClosureOnceWhenIncludingNestedRela
PostResource::$commentsResolutionCount = 0;
- $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => 'posts.comments']))
+ $this->expectsDatabaseQueryCount($queryCount);
+
+ $this->getJson("/users/{$user->getKey()}?" . http_build_query(['include' => $include]))
->assertJsonPath('data.relationships.posts.data.0.id', (string) $posts[0]->getKey());
// The "comments" closure should be resolved once per included post, not multiple times...
$this->assertSame(2, PostResource::$commentsResolutionCount);
}
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testRequestedRelationshipsAreAvailableToAttributeCallbacks(bool $collection): void
+ {
+ $user = User::factory()->create();
+ Post::factory()->count(2)->create(['user_id' => $user->getKey()]);
+
+ $resource = new class($user) extends UserResource {
+ /**
+ * Include the requested post count without loading it from the callback.
+ */
+ #[Override]
+ public function toAttributes(Request $request): array
+ {
+ return [
+ ...parent::toAttributes($request),
+ 'post_count' => $this->whenLoaded('posts', fn (): int => $this->posts->count()),
+ ];
+ }
+ };
+
+ $response = ($collection ? $resource::collection([$user]) : $resource)
+ ->toResponse(JsonApiRequest::create('/?include=posts'))
+ ->getData(true);
+
+ $data = $collection ? $response['data'][0] : $response['data'];
+
+ $this->assertSame(2, $data['attributes']['post_count']);
+ $this->assertCount(2, $data['relationships']['posts']['data']);
+ }
+
public function testItCanResolveRelationshipWithRecursiveNestedRelationship(): void
{
$now = $this->freezeSecond();
From 64296f09110c9fa12cde732ea0558a76a1e19c17 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 19:26:07 +0000
Subject: [PATCH 13/16] Use immutable Carbon for date-format assertions
Align the remaining date-format assertions with Hypervel's immutable Carbon convention and remove the unused Date facade import. The existing global test cleanup already covers the other applicable upstream clock-reset changes. The complete Eloquent integration test file passes.
Upstream:
https://github.com/laravel/framework/pull/61190
Source: laravel/framework master 7068848dfe48fc3a433598e09ce798799d442a52.
---
tests/Database/DatabaseEloquentIntegrationTest.php | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php
index 58e73a234..337b4f7a6 100644
--- a/tests/Database/DatabaseEloquentIntegrationTest.php
+++ b/tests/Database/DatabaseEloquentIntegrationTest.php
@@ -28,7 +28,6 @@
use Hypervel\Pagination\CursorPaginator;
use Hypervel\Pagination\LengthAwarePaginator;
use Hypervel\Support\CarbonImmutable;
-use Hypervel\Support\Facades\Date;
use Hypervel\Support\Str;
use Hypervel\Testbench\TestCase;
use UnitEnum;
@@ -2283,7 +2282,7 @@ public function testTimestampsUsingOldSqlServerDateFormat()
$this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($model->getAttribute('created_at')));
}
- public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing()
+ public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsing(): void
{
$model = new User;
$model->setDateFormat('Y-m-d H:i:s.000'); // Old SQL Server date format
@@ -2296,8 +2295,8 @@ public function testTimestampsUsingOldSqlServerDateFormatFallbackToDefaultParsin
$this->assertSame('2017-11-14 08:23:19.000', $model->fromDateTime($date), 'the format should trims it');
// No longer throwing exception since Laravel 7,
// but Date::hasFormat() can be used instead to check date formatting:
- $this->assertTrue(Date::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat()));
- $this->assertFalse(Date::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat()));
+ $this->assertTrue(CarbonImmutable::hasFormat('2017-11-14 08:23:19.000', $model->getDateFormat()));
+ $this->assertFalse(CarbonImmutable::hasFormat('2017-11-14 08:23:19.734', $model->getDateFormat()));
}
public function testSpecialFormats(): void
From 20525f123492a3d6bd2139a2896d1c42807be5bd Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 20:42:20 +0000
Subject: [PATCH 14/16] Preserve JSON:API linkage when combining duplicate
resources
Responses could repeat a primary resource in included data, or discard
relationship links when the same resource appeared through another path.
Combine those links before serializing primary data and retain one ordinary
included representation per identity. Preserve distinct pivot attributes,
resource order, and the existing relationship-loading queries.
Resolve included data once per response. Merge to-many identifiers with
lookup sets and retain explicit empty includes, including include parameters
normalized to null by middleware. Keep array-backed resources and custom
resource types intact.
Extend the existing upstream deduplication case, cover requested and eager
loaded descendants and constrained linkage, and replace ineffective JSON
fragment assertions with checks for absent paths.
Related upstream changes:
https://github.com/laravel/framework/pull/61322
https://github.com/laravel/framework/pull/61323
Framework reference: 7068848dfe48fc3a433598e09ce798799d442a52.
Validation: JSON:API unit and integration tests, unchanged query-count
assertions, source and type analysis, formatting and diff checks pass.
---
.../JsonApi/AnonymousResourceCollection.php | 28 +++--
.../Concerns/ResolvesJsonApiElements.php | 115 ++++++++++++++++-
.../src/Resources/JsonApi/JsonApiResource.php | 25 ++--
.../JsonApi/JsonApiCollectionTest.php | 40 ++++--
.../Resources/JsonApi/JsonApiResourceTest.php | 119 ++++++++++++------
.../Http/Resources/JsonApi/TestCase.php | 6 +-
6 files changed, 264 insertions(+), 69 deletions(-)
diff --git a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
index ab5bcd86b..7e1340d0a 100644
--- a/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
+++ b/src/http/src/Resources/JsonApi/AnonymousResourceCollection.php
@@ -9,7 +9,6 @@
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Http\Resources\Json\AnonymousResourceCollection as BaseAnonymousResourceCollection;
-use Hypervel\Support\Arr;
use JsonSerializable;
use Override;
@@ -17,26 +16,26 @@ class AnonymousResourceCollection extends BaseAnonymousResourceCollection
{
use Concerns\ResolvesJsonApiRequest;
+ /**
+ * The included resources resolved for this response.
+ */
+ protected ?array $includedResources = null;
+
/**
* Get any additional data that should be returned with the resource array.
*/
#[Override]
public function with(Request $request): array
{
- JsonApiResource::prepareResourceRelationships($this->collection, $this->resolveJsonApiRequestFrom($request));
+ $request = $this->resolveJsonApiRequestFrom($request);
+ $included = $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request);
- return array_filter([
- 'included' => $this->collection
- ->map(fn ($resource) => $resource->resolveIncludedResourceObjects($request))
- ->flatten(depth: 1)
- ->uniqueStrict('_uniqueKey')
- ->map(fn ($included) => Arr::except($included, ['_uniqueKey']))
- ->values()
- ->all(),
+ return [
+ ...($included !== [] || $request->has('include')) ? ['included' => $included] : [],
...($implementation = JsonApiResource::$jsonApiInformation)
? ['jsonapi' => $implementation]
: [],
- ]);
+ ];
}
/**
@@ -67,7 +66,12 @@ public function withResponse(Request $request, JsonResponse $response): void
#[Override]
public function toResponse(Request $request): JsonResponse
{
- return parent::toResponse($this->resolveJsonApiRequestFrom($request));
+ $request = $this->resolveJsonApiRequestFrom($request);
+
+ // Merge duplicate linkage before the primary collection is serialized.
+ $this->includedResources ??= JsonApiResource::resolveIncludedResources($this->collection, $request);
+
+ return parent::toResponse($request);
}
/**
diff --git a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
index 48b90b92d..7567f681b 100644
--- a/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
+++ b/src/http/src/Resources/JsonApi/Concerns/ResolvesJsonApiElements.php
@@ -33,7 +33,7 @@ trait ResolvesJsonApiElements
protected const int DEFAULT_MAX_RELATIONSHIP_DEPTH = 5;
/**
- * Determine whether resources respect inclusions and fields from the request.
+ * Determine whether resources respect sparse fieldsets from the request.
*/
protected bool $usesRequestQueryString = true;
@@ -480,6 +480,115 @@ public function resolveIncludedResourceObjects(JsonApiRequest $request): Collect
return $relations;
}
+ /**
+ * Resolve included resources and preserve linkage when combining duplicate identities.
+ *
+ * @internal
+ * @param Collection $resources
+ */
+ public static function resolveIncludedResources(Collection $resources, JsonApiRequest $request): array
+ {
+ static::prepareResourceRelationships($resources, $request);
+
+ $roots = [];
+
+ foreach ($resources as $resource) {
+ if ($resource->resource instanceof Model) {
+ $roots[$resource->resolveResourceType($request)][$resource->resolveResourceIdentifier($request)] = $resource;
+ }
+ }
+
+ $included = [];
+ $positions = [];
+ $identifiers = [];
+
+ foreach ($resources as $resource) {
+ foreach ($resource->resolveIncludedResourceObjects($request) as $entry) {
+ $key = $entry['_uniqueKey'];
+ $relationships = (array) ($entry['relationships'] ?? []);
+ $root = $roots[$entry['type']][$entry['id']] ?? null;
+
+ // Pivot variants have distinct keys and must retain their own attributes.
+ if ($root !== null && $key === $entry['id'] . ':' . $entry['type']) {
+ $identifiers[$key] ??= [];
+ static::mergeResourceRelationships($root->loadedRelationshipIdentifiers, $relationships, $identifiers[$key]);
+ } elseif (isset($positions[$key])) {
+ $position = $positions[$key];
+ $identifiers[$key] ??= [];
+ static::mergeResourceRelationships($included[$position]['relationships'], $relationships, $identifiers[$key]);
+ } else {
+ $positions[$key] = count($included);
+ $entry['relationships'] = $relationships;
+ unset($entry['_uniqueKey']);
+ $included[] = $entry;
+ }
+ }
+ }
+
+ foreach ($included as &$entry) {
+ if ($entry['relationships'] === []) {
+ unset($entry['relationships']);
+ } else {
+ $entry['relationships'] = (object) $entry['relationships'];
+ }
+ }
+ unset($entry);
+
+ return $included;
+ }
+
+ /**
+ * Merge relationship linkage without discarding descendants of duplicate resources.
+ */
+ protected static function mergeResourceRelationships(array &$relationships, array $additional, array &$identifiers): void
+ {
+ foreach ($additional as $name => $relationship) {
+ if (! array_key_exists($name, $relationships)) {
+ $relationships[$name] = $relationship;
+ continue;
+ }
+
+ if ($relationships[$name] instanceof MissingValue) {
+ continue;
+ }
+
+ if (! isset($relationships[$name]['data'])) {
+ if (isset($relationship['data'])) {
+ $relationships[$name]['data'] = $relationship['data'];
+ }
+ continue;
+ }
+
+ $data = &$relationships[$name]['data'];
+ $incoming = $relationship['data'] ?? null;
+
+ if ($data instanceof Collection) {
+ $data = $data->all();
+ }
+
+ $incoming = $incoming instanceof Collection ? $incoming->all() : $incoming;
+
+ if ($incoming === null || ! array_is_list($data)) {
+ continue;
+ }
+
+ if (! isset($identifiers[$name])) {
+ $identifiers[$name] = [];
+
+ foreach ($data as $identifier) {
+ $identifiers[$name][$identifier['type']][$identifier['id']] = true;
+ }
+ }
+
+ foreach ($incoming as $identifier) {
+ if (! isset($identifiers[$name][$identifier['type']][$identifier['id']])) {
+ $identifiers[$name][$identifier['type']][$identifier['id']] = true;
+ $data[] = $identifier;
+ }
+ }
+ }
+ }
+
/**
* Resolve the links for the resource.
*
@@ -501,7 +610,7 @@ protected function resolveResourceMetaInformation(JsonApiRequest $request): arra
}
/**
- * Indicate that relationship loading should respect the request's "includes" query string.
+ * Indicate that attributes should respect the request's sparse fieldsets.
*/
public function respectFieldsAndIncludesInQueryString(bool $value = true): static
{
@@ -511,7 +620,7 @@ public function respectFieldsAndIncludesInQueryString(bool $value = true): stati
}
/**
- * Indicate that relationship loading should not rely on the request's "includes" query string.
+ * Indicate that attributes should ignore the request's sparse fieldsets.
*/
public function ignoreFieldsAndIncludesInQueryString(): static
{
diff --git a/src/http/src/Resources/JsonApi/JsonApiResource.php b/src/http/src/Resources/JsonApi/JsonApiResource.php
index b7c8f3962..90f84d533 100644
--- a/src/http/src/Resources/JsonApi/JsonApiResource.php
+++ b/src/http/src/Resources/JsonApi/JsonApiResource.php
@@ -10,7 +10,6 @@
use Hypervel\Http\JsonResponse;
use Hypervel\Http\Request;
use Hypervel\Http\Resources\Json\JsonResource;
-use Hypervel\Support\Arr;
use Hypervel\Support\Collection;
use JsonSerializable;
use Override;
@@ -42,6 +41,11 @@ class JsonApiResource extends JsonResource
*/
protected array $jsonApiMeta = [];
+ /**
+ * The included resources resolved for this response.
+ */
+ protected ?array $includedResources = null;
+
/**
* Set the JSON:API version for the request.
*
@@ -144,16 +148,14 @@ public function with(Request $request): array
{
$jsonApiRequest = $this->resolveJsonApiRequestFrom($request);
- return array_filter([
- 'included' => $this->resolveIncludedResourceObjects($jsonApiRequest)
- ->uniqueStrict('_uniqueKey')
- ->map(fn ($included) => Arr::except($included, ['_uniqueKey']))
- ->values()
- ->all(),
+ $included = $this->includedResources ??= static::resolveIncludedResources(new Collection([$this]), $jsonApiRequest);
+
+ return [
+ ...($included !== [] || $jsonApiRequest->has('include')) ? ['included' => $included] : [],
...($implementation = static::$jsonApiInformation)
? ['jsonapi' => $implementation]
: [],
- ]);
+ ];
}
/**
@@ -191,7 +193,12 @@ public function withResponse(Request $request, JsonResponse $response): void
#[Override]
public function toResponse(Request $request): JsonResponse
{
- return parent::toResponse($this->resolveJsonApiRequestFrom($request));
+ $request = $this->resolveJsonApiRequestFrom($request);
+
+ // Merge duplicate linkage before the primary resource is serialized.
+ $this->includedResources ??= static::resolveIncludedResources(new Collection([$this]), $request);
+
+ return parent::toResponse($request);
}
/**
diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
index 6499d08d4..9ecdb990c 100644
--- a/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
+++ b/tests/Integration/Http/Resources/JsonApi/JsonApiCollectionTest.php
@@ -4,11 +4,14 @@
namespace Hypervel\Tests\Integration\Http\Resources\JsonApi;
+use Hypervel\Http\Resources\JsonApi\JsonApiRequest;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Comment;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Post;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Profile;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\Team;
use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\User;
+use Hypervel\Tests\Integration\Http\Resources\JsonApi\Fixtures\UserResource;
+use PHPUnit\Framework\Attributes\TestWith;
class JsonApiCollectionTest extends TestCase
{
@@ -28,7 +31,7 @@ public function testItCanGenerateJsonApiResponse(): void
'email' => $user->email,
],
])->all()
- )->assertJsonMissing(['jsonapi', 'included']);
+ )->assertJsonMissingPath('jsonapi')->assertJsonMissingPath('included');
}
public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void
@@ -46,7 +49,7 @@ public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void
'name' => $user->name,
],
])->all()
- )->assertJsonMissing(['jsonapi', 'included']);
+ )->assertJsonMissingPath('jsonapi')->assertJsonMissingPath('included');
}
public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded(): void
@@ -70,7 +73,7 @@ public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSpars
],
],
])->all()
- )->assertJsonMissing(['jsonapi', 'included']);
+ )->assertJsonPath('included', [])->assertJsonMissingPath('jsonapi');
}
public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded(): void
@@ -209,10 +212,13 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
);
}
- public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingIncludedResources(): void
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingIncludedResources(bool $includeProfile): void
{
$users = User::factory()->count(5)->create();
$expectedIncluded = [];
+ $profiles = [];
foreach ($users as $user) {
$posts = Post::factory()->count(2)->create(['user_id' => $user->getKey()]);
@@ -226,17 +232,37 @@ public function testNestedRelationshipsAreBatchedAcrossThePageWithoutReorderingI
$expectedIncluded,
...$posts->map(fn (Post $post): string => 'posts:' . $post->getKey())->all(),
...$comments->map(fn (Comment $comment): string => 'comments:' . $comment->getKey())->all(),
- ...['users:' . $user->getKey()],
);
+
+ if ($includeProfile) {
+ $profile = Profile::factory()->create(['user_id' => $user->getKey()]);
+ $profiles[] = ['id' => (string) $profile->getKey(), 'type' => 'profiles'];
+ $expectedIncluded[] = 'profiles:' . $profile->getKey();
+ }
}
- $this->expectsDatabaseQueryCount(5);
+ $this->expectsDatabaseQueryCount($includeProfile ? 6 : 5);
- $response = $this->getJson('/users?include=posts.comments.commenter')->assertOk();
+ $response = $this->getJson('/users?include=posts.comments.commenter' . ($includeProfile ? '.profile' : ''))->assertOk();
$this->assertSame($expectedIncluded, array_map(
fn (array $resource): string => $resource['type'] . ':' . $resource['id'],
$response->json('included'),
));
+
+ if ($includeProfile) {
+ $this->assertSame($profiles, array_map(
+ fn (array $resource): array => $resource['relationships']['profile']['data'],
+ $response->json('data'),
+ ));
+ }
+ }
+
+ public function testEmptyCollectionRetainsExplicitIncludes(): void
+ {
+ $data = UserResource::collection([])->toResponse(JsonApiRequest::create('/?include='))->getData(true);
+
+ $this->assertSame(['data' => [], 'included' => []], $data);
+ $this->assertSame(['data' => []], UserResource::collection([])->toResponse(JsonApiRequest::create('/'))->getData(true));
}
}
diff --git a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
index 258f6afbb..e64141f77 100644
--- a/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
+++ b/tests/Integration/Http/Resources/JsonApi/JsonApiResourceTest.php
@@ -4,6 +4,8 @@
namespace Hypervel\Tests\Integration\Http\Resources\JsonApi;
+use Hypervel\Database\Eloquent\Relations\HasMany;
+use Hypervel\Database\Eloquent\Relations\HasOne;
use Hypervel\Http\Request;
use Hypervel\Http\Resources\JsonApi\JsonApiRequest;
use Hypervel\Http\Resources\JsonApi\JsonApiResource;
@@ -35,7 +37,8 @@ public function testItCanGenerateJsonApiResponse(): void
],
],
])
- ->assertJsonMissing(['jsonapi', 'included']);
+ ->assertJsonMissingPath('jsonapi')
+ ->assertJsonMissingPath('included');
}
public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void
@@ -53,7 +56,8 @@ public function testItCanGenerateJsonApiResponseWithSparseFieldsets(): void
],
],
])
- ->assertJsonMissing(['jsonapi', 'included']);
+ ->assertJsonMissingPath('jsonapi')
+ ->assertJsonMissingPath('included');
}
public function testItCanGenerateJsonApiResponseWithEmptySparseFieldsets(): void
@@ -68,7 +72,8 @@ public function testItCanGenerateJsonApiResponseWithEmptySparseFieldsets(): void
'type' => 'users',
],
])
- ->assertJsonMissing(['jsonapi', 'included']);
+ ->assertJsonMissingPath('jsonapi')
+ ->assertJsonMissingPath('included');
}
public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSparseIncluded(): void
@@ -91,8 +96,9 @@ public function testItCanGenerateJsonApiResponseWithEmptyRelationshipsUsingSpars
],
],
],
+ 'included' => [],
])
- ->assertJsonMissing(['jsonapi', 'included']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncluded(): void
@@ -289,7 +295,7 @@ public function testItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncl
],
])
->assertJsonCount(1, 'included')
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanResolveRelationshipWithCustomNameAndResourceClass(): void
@@ -343,7 +349,7 @@ public function testItCanResolveRelationshipWithCustomNameAndResourceClass(): vo
],
],
])
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanResolveRelationshipWithNestedRelationship(): void
@@ -425,7 +431,7 @@ public function testItCanResolveRelationshipWithNestedRelationship(): void
],
],
])
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanResolveNestedRelationshipThroughClosureReturningResourceCollection(): void
@@ -567,22 +573,9 @@ public function testItCanResolveRelationshipWithRecursiveNestedRelationship(): v
],
],
],
- [
- 'attributes' => [
- 'email' => $user->email,
- 'name' => $user->name,
- ],
- 'id' => (string) $user->getKey(),
- 'type' => 'users',
- 'relationships' => [
- 'profile' => [
- 'data' => ['id' => (string) $profile->getKey(), 'type' => 'profiles'],
- ],
- ],
- ],
],
])
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanResolveRelationshipWithRecursiveNestedRelationshipLimitedToDepthConfiguration(): void
@@ -629,17 +622,9 @@ public function testItCanResolveRelationshipWithRecursiveNestedRelationshipLimit
],
],
],
- [
- 'attributes' => [
- 'email' => $user->email,
- 'name' => $user->name,
- ],
- 'id' => (string) $user->getKey(),
- 'type' => 'users',
- ],
],
])
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItCanResolveRelationshipWithoutRedundantIncludedRelationship(): void
@@ -722,7 +707,7 @@ public function testItCanResolveRelationshipWithoutRedundantIncludedRelationship
],
])
->assertJsonCount(1, 'included')
- ->assertJsonMissing(['jsonapi']);
+ ->assertJsonMissingPath('jsonapi');
}
public function testItHandlesBidirectionalRelationshipsWithChaperoneWithoutInfiniteLoop(): void
@@ -778,7 +763,8 @@ public function testTopLevelArrayBackedCustomResourceCanGenerateJsonApiResponse(
],
],
])
- ->assertJsonMissing(['jsonapi', 'included']);
+ ->assertJsonMissingPath('jsonapi')
+ ->assertJsonMissingPath('included');
}
public function testSameModelWithTheSameResourceTypeIsDeduplicated(): void
@@ -819,19 +805,82 @@ public function testSameModelWithTheSameResourceTypeIsDeduplicated(): void
public function testDifferentModelInstancesWithSameTypeAndIdAreDeduplicated(): void
{
+ $root = User::factory()->create();
$user = User::factory()->create();
+ $post = Post::factory()->create(['user_id' => $user->getKey()]);
+ $profile = Profile::factory()->create(['user_id' => $user->getKey()]);
// This route manually creates two different User model instances with the same ID and
// adds them both to the loadedRelationshipsMap. Per the JSON:API spec, they should
// be deduplicated since they have the same type+id, even though they're different object instances.
- $response = $this->getJson("/users/{$user->getKey()}/with-duplicate-instances")
+ $response = $this->getJson("/users/{$root->getKey()}/with-duplicate-instances/{$user->getKey()}")
->assertHeader('Content-type', 'application/vnd.api+json');
$included = $response->json('included');
- $this->assertCount(1, $included);
- $this->assertSame('users', $included[0]['type']);
+ $this->assertSame(['users', 'posts', 'profiles'], array_column($included, 'type'));
$this->assertSame((string) $user->getKey(), $included[0]['id']);
+ $this->assertSame(['id' => (string) $post->getKey(), 'type' => 'posts'], $included[0]['relationships']['posts']['data'][0]);
+ $this->assertSame(['id' => (string) $profile->getKey(), 'type' => 'profiles'], $included[0]['relationships']['profile']['data']);
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testPrimaryDuplicatesPreserveNestedRelationshipLinkage(bool $alreadyLoaded): void
+ {
+ $user = User::factory()->create();
+ $post = Post::factory()->create(['user_id' => $user->getKey()]);
+ $comment = Comment::factory()->create([
+ 'post_id' => $post->getKey(), 'user_id' => $user->getKey(), 'content' => 'public',
+ ]);
+ $profile = Profile::factory()->create(['user_id' => $user->getKey()]);
+
+ if ($alreadyLoaded) {
+ $user->load('posts.comments.commenter.profile');
+ }
+
+ $user->load(['profile' => fn (HasOne $query): HasOne => $query->whereRaw('1 = 0')]);
+
+ $this->expectsDatabaseQueryCount($alreadyLoaded ? 0 : 4);
+ $request = JsonApiRequest::create('/?include=profile,' . ($alreadyLoaded ? 'posts.comments' : 'posts.comments.commenter.profile'));
+ $data = $user->toResource()->toResponse($request)->getData(true);
+
+ $this->assertSame(['id' => (string) $profile->getKey(), 'type' => 'profiles'], $data['data']['relationships']['profile']['data']);
+ $this->assertSame(['posts', 'comments', 'profiles'], array_column($data['included'], 'type'));
+ $this->assertSame((string) $comment->getKey(), $data['included'][1]['id']);
+ $this->assertSame((string) $profile->getKey(), $data['included'][2]['id']);
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testPrimaryDuplicatesCombineConstrainedRelationshipLinkage(bool $empty): void
+ {
+ $user = User::factory()->create();
+ $keep = Post::factory()->create(['user_id' => $user->getKey(), 'title' => 'keep']);
+ $other = Post::factory()->create(['user_id' => $user->getKey(), 'title' => 'other']);
+ Comment::factory()->create([
+ 'post_id' => $keep->getKey(), 'user_id' => $user->getKey(), 'content' => 'public',
+ ]);
+ $user->load(['posts' => fn (HasMany $query): HasMany => $query->where('title', $empty ? 'absent' : 'keep')]);
+
+ $data = $user->toResource()->toResponse(JsonApiRequest::create('/?include=posts,comments.commenter.posts'))->getData(true);
+
+ $this->assertSame([
+ ['id' => (string) $keep->getKey(), 'type' => 'posts'],
+ ['id' => (string) $other->getKey(), 'type' => 'posts'],
+ ], $data['data']['relationships']['posts']['data']);
+ $this->assertNotContains('users', array_column($data['included'], 'type'));
+ $this->assertCount(3, $data['included']);
+ }
+
+ #[TestWith([false])]
+ #[TestWith([true])]
+ public function testExplicitEmptyIncludesAreRetained(bool $collection): void
+ {
+ $user = User::factory()->create();
+ $uri = $collection ? '/users' : '/users/' . $user->getKey();
+
+ $this->getJson($uri . '?include=')->assertOk()->assertJsonPath('included', []);
}
public function testSameModelOnDifferentResourcesIsNotDeduplicated(): void
diff --git a/tests/Integration/Http/Resources/JsonApi/TestCase.php b/tests/Integration/Http/Resources/JsonApi/TestCase.php
index ea1a50770..f94cc08fc 100644
--- a/tests/Integration/Http/Resources/JsonApi/TestCase.php
+++ b/tests/Integration/Http/Resources/JsonApi/TestCase.php
@@ -67,9 +67,9 @@ protected function defineRoutes(Router $router): void
return $resource;
});
- $router->get('users/{userId}/with-duplicate-instances', function ($userId) {
- $instance1 = User::find($userId);
- $instance2 = User::find($userId);
+ $router->get('users/{userId}/with-duplicate-instances/{includedId}', function (string $userId, string $includedId): UserWithArrayRelationshipResource {
+ $instance1 = User::find($includedId)->load('posts');
+ $instance2 = User::find($includedId)->load('profile');
$resource = new UserWithArrayRelationshipResource(User::find($userId));
$resource->loadedRelationshipsMap = [
From d142908c0c1468b5000db7f2bed5267ce391ad5f Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 20:42:20 +0000
Subject: [PATCH 15/16] Move scheduled checks away from the start of the hour
GitHub can delay or drop scheduled jobs during start-of-hour load. Offset
the nightly database and installation checks and the weekly protocol and
AAGUID synchronization jobs while keeping their existing daily and weekly
cadence. Existing off-hour schedules are unchanged.
Validation: all four workflow files parse successfully; diff checks pass.
---
.github/workflows/databases-nightly.yml | 2 +-
.github/workflows/install-nightly.yml | 2 +-
.github/workflows/sync-grpc-health-protocol.yml | 2 +-
.github/workflows/sync-passkeys-aaguids.yml | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/databases-nightly.yml b/.github/workflows/databases-nightly.yml
index c33839957..561a7f5ab 100644
--- a/.github/workflows/databases-nightly.yml
+++ b/.github/workflows/databases-nightly.yml
@@ -2,7 +2,7 @@ name: databases-nightly
on:
schedule:
- - cron: '0 0 * * *'
+ - cron: '17 0 * * *'
workflow_dispatch:
permissions:
diff --git a/.github/workflows/install-nightly.yml b/.github/workflows/install-nightly.yml
index 1e07db46b..283ce8d45 100644
--- a/.github/workflows/install-nightly.yml
+++ b/.github/workflows/install-nightly.yml
@@ -2,7 +2,7 @@ name: install-nightly
on:
schedule:
- - cron: '0 0 * * *'
+ - cron: '37 0 * * *'
workflow_dispatch:
permissions:
diff --git a/.github/workflows/sync-grpc-health-protocol.yml b/.github/workflows/sync-grpc-health-protocol.yml
index b6a6b5698..f962084c6 100644
--- a/.github/workflows/sync-grpc-health-protocol.yml
+++ b/.github/workflows/sync-grpc-health-protocol.yml
@@ -2,7 +2,7 @@ name: synchronize gRPC health protocol
on:
schedule:
- - cron: "0 1 * * 1"
+ - cron: "47 1 * * 1"
workflow_dispatch:
permissions:
diff --git a/.github/workflows/sync-passkeys-aaguids.yml b/.github/workflows/sync-passkeys-aaguids.yml
index 0d4b532e4..506742cb0 100644
--- a/.github/workflows/sync-passkeys-aaguids.yml
+++ b/.github/workflows/sync-passkeys-aaguids.yml
@@ -2,7 +2,7 @@ name: synchronize Passkeys AAGUIDs
on:
schedule:
- - cron: "0 0 * * 1"
+ - cron: "47 0 * * 1"
workflow_dispatch:
permissions:
From 2a4dd51a67ba89752970318a2b43caf8117e3d62 Mon Sep 17 00:00:00 2001
From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com>
Date: Sun, 27 Sep 2026 20:42:20 +0000
Subject: [PATCH 16/16] Clarify pivot filters, URI lookups and test cleanup
Describe pivot closures as invoking local scopes and clarify that pivot
filters constrain existing rows, not newly attached rows. Distinguish
literal URI query mutations from wildcard lookups and point to all() for
literal-key access. Explain that UnitTest methods skip the application
flushState hook without making its execution conditional on successful
application creation.
Validation: wording checked against the implementations; diff checks pass.
---
src/docs/eloquent-relationships.md | 4 ++--
src/docs/helpers.md | 4 ++--
src/docs/testing.md | 2 +-
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md
index 08281f8b0..a803df984 100644
--- a/src/docs/eloquent-relationships.md
+++ b/src/docs/eloquent-relationships.md
@@ -943,7 +943,7 @@ return $this->belongsToMany(Podcast::class)
->wherePivotNotNull('expired_at');
```
-You may pass a closure to `wherePivot` or `orWherePivot` to group conditions or call scopes on a [custom pivot model](#defining-custom-intermediate-table-models). The closure receives an Eloquent builder for the pivot model:
+You may pass a closure to `wherePivot` or `orWherePivot` to group conditions or call local scopes on a [custom pivot model](#defining-custom-intermediate-table-models). The closure receives an Eloquent builder for the pivot model:
```php
use Hypervel\Database\Eloquent\Builder;
@@ -954,7 +954,7 @@ return $this->belongsToMany(Role::class)
});
```
-Qualify column names inside the closure if the related table has columns with the same names. Pivot filters also constrain `detach`, `sync`, and `updateExistingPivot` operations.
+Qualify column names inside the closure if the related table has columns with the same names. Pivot filters constrain which existing rows `detach`, `sync`, and `updateExistingPivot` affect; they do not restrict newly attached rows.
The `wherePivot` adds a where clause constraint to the query, but does not add the specified value when creating new models via the defined relationship. If you need to both query and create relationships with a particular pivot value, you may use the `withPivotValue` method:
diff --git a/src/docs/helpers.md b/src/docs/helpers.md
index 1a8083152..8342f1842 100644
--- a/src/docs/helpers.md
+++ b/src/docs/helpers.md
@@ -3981,8 +3981,6 @@ The `Uri` class offers several methods that may be used to manipulate a URI's qu
$uri = $uri->withQuery(['sort' => 'name']);
```
-Query keys support dot notation for nested parameters. Asterisks in keys are treated literally, not as wildcards.
-
The `withQueryIfMissing` method may be used to merge additional query string parameters into the existing query string if the given keys do not already exist in the query string:
```php
@@ -4007,6 +4005,8 @@ The `withoutQuery` method may be used to remove parameters from the query string
$uri = $uri->withoutQuery(['page']);
```
+These mutation methods support dot notation for nested parameters and treat asterisks in keys literally. `$uri->query()->get()` treats asterisks as wildcards; use `$uri->query()->all()` to read a literal asterisk key.
+
#### Generating Responses From URIs
diff --git a/src/docs/testing.md b/src/docs/testing.md
index 5d8932f6f..235062ce5 100644
--- a/src/docs/testing.md
+++ b/src/docs/testing.md
@@ -272,7 +272,7 @@ Do not call `AfterEachTestCleanup::forgetCallbacks()` from ordinary application
To remove a specific callback that your test registered, call `AfterEachTestCleanup::forget($name)` instead.
-For static state owned by a particular application base test case, you may override its protected `flushState` method. Call `parent::flushState()` in your override. This hook runs after the test application is destroyed, so it must not resolve container services. It runs only for tests that boot the application; use the shared `TestState` registration above for cleanup that must also run after `#[UnitTest]` methods:
+For static state owned by a particular application base test case, you may override its protected `flushState` method. Call `parent::flushState()` in your override. This hook runs after the test application is destroyed, so it must not resolve container services. Tests marked `#[UnitTest]` skip this hook; use the shared `TestState` registration above for cleanup that must also run after those methods:
```php
protected function flushState(): void