From 2e12a711021b1ab1a3f67a6b7cfce55949b12910 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:51:28 +0000 Subject: [PATCH 01/16] Isolate query casts on cloned Eloquent builders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clone the builder model before merging query-time casts so a cloned query cannot change the original query’s casts. Consolidate the existing cast isolation regressions under the upstream test while retaining raw-value and cast-cache assertions. Port Laravel framework #61883 (2e172fca85d51e97a19b616260e108ecb1ac02d3), using the 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20. The regression fails before the correction and passes afterward. Eloquent builder and cast tests, full static analysis and the affected framework verification pass. --- src/database/src/Eloquent/Builder.php | 2 +- .../DatabaseEloquentWithCastsTest.php | 20 ++++++++----------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 4d1e98b96d..84feff07c6 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1899,7 +1899,7 @@ public function withAttributes(Expression|array|string $attributes, mixed $value */ public function withCasts(array $casts): static { - $this->model->mergeCasts($casts); + $this->model = (clone $this->model)->mergeCasts($casts); return $this; } diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index 55cf0360cc..38516505bd 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -83,26 +83,22 @@ public function testWithCreateOrFirst(): void $this->assertSame($time1->id, $time2->id); } - public function testWithCastsDoesNotLeakAcrossQueries(): void + public function testWithCastsOnClonedQueryDoesNotAffectOriginalQuery(): void { Time::query()->insert(['time' => '07:30']); - $scoped = Time::query()->withCasts(['time' => 'string'])->first(); + $query = Time::query(); + $scoped = $query->clone()->withCasts(['time' => 'string'])->first(); $this->assertIsString($scoped->time); $this->assertSame('07:30', $scoped->time); - - $default = Time::query()->first(); - $this->assertInstanceOf(CarbonInterface::class, $default->time); - } - - public function testWithCastsDoesNotLeakPredicateCacheAcrossQueries(): void - { - Time::query()->insert(['time' => '07:30']); - - $scoped = Time::query()->withCasts(['time' => 'string'])->first(); $this->assertFalse($scoped->hasCast('time', 'datetime')); + $original = $query->first(); + $this->assertInstanceOf(CarbonInterface::class, $original->time); + $this->assertTrue($original->hasCast('time', 'datetime')); + $default = Time::query()->first(); + $this->assertInstanceOf(CarbonInterface::class, $default->time); $this->assertTrue($default->hasCast('time', 'datetime')); } From 1431fbe9d1e90d121bda3eaf0fda2bc73bc2edbc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:51:55 +0000 Subject: [PATCH 02/16] Preserve scoped facade lifetimes and document queue state isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve scoped facade services through the container instead of retaining one coroutine’s instance in the worker-wide facade cache. Keep the ordinary cached and explicit swap paths. Declare the container’s existing scope inspection method on its contract and resolve aliases before classifying a binding. Document how facade caching interacts with bind() and scoped() registrations. Custom container implementations must supply isScoped(); subclasses of the framework container already inherit it. Reconcile Laravel framework #61915 (01ad26596c04306ad982c7d4dee262a623c30333) against 13.x target 5d7797528723107cd4f495326838a7d7d67fbb20. Daemon jobs already have independent database routing, log context and scoped services. Intentionally omit the flushStickyConnections switch and document the existing resetScope constructor omission, directing custom job setup to events or job-specific middleware. Add a real one-slot SQLite pool regression for successive jobs and concurrent facade identity coverage. Both regressions fail when their isolation is disabled. Container, facade and queue tests pass, as do source/type/skeleton analysis, Testbench, dogfood checks and generated facade verification. The full framework run exposed only an independent Redis test fixture configuration issue, corrected separately. --- src/container/src/Container.php | 5 ++ src/contracts/README.md | 4 ++ src/contracts/src/Container/Container.php | 5 ++ src/docs/database.md | 2 + src/docs/facades.md | 2 + src/docs/porting-from-laravel.md | 4 ++ src/queue/README.md | 1 + src/queue/src/Worker.php | 4 ++ src/support/README.md | 1 + src/support/src/Facades/Facade.php | 20 ++++---- tests/Container/ContainerTest.php | 3 ++ .../Sqlite/WorkerResourceLifetimeTest.php | 46 +++++++++++++++++++ tests/Integration/Queue/WorkCommandTest.php | 3 ++ tests/Support/SupportFacadeTest.php | 33 +++++++++++++ 14 files changed, 124 insertions(+), 9 deletions(-) diff --git a/src/container/src/Container.php b/src/container/src/Container.php index d3a9b06465..a542aeb31e 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -363,6 +363,11 @@ protected function isExplicitlyShared(string $abstract): bool */ public function isScoped(string $abstract): bool { + // Internal resolution usually supplies an already canonical name. + if (isset($this->aliases[$abstract])) { + $abstract = $this->getAlias($abstract); + } + if (isset($this->scopedInstances[$abstract])) { return true; } diff --git a/src/contracts/README.md b/src/contracts/README.md index 3825a769f3..d3274f89fe 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -5,4 +5,8 @@ Contracts for Hypervel Documentation: https://hypervel.org/docs/contracts +## Differences From Laravel + +The `Container` contract also requires `isScoped()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. + Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/contracts/src/Container/Container.php b/src/contracts/src/Container/Container.php index 7f5b05feca..bee49a9d7b 100644 --- a/src/contracts/src/Container/Container.php +++ b/src/contracts/src/Container/Container.php @@ -76,6 +76,11 @@ public function scoped(Closure|string $abstract, Closure|string|null $concrete = */ public function scopedIf(Closure|string $abstract, Closure|string|null $concrete = null): void; + /** + * Determine if a given abstract is a scoped binding. + */ + public function isScoped(string $abstract): bool; + /** * "Extend" an abstract type in the container. * diff --git a/src/docs/database.md b/src/docs/database.md index bd7560b0b8..70fb82e3fc 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -245,6 +245,8 @@ Eloquent models created or retrieved through `::write` retain that connection fo The `sticky` option is an *optional* value that can be used to allow the immediate reading of records that have been written to the database during the current request cycle. If the `sticky` option is enabled and a "write" operation has been performed against the database during the current request cycle, any further "read" operations will use the "write" connection. This ensures that any data written during the request cycle can be immediately read back from the database during that same request. In Hypervel, sticky state belongs to the coroutine's connection and survives an early release of its physical session. If the write side can reach more than one server, the connection keeps its session after writing instead; see [releasing and pinning connections](#releasing-and-pinning-connections). Other coroutines have independent routing state. It is up to you to decide if this is the desired behavior for your application. +A long-running queue worker gives each job its own coroutine, so one job's writes do not send later jobs' reads to the write connection. + ### Connection Pooling diff --git a/src/docs/facades.md b/src/docs/facades.md index 30101cf2c8..0e7e73c020 100644 --- a/src/docs/facades.md +++ b/src/docs/facades.md @@ -193,6 +193,8 @@ class Cache extends Facade Instead, the `Cache` facade extends the base `Facade` class and defines the method `getFacadeAccessor()`. This method's job is to return the name of a service container binding. When a user references any static method on the `Cache` facade, Hypervel resolves the `cache` binding from the [service container](/docs/{{version}}/container) and runs the requested method (in this case, `get`) against that object. +By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped bindings are the exception: register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. + ## Facade Class Reference diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 8740e3f7eb..1fb1db8da1 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -431,6 +431,8 @@ Container lifecycles are adapted for Swoole: > [!WARNING] > Unbound concrete classes are automatically cached for the worker lifetime after their first resolution. If an unbound class captures the current user, tenant, request, or other mutable per-request data in its constructor, ordinary tests may pass while concurrent requests receive another request's state. Register the class with `bind()` for a fresh instance, use `scoped()` for one instance per request or job coroutine, call `makeTransient()` for one normal resolution without implicit auto-singletoning or constructor-derived execution scope, construct a guaranteed fresh instance with `build()`, or implement `Transient` when every subclass must always be fresh. Hypervel's intrinsically fresh model, value, builder, pending-request, pipeline, and response families already implement `Transient`; see [Transient Classes](/docs/{{version}}/container#transient-classes) for the current list and binding behavior. +Facades cache non-scoped services for the worker lifetime, including `bind()` bindings; use `scoped()` for request or job state, or disable the facade's cache for a fresh resolution on every call. See [how facades work](/docs/{{version}}/facades#how-facades-work). + Hypervel treats a contextual attribute's resolved value as authoritative, including `null`. Laravel constructor injection may fall through from `null` to class or primitive resolution, a contextual binding, or a declared default. Move that fallback into the attribute resolver when porting code that relies on this behavior. On PHP 8.5 and later, `#[BindWhen]` conditions must depend only on boot-stable state. A matching condition becomes a normal worker-lifetime binding in Hypervel, while an unmatched condition may be evaluated again on a later resolution. Do not read the current request, user, or tenant from the condition. @@ -869,6 +871,8 @@ Laravel's Memcached, APC / APCu, DynamoDB, and generic cache-backed session conf Queue connections include `database`, `redis`, `sqs`, `beanstalkd`, `failover`, `sync`, `background`, `deferred`, and `null`. The `background` and `deferred` drivers run work inside the current worker process and are not durable external queues. +Remove assignments to `Worker::$flushStickyConnections`: daemon jobs already have independent [sticky routing state](/docs/{{version}}/database#the-sticky-option). If a job must read from the writer regardless of its own writes, use an explicit `::write` connection. The worker constructor also omits Laravel's `$resetScope` callback; move custom per-job setup or cleanup into [job events](/docs/{{version}}/queues#job-events), or [job middleware](/docs/{{version}}/queues#job-middleware) for specific jobs, instead of a worker-wide reset. + Hypervel stores job batches in a relational database. Laravel's DynamoDB batch repository and DynamoDB failed-job provider are not available. Supported failed-job drivers are `database`, `database-uuids`, `file`, and `null`. See the [queue documentation](/docs/{{version}}/queues) for connection and worker configuration. Custom queue drivers whose job classes implement `Hypervel\Contracts\Queue\Job` directly must also implement `Hypervel\Contracts\Queue\JobLifecycleCallbacks` to run jobs that use `CountCrashesAsExceptions`; extending `Hypervel\Queue\Jobs\Job` provides it. See [counting worker crashes](/docs/{{version}}/queues#counting-worker-crashes) for the callback requirements. diff --git a/src/queue/README.md b/src/queue/README.md index 63ccf48feb..e2228897de 100644 --- a/src/queue/README.md +++ b/src/queue/README.md @@ -7,6 +7,7 @@ Documentation: https://hypervel.org/docs/queues ## Differences From Laravel +- Daemon jobs own their database routing, log context and scoped-container state. Laravel's `Worker::$flushStickyConnections` setting and the worker constructor's `$resetScope` callback are omitted; a worker-wide reset could affect concurrent jobs. See [porting queues](https://hypervel.org/docs/porting-from-laravel#queues). - Laravel's DynamoDB failed-job provider is not provided. Use database or file storage for failed jobs. - Custom job implementations must support `JobLifecycleCallbacks` to use `CountCrashesAsExceptions`, so cleanup completes before a concurrent retry starts. The base `Jobs\Job` already implements it; the `Job` contract is unchanged. See [counting worker crashes](https://hypervel.org/docs/queues#counting-worker-crashes). - Timeouts unwind yielding job code with Swoole's `CanceledException`, starting its `catch` and `finally` blocks. Use `finally` for cleanup and rethrow cancellation if caught. The worker stops after timeout handling by default, with a bounded wait for cleanup; `Worker::$killOnTimeout = false` keeps other jobs running. See [worker timeouts](https://hypervel.org/docs/queues#worker-timeouts) for cleanup and blocked-loop recovery. diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 456cae9964..088bc0e82f 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -279,6 +279,10 @@ class Worker */ public static bool $pausable = true; + // Laravel's $flushStickyConnections and $resetScope constructor callback are + // intentionally omitted. Daemon jobs own their database, log and scoped + // container state; worker-wide resets would interfere with concurrent jobs. + /** * Create a new queue worker. * diff --git a/src/support/README.md b/src/support/README.md index 11eaf4538f..01b4fdf1e0 100644 --- a/src/support/README.md +++ b/src/support/README.md @@ -5,6 +5,7 @@ Support for Hypervel ## Differences From Laravel +- Facades cache non-scoped services for the worker lifetime, including `bind()` bindings. Use `scoped()` for request or job state, or disable facade caching for fresh resolutions. See [facade lifetimes](https://hypervel.org/docs/facades#how-facades-work). - `all([])` on `Fluent`, `ValidatedInput`, and `UriQueryString` returns an empty array. Use `all()` to retrieve every field. - Laravel's deferred service-provider metadata APIs are intentionally omitted. Hypervel registers providers once for a long-lived worker, so the per-request bootstrap optimization those APIs support does not apply. - Node package-manager detection walks from the current directory to the filesystem root, so applications inside a workspace use the nearest ancestor lockfile. Within each directory, detection prefers Bun, pnpm, Yarn, then npm. diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php index 2b37906ae4..9ed05d0609 100644 --- a/src/support/src/Facades/Facade.php +++ b/src/support/src/Facades/Facade.php @@ -44,12 +44,11 @@ abstract class Facade * instead of caching in $resolvedInstance. Useful for stateful services * like Pipeline where each usage needs a fresh instance. * - * Important: for $cached = false to produce fresh instances, the - * underlying class must be explicitly bound with bind(), not singleton(). - * Our container auto-singletons unbound classes, so an unbound class - * would still return the same instance from the container regardless - * of this setting. This differs from Laravel where unbound classes - * always resolve fresh. + * Scoped bindings always resolve through the container, preserving the + * current coroutine's instance. When caching is enabled, other bindings + * keep their first resolved instance for the worker lifetime. + * The container's lifetime still applies: disabling this cache does not + * make singletons or auto-singletons fresh. */ protected static bool $cached = true; @@ -238,11 +237,14 @@ protected static function resolveFacadeInstance(string $name): mixed } if (static::$app) { - if (static::$cached) { - return static::$resolvedInstance[$name] = static::$app->make($name); + $instance = static::$app->make($name); + + // Scoped services belong to one coroutine, so the worker-wide cache must not keep them. + if (static::$cached && ! static::$app->isScoped($name)) { + static::$resolvedInstance[$name] = $instance; } - return static::$app->make($name); + return $instance; } return null; diff --git a/tests/Container/ContainerTest.php b/tests/Container/ContainerTest.php index 958c59aedb..8c8d50ba30 100755 --- a/tests/Container/ContainerTest.php +++ b/tests/Container/ContainerTest.php @@ -1628,6 +1628,9 @@ public function testIsScopedDetectsAttributeWithoutCallingIsShared(): void $container = new Container; $this->assertTrue($container->isScoped(ContainerScopedAttribute::class)); + + $container->alias(ContainerScopedAttribute::class, 'scoped.alias'); + $this->assertTrue($container->isScoped('scoped.alias')); } public function testExecutionScopedConstructorAttributeUsesTheExistingScopedLifetime(): void diff --git a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php index 7f68ec665e..3994ea4bef 100644 --- a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php +++ b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php @@ -292,6 +292,52 @@ protected function supportsAsyncSignals(): bool $this->assertTrue($stopped); } + public function testDaemonJobsHaveIndependentRecordModificationState(): void + { + (new Waiter(-1))->wait(static fn (): bool => DB::statement('CREATE TABLE records (id INTEGER)')); + $modificationStates = []; + $jobs = []; + + foreach ([ + static function () use (&$modificationStates): void { + DB::table('records')->insert(['id' => 1]); + $modificationStates[] = DB::connection()->hasModifiedRecords(); + }, + static function () use (&$modificationStates): void { + $modificationStates[] = DB::connection()->hasModifiedRecords(); + }, + ] as $handler) { + $job = m::mock(JobContract::class); + $job->expects('payload')->twice()->andReturn([]); + $job->expects('maxTries')->once()->andReturnNull(); + $job->expects('retryUntil')->once()->andReturnNull(); + $job->expects('attempts')->once()->andReturn(1); + $job->expects('isDeleted')->once()->andReturnFalse(); + $job->expects('isReleased')->once()->andReturnFalse(); + $job->expects('timeout')->once()->andReturnNull(); + $job->expects('fire')->once()->andReturnUsing($handler); + $jobs[] = $job; + } + + $connection = m::mock(Queue::class); + $connection->expects('getConnectionName')->times(6)->andReturn('default'); + $connection->expects('pop')->twice()->with('queue')->andReturn(...$jobs); + $manager = m::mock(QueueManager::class); + $manager->expects('connection')->twice()->with('default')->andReturn($connection); + $worker = new class($manager, $this->app->make(EventDispatcher::class), $this->app->make(ExceptionHandlerContract::class), static fn (): bool => false) extends Worker { + protected function supportsAsyncSignals(): bool + { + return false; + } + }; + + $this->assertSame( + Worker::EXIT_SUCCESS, + $worker->daemon('default', 'queue', new WorkerOptions(maxJobs: 2, memory: 1024)), + ); + $this->assertSame([true, false], $modificationStates); + } + public function testTimeoutAndSignalCallbacksReleasePooledConnectionsAfterEachBatch(): void { $connectionName = $this->app->make('config')->string('database.default'); diff --git a/tests/Integration/Queue/WorkCommandTest.php b/tests/Integration/Queue/WorkCommandTest.php index 59d0acc1f1..5b055736ee 100644 --- a/tests/Integration/Queue/WorkCommandTest.php +++ b/tests/Integration/Queue/WorkCommandTest.php @@ -194,6 +194,9 @@ public function testDaemon(): void $this->assertTrue(SecondJob::$ran); } + // REMOVED: testWorkerDoesNotFlushStickyConnectionsByDefault and testWorkerCanFlushStickyConnections. + // Daemon jobs always own independent routing state; WorkerResourceLifetimeTest covers the real pool. + public function testDaemonWritesOutputFromJobCoroutine(): void { $this->markTestSkippedWhenUsingQueueDrivers(['redis', 'beanstalkd']); diff --git a/tests/Support/SupportFacadeTest.php b/tests/Support/SupportFacadeTest.php index 4697b88614..c65e7f95fc 100755 --- a/tests/Support/SupportFacadeTest.php +++ b/tests/Support/SupportFacadeTest.php @@ -12,6 +12,8 @@ use Mockery\MockInterface; use stdClass; +use function Hypervel\Coroutine\parallel; + class SupportFacadeTest extends TestCase { protected function setUp(): void @@ -204,6 +206,37 @@ public function testIsFakeReturnsFalseForNonFakeInstance(): void $this->assertFalse(FacadeStub::isFake()); } + public function testScopedFacadeResolvesWithinEachCoroutine(): void + { + $app = new Container; + $app->scoped('foo', static fn (): stdClass => new stdClass); + FacadeStub::setFacadeApplication($app); + + $resolve = function (): object { + $instance = FacadeStub::getFacadeRoot(); + usleep(1000); + $this->assertSame($instance, FacadeStub::getFacadeRoot()); + + return $instance; + }; + + [$first, $second] = parallel([$resolve, $resolve]); + + $this->assertNotSame($first, $second); + } + + public function testCachedFacadeResolvesOnce(): void + { + $app = new CountingApplicationStub; + $app->setInstances(['foo' => new stdClass]); + FacadeStub::setFacadeApplication($app); + + FacadeStub::getFacadeRoot(); + FacadeStub::getFacadeRoot(); + + $this->assertSame(1, $app->makeCount); + } + public function testUncachedFacadeResolvesEachTime(): void { $app = new CountingApplicationStub; From 20f1595e09fa259b9f077e5391ca72cc5cc58e0c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:51:55 +0000 Subject: [PATCH 03/16] Isolate Redis allocation tests from runner database reservations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clear the ambient secondary database in the existing captured environment setup and explicitly provide the upper bound required by the minimum-default test. Restore all environment sources afterward through the existing cleanup. A valid four-worker allocation ending at database four previously made the fixture fail after it changed its starting database to four. Reserving database six also changed two fixtures’ expected worker assignments. These tests exercise simulated configuration, so their inputs must not inherit the runner’s reservations. The complete fixture passes with the previously failing limit and reservation and through four-worker ParaTest. Keep every existing assertion; no Redis runtime behavior changes. --- .../Testing/Concerns/InteractsWithRedisParallelTest.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php index afccd855b2..2b154dc5ac 100644 --- a/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php +++ b/tests/Foundation/Testing/Concerns/InteractsWithRedisParallelTest.php @@ -46,6 +46,7 @@ protected function setUp(): void $this->captureEnvironmentValues(); $this->setRedisEnvironmentValue('REDIS_CLUSTER_HOSTS_AND_PORTS', null); + $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', null); $this->setParallelTestingToken(false); } @@ -91,6 +92,7 @@ public function testRedisTestDbMinDefaultsToRedisDb(): void { $this->setRedisEnvironmentValue('REDIS_DB', '4'); $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', null); + $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', '5'); $this->setParallelTestingToken('2'); $this->assertSame(5, $this->harness()->parallelRedisDb()); @@ -100,7 +102,6 @@ public function testRedisTestDbMaxDefaultsToFifteen(): void { $this->setRedisEnvironmentValue('REDIS_TEST_DB_MIN', '14'); $this->setRedisEnvironmentValue('REDIS_TEST_DB_MAX', null); - $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', null); $this->assertSame([14, 15], RedisTestDatabases::workerDatabases()); } @@ -119,8 +120,6 @@ public function testParallelWorkerOverflowFails(): void public function testSecondaryRedisDbIsRequired(): void { - $this->setRedisEnvironmentValue('REDIS_TEST_SECONDARY_DB', null); - $this->expectException(RuntimeException::class); $this->expectExceptionMessageIs('REDIS_TEST_SECONDARY_DB must be set before requesting the secondary Redis test database.'); From 4056b4fcd1795a3f478a0268caf84f62b1a4516a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:19:17 +0000 Subject: [PATCH 04/16] Run formatting at review and commit checkpoints Move composer lint:fix out of the per-slice verification sequence. Run it before a scheduled code review or commit checkpoint instead of after individual changes within a batch. Keep static analysis and affected tests at the end of each coherent implementation slice, and retain immediate checks for changed test files. This avoids repeated full-repository formatting while a batch is still being built. --- AGENTS.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b432595329..aecf43eeb5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -121,7 +121,9 @@ Anything found follows When to Stop and Report — "the task didn't ask me to fi Do not routinely run formatting or PHPStan during edits. Run targeted PHPStan earlier only when investigating a typing issue or when its feedback is needed to guide implementation. -**At the end of a coherent implementation slice**, run `composer lint:fix`, `composer analyse`, and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection. +**At the end of a coherent implementation slice**, run `composer analyse` and the affected tests, in that order. Use PHPUnit for one test file or `composer test:parallel -- --processes=4 tests/PackageName` for multiple files. Use `--filter` when needed to narrow the selection. + +Run `composer lint:fix` before a scheduled code review or commit checkpoint, not after individual changes within a batch. Run the full test suite only when the change has framework-wide impact. In that case, use `composer fix`, which includes formatting, analysis and all test suites; do not run those checks separately first. For focused changes, include separate Testbench or dogfood checks when the changed behavior affects those workflows. From ea9524f2e5cf6dd1170e9c83f987259d8826b4a7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:29:49 +0000 Subject: [PATCH 05/16] Cover unreachable Redis Cluster connection replacement Reconcile Laravel framework #61911 at 594f3a8da31e4a37643c8b77b7e76628419f54be. The existing connection failure handling already invalidates a client when no cluster node is reachable, so retain that implementation and its no-command-replay policy. Add the native RedisClusterException to the existing failure provider. The shared assertions verify that the original error propagates, the command is not replayed, and the next acquisition uses a replacement client successfully. Validated with the complete RedisConnectionTest file and affected parallel tests. --- tests/Redis/RedisConnectionTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 1030848053..1a359f02e1 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -1648,6 +1648,7 @@ public static function connectionFailureProvider(): array 'non-idempotent write' => [new RedisException('Connection lost'), 'incr', ['foo'], false], 'write with options' => [new RedisException('Connection lost'), 'set', ['foo', 'bar', ['ex' => 60]], false], 'cluster response error' => [new RedisClusterException('Error processing response from Redis node!'), 'get', ['foo'], false], + 'no reachable cluster node' => [new RedisClusterException("Can't communicate with any node in the cluster"), 'get', ['foo'], false], ]; } From fb0beeda82bfd3d41a26af91045ee92fa437b365 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:01 +0000 Subject: [PATCH 06/16] Consume notification locale overrides for each send Port Laravel framework #61904 and #61959 at ca8cc85f47acbc672e36f8b9ed3825000b1b8514 and 1ee6d193dbb293fc0cd7334856848d0d80d50c2e. A locale selected after boot applies to the next send, including when earlier notifications have already been sent. Create a sender for each call and consume the coroutine-local override before dispatch. This preserves provider-configured defaults while preventing concurrent sends from changing each other. NotificationFake captures and clears its override in the same way. Document the retained boot default and the sender-creation extension point instead of introducing a shared mutable sender cache. Port both upstream integration cases and cover overlapping multi-recipient sends, later default-locale sends, and fake behavior. The original leakage and a temporary shared-sender implementation both fail the new regressions. Notification suites, affected parallel tests, formatting and full static analysis pass. --- src/docs/notifications.md | 2 +- src/notifications/README.md | 5 ++ src/notifications/src/ChannelManager.php | 38 ++++++++----- src/notifications/src/NotificationSender.php | 10 ++++ .../src/Testing/Fakes/NotificationFake.php | 5 +- .../SendingNotificationsWithLocaleTest.php | 34 ++++++++++++ .../Notifications/CoroutineIsolationTest.php | 53 +++++++++++++++++++ .../SupportTestingNotificationFakeTest.php | 10 ++++ 8 files changed, 141 insertions(+), 16 deletions(-) diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 6f35fdd2b2..7ae82eba78 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -1731,7 +1731,7 @@ Notification::locale('es')->send( ); ``` -Calling `Notification::locale` from a service provider's `register` or `boot` method configures the default locale for later executions. Calls made after the application has booted apply only to the current execution. +Calling `Notification::locale` from a service provider's `register` or `boot` method configures the default locale for later executions. Calls made after the application has booted apply only to the next `send` or `sendNow` call in the current execution. Later sends return to the boot default. Both the boot default and a per-send override take precedence over a notifiable's preferred locale. #### User Preferred Locales diff --git a/src/notifications/README.md b/src/notifications/README.md index 04aa9adecf..61ed955cb0 100644 --- a/src/notifications/README.md +++ b/src/notifications/README.md @@ -3,4 +3,9 @@ Notifications for Hypervel Documentation: https://hypervel.org/docs/notifications +## Differences From Laravel + +- Calling `Notification::locale()` during provider registration or boot sets a worker default. Calls after boot apply only to the next send in the current execution. See [notification locales](https://hypervel.org/docs/notifications#localizing-notifications). +- The manager creates a sender for each send so concurrent sends cannot change one another's locale. Laravel's protected `$notificationSender` cache is omitted; override `resolveNotificationSender()` to customize sender creation. + Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Notifications diff --git a/src/notifications/src/ChannelManager.php b/src/notifications/src/ChannelManager.php index 9bbd1dee3e..d281672ba6 100644 --- a/src/notifications/src/ChannelManager.php +++ b/src/notifications/src/ChannelManager.php @@ -25,13 +25,16 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr use Macroable; use ResolvesQueueRoutes; + // Laravel's cached $notificationSender is intentionally omitted: overlapping + // sends must not change one another's locale. + /** * Context key for the per-request default channel override. */ protected const string DEFAULT_CHANNEL_CONTEXT_KEY = '__notifications.default_channel'; /** - * Context key for the per-request default locale override. + * Context key for the next send's locale override. */ protected const string DEFAULT_LOCALE_CONTEXT_KEY = '__notifications.default_locale'; @@ -41,7 +44,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr protected string $defaultChannel = 'mail'; /** - * The locale used when sending notifications. + * The default notification locale configured during boot. */ protected ?string $locale = null; @@ -50,12 +53,7 @@ class ChannelManager extends Manager implements DispatcherContract, FactoryContr */ public function send(mixed $notifiables, mixed $notification): void { - (new NotificationSender( - $this, - $this->container->make(BusDispatcherContract::class), - $this->container->make(EventDispatcher::class), - $this->getLocale() - ))->send($notifiables, $notification); + $this->resolveNotificationSender()->send($notifiables, $notification); } /** @@ -63,12 +61,7 @@ public function send(mixed $notifiables, mixed $notification): void */ public function sendNow(mixed $notifiables, mixed $notification, ?array $channels = null): void { - (new NotificationSender( - $this, - $this->container->make(BusDispatcherContract::class), - $this->container->make(EventDispatcher::class), - $this->getLocale() - ))->sendNow($notifiables, $notification, $channels); + $this->resolveNotificationSender()->sendNow($notifiables, $notification, $channels); } /** @@ -129,6 +122,23 @@ protected function createDriver(string $driver): mixed } } + /** + * Resolve the notification sender instance. + */ + protected function resolveNotificationSender(): NotificationSender + { + $sender = (new NotificationSender( + $this, + $this->container->make(BusDispatcherContract::class), + $this->container->make(EventDispatcher::class), + ))->locale($this->getLocale()); + + // Consume the execution override without changing the worker's boot default. + CoroutineContext::forget(self::DEFAULT_LOCALE_CONTEXT_KEY); + + return $sender; + } + /** * Get the default channel driver name. */ diff --git a/src/notifications/src/NotificationSender.php b/src/notifications/src/NotificationSender.php index 861d30ac3b..fc0fe6b4d2 100644 --- a/src/notifications/src/NotificationSender.php +++ b/src/notifications/src/NotificationSender.php @@ -293,4 +293,14 @@ protected function formatNotifiables(mixed $notifiables): array|Collection return $notifiables; } + + /** + * Set the locale to be used when sending notifications. + */ + public function locale(?string $locale): static + { + $this->locale = $locale; + + return $this; + } } diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 3c1558500e..03ef5b3ac1 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -280,6 +280,9 @@ public function send(mixed $notifiables, mixed $notification): void */ public function sendNow(mixed $notifiables, mixed $notification, ?array $channels = null): void { + $locale = $this->locale; + $this->locale = null; + if (! $notifiables instanceof Collection && ! is_array($notifiables)) { $notifiables = [$notifiables]; } @@ -308,7 +311,7 @@ public function sendNow(mixed $notifiables, mixed $notification, ?array $channel : $notification, 'channels' => $notifiableChannels, 'notifiable' => $notifiable, - 'locale' => $notification->locale ?? $this->locale ?? value(function () use ($notifiable) { + 'locale' => $notification->locale ?? $locale ?? value(function () use ($notifiable) { if ($notifiable instanceof HasLocalePreference) { return $notifiable->preferredLocale(); } diff --git a/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php b/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php index bbc62d5e67..5e57043fac 100644 --- a/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php +++ b/tests/Integration/Notifications/SendingNotificationsWithLocaleTest.php @@ -93,6 +93,40 @@ public function testMailIsSentWithFacadeSelectedLocale(): void ); } + public function testMailIsSentWithFacadeSelectedLocaleAfterAnotherNotificationWasSent(): void + { + $user = NotifiableLocalizedUser::forceCreate([ + 'email' => 'taylor@hypervel.com', + 'name' => 'Taylor Otwell', + ]); + + NotificationFacade::send($user, new GreetingMailNotification); + NotificationFacade::locale('fr')->send($user, new GreetingMailNotification); + + $messages = app('mailer')->getSymfonyTransport()->messages(); + + $this->assertStringContainsString('hello', $messages[0]->toString()); + $this->assertStringContainsString('bonjour', $messages[1]->toString()); + } + + public function testFacadeSelectedLocaleIsNotUsedForLaterNotifications(): void + { + $user = NotifiableLocalizedUser::forceCreate([ + 'email' => 'taylor@hypervel.com', + 'name' => 'Taylor Otwell', + ]); + + NotificationFacade::locale('fr')->send($user, new GreetingMailNotification); + NotificationFacade::send($user, new GreetingMailNotification); + $user->notify(new GreetingMailNotification); + + $messages = app('mailer')->getSymfonyTransport()->messages(); + + $this->assertStringContainsString('bonjour', $messages[0]->toString()); + $this->assertStringNotContainsString('bonjour', $messages[1]->toString()); + $this->assertStringNotContainsString('bonjour', $messages[2]->toString()); + } + public function testMailIsSentWithNotificationSelectedLocale(): void { $users = [ diff --git a/tests/Notifications/CoroutineIsolationTest.php b/tests/Notifications/CoroutineIsolationTest.php index e9c11a1c37..5d5b8d0ba6 100644 --- a/tests/Notifications/CoroutineIsolationTest.php +++ b/tests/Notifications/CoroutineIsolationTest.php @@ -6,9 +6,15 @@ use Hypervel\Config\Repository as ConfigRepository; use Hypervel\Container\Container; +use Hypervel\Contracts\Bus\Dispatcher as BusDispatcher; use Hypervel\Foundation\Application; use Hypervel\Notifications\ChannelManager; +use Hypervel\Notifications\Notification; +use Hypervel\Tests\Notifications\Fixtures\NotificationStub; use Hypervel\Tests\TestCase; +use Hypervel\Translation\ArrayLoader; +use Hypervel\Translation\Translator; +use Mockery as m; use function Hypervel\Coroutine\parallel; @@ -50,6 +56,53 @@ function () use ($manager): array { $this->assertSame([['mail', null]], $freshContext); } + public function testConcurrentSendsConsumeOnlyTheirOwnLocaleOverride(): void + { + $application = new Application; + $application->instance('config', new ConfigRepository([])); + $application->instance(BusDispatcher::class, m::mock(BusDispatcher::class)); + $translator = new Translator(new ArrayLoader, 'en'); + $application->instance('translator', $translator); + $manager = new ChannelManager($application); + $manager->locale('fr'); + $manager->extend('mail', fn () => new class($translator) { + /** + * Create a channel with the shared translator. + */ + public function __construct(private Translator $translator) + { + } + + /** + * Record the locale after another send can run. + */ + public function send(object $notifiable, Notification $notification): void + { + usleep(1000); + $notifiable->locales[] = $this->translator->getLocale(); + } + }); + $application->boot(); + + $send = function (string $locale) use ($manager): array { + $first = (object) ['locales' => []]; + $second = (object) ['locales' => []]; + + $manager->locale($locale)->send([$first, $second], new NotificationStub); + $manager->send([$first, $second], new NotificationStub); + + return [$first->locales, $second->locales]; + }; + + $this->assertSame([ + [['en', 'fr'], ['en', 'fr']], + [['es', 'fr'], ['es', 'fr']], + ], parallel([ + fn (): array => $send('en'), + fn (): array => $send('es'), + ])); + } + public function testConfigurationBeforeBootBecomesTheWorkerBaseline(): void { $application = new Application; diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php index d6b296d9db..7ee960158d 100644 --- a/tests/Support/SupportTestingNotificationFakeTest.php +++ b/tests/Support/SupportTestingNotificationFakeTest.php @@ -259,6 +259,16 @@ public function testAssertSentToWhenNotifiableHasPreferredLocale(): void }); } + public function testLocaleIsNotUsedForLaterNotifications(): void + { + $this->fake->locale('fr')->send($this->user, new NotificationStub); + $this->fake->send($this->user, new NotificationStub); + + $notifications = $this->fake->sentNotifications()[UserStub::class][''][NotificationStub::class]; + + $this->assertSame(['fr', null], array_column($notifications, 'locale')); + } + public function testAssertSentToWhenNotifiableHasFalsyShouldSend(): void { $user = new LocalizedUserStub; From 101f2f8c203df44b497c73bb22a668f800a8eda4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:23 +0000 Subject: [PATCH 07/16] Resolve morph aliases in relationship loading constraints Port Laravel framework #61898 at d1df8eef5c2850018492bcbf8ab4c2eb51f4b26c. Resolve mapped aliases for morph constraints and eager loads, and accept alias keys when models and collections load polymorphic relationships or aggregates. Explicit class keys retain their precedence. Correct the Model and Collection relation-loading annotations as well. Keyed callbacks and per-type strings are supported inputs; aggregate callbacks receive an Eloquent builder, while eager-loading callbacks receive a relation. Update existing type fixtures and add model examples that reproduce the previous false PHPStan argument errors. Replace invalid closure-per-type examples with supported string mappings. Port every upstream regression and retain framework-owned morph-map cleanup. Add alias coverage to the existing count-loading tests and document the supported keys. The affected relationship tests, complete source/type/skeleton analysis and formatting pass. --- src/database/src/Eloquent/Collection.php | 26 +++++++----- src/database/src/Eloquent/Model.php | 42 +++++++++---------- .../src/Eloquent/Relations/MorphTo.php | 20 +++++++-- src/docs/eloquent-relationships.md | 2 + .../Database/EloquentMorphConstrainTest.php | 24 +++++++++++ .../EloquentMorphCountEagerLoadingTest.php | 16 +++++++ ...EloquentMorphCountLazyEagerLoadingTest.php | 16 +++++++ .../EloquentMorphEagerLoadingTest.php | 18 ++++++++ .../EloquentMorphLazyEagerLoadingTest.php | 16 +++++++ types/Database/Eloquent/Collection.php | 30 +++++-------- types/Database/Eloquent/Model.php | 4 ++ 11 files changed, 159 insertions(+), 55 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 4144b7266d..7a032db5bf 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -127,7 +127,7 @@ public function load(array|string $relations): static /** * Load a set of aggregations over relationship's column onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations * * @throws MissingAttributeException */ @@ -189,7 +189,7 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Load a set of relationship counts onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadCount(array|string $relations): static { @@ -199,7 +199,7 @@ public function loadCount(array|string $relations): static /** * Load a set of relationship's max column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -209,7 +209,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's min column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -219,7 +219,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's column summations onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -229,7 +229,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's average column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -239,7 +239,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Load a set of related existences onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadExists(array|string $relations): static { @@ -351,14 +351,16 @@ function (BaseCollection $models) use ($path): void { /** * Load a set of relationships onto the mixed relationship collection. * - * @param array): mixed)|string> $relations + * @param array $relations */ public function loadMorph(string $relation, array $relations): static { $this->pluck($relation) ->filter() ->groupBy(fn ($model) => get_class($model)) - ->each(fn ($models, $className) => static::make($models)->load($relations[$className] ?? [])); + ->each(fn ($models, $className) => static::make($models)->load( + $relations[$className] ?? $relations[$models->first()->getMorphClass()] ?? [] + )); return $this; } @@ -366,14 +368,16 @@ public function loadMorph(string $relation, array $relations): static /** * Load a set of relationship counts onto the mixed relationship collection. * - * @param array): mixed)|string> $relations + * @param array $relations */ public function loadMorphCount(string $relation, array $relations): static { $this->pluck($relation) ->filter() ->groupBy(fn ($model) => get_class($model)) - ->each(fn ($models, $className) => static::make($models)->loadCount($relations[$className] ?? [])); + ->each(fn ($models, $className) => static::make($models)->loadCount( + $relations[$className] ?? $relations[$models->first()->getMorphClass()] ?? [] + )); return $this; } diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 8ebf1d37b5..7fda1a3bae 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -972,7 +972,7 @@ public static function all(array|string $columns = ['*']): Collection /** * Begin querying a model with eager loading. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations * @return Builder */ public static function with(array|string $relations): Builder @@ -985,7 +985,7 @@ public static function with(array|string $relations): Builder /** * Eager load relations on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function load(array|string $relations): static { @@ -1001,7 +1001,7 @@ public function load(array|string $relations): static /** * Eager load relationships on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorph(string $relation, array $relations): static { @@ -1009,9 +1009,9 @@ public function loadMorph(string $relation, array $relations): static return $this; } - $className = get_class($this->{$relation}); + $model = $this->{$relation}; - $this->{$relation}->load($relations[$className] ?? []); + $model->load($relations[get_class($model)] ?? $relations[$model->getMorphClass()] ?? []); return $this; } @@ -1019,7 +1019,7 @@ public function loadMorph(string $relation, array $relations): static /** * Eager load relations on the model if they are not already eager loaded. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMissing(array|string $relations): static { @@ -1033,7 +1033,7 @@ public function loadMissing(array|string $relations): static /** * Eager load relation's column aggregations on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { @@ -1045,7 +1045,7 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Eager load relation counts on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadCount(array|string $relations): static { @@ -1057,7 +1057,7 @@ public function loadCount(array|string $relations): static /** * Eager load relation max column values on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -1067,7 +1067,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Eager load relation min column values on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -1077,7 +1077,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Eager load relation's column summations on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -1087,7 +1087,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Eager load relation average column values on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -1097,7 +1097,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Eager load related model existence values on the model. * - * @param array|string $relations + * @param array): mixed)|string>|string $relations */ public function loadExists(array|string $relations): static { @@ -1107,7 +1107,7 @@ public function loadExists(array|string $relations): static /** * Eager load relationship column aggregation on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphAggregate(string $relation, array $relations, Expression|string $column, ?string $function = null): static { @@ -1115,9 +1115,9 @@ public function loadMorphAggregate(string $relation, array $relations, Expressio return $this; } - $className = get_class($this->{$relation}); + $model = $this->{$relation}; - $this->{$relation}->loadAggregate($relations[$className] ?? [], $column, $function); + $model->loadAggregate($relations[get_class($model)] ?? $relations[$model->getMorphClass()] ?? [], $column, $function); return $this; } @@ -1125,7 +1125,7 @@ public function loadMorphAggregate(string $relation, array $relations, Expressio /** * Eager load relationship counts on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphCount(string $relation, array $relations): static { @@ -1135,7 +1135,7 @@ public function loadMorphCount(string $relation, array $relations): static /** * Eager load relationship max column values on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphMax(string $relation, array $relations, Expression|string $column): static { @@ -1145,7 +1145,7 @@ public function loadMorphMax(string $relation, array $relations, Expression|stri /** * Eager load relationship min column values on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphMin(string $relation, array $relations, Expression|string $column): static { @@ -1155,7 +1155,7 @@ public function loadMorphMin(string $relation, array $relations, Expression|stri /** * Eager load relationship column summations on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphSum(string $relation, array $relations, Expression|string $column): static { @@ -1165,7 +1165,7 @@ public function loadMorphSum(string $relation, array $relations, Expression|stri /** * Eager load relationship average column values on the polymorphic relation of a model. * - * @param array> $relations + * @param array $relations */ public function loadMorphAvg(string $relation, array $relations, Expression|string $column): static { diff --git a/src/database/src/Eloquent/Relations/MorphTo.php b/src/database/src/Eloquent/Relations/MorphTo.php index bbcd48827f..41b0fd0c97 100644 --- a/src/database/src/Eloquent/Relations/MorphTo.php +++ b/src/database/src/Eloquent/Relations/MorphTo.php @@ -335,7 +335,7 @@ public function morphWith(array $with): static { $this->morphableEagerLoads = array_merge( $this->morphableEagerLoads, - $with + $this->resolveMorphTypes($with) ); return $this; @@ -350,7 +350,7 @@ public function morphWithCount(array $withCount): static { $this->morphableEagerLoadCounts = array_merge( $this->morphableEagerLoadCounts, - $withCount + $this->resolveMorphTypes($withCount) ); return $this; @@ -365,12 +365,26 @@ public function constrain(array $callbacks): static { $this->morphableConstraints = array_merge( $this->morphableConstraints, - $callbacks + $this->resolveMorphTypes($callbacks) ); return $this; } + /** + * Resolve morph map aliases used as keys to their class names. + */ + protected function resolveMorphTypes(array $values): array + { + $resolved = []; + + foreach ($values as $type => $value) { + $resolved[static::getMorphedModel($type) ?? $type] = $value; + } + + return $resolved; + } + /** * Indicate that soft deleted models should be included in the results. * diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index c567c17bee..97aba5aac2 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -1548,6 +1548,8 @@ $alias = $post->getMorphClass(); $class = Relation::getMorphedModel($alias); ``` +Registered aliases may also be used instead of class names as the keys passed to `constrain`, `morphWith`, `morphWithCount`, `loadMorph`, and `loadMorphCount`. For example, `$comments->loadMorph('commentable', ['post' => ['author']])` loads the `author` relationship for the mapped post models. + > [!WARNING] > When adding a "morph map" to your existing application, every morphable `*_type` column value in your database that still contains a fully-qualified class will need to be converted to its "map" name. diff --git a/tests/Integration/Database/EloquentMorphConstrainTest.php b/tests/Integration/Database/EloquentMorphConstrainTest.php index 1cf01fd1ad..27b6656d57 100644 --- a/tests/Integration/Database/EloquentMorphConstrainTest.php +++ b/tests/Integration/Database/EloquentMorphConstrainTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\MorphTo; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Database\Fixtures\Models\Comment; @@ -67,6 +68,29 @@ public function testMorphConstraints(): void $this->assertTrue($comments[2]->commentable->video_visible); $this->assertNull($comments[3]->commentable); } + + public function testMorphConstraintsWithMorphMap(): void + { + Relation::morphMap(['post' => Post::class, 'video' => Video::class]); + + Comment::where('commentable_type', Post::class)->update(['commentable_type' => 'post']); + Comment::where('commentable_type', Video::class)->update(['commentable_type' => 'video']); + + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo): void { + $morphTo->constrain([ + 'post' => fn ($query) => $query->where('post_visible', true), + 'video' => fn ($query) => $query->where('video_visible', true), + ]); + }]) + ->orderBy('id') + ->get(); + + $this->assertTrue($comments[0]->commentable->post_visible); + $this->assertNull($comments[1]->commentable); + $this->assertTrue($comments[2]->commentable->video_visible); + $this->assertNull($comments[3]->commentable); + } } class Post extends Model diff --git a/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php index d5ce103ca8..93c44d7c4e 100644 --- a/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphCountEagerLoadingTest.php @@ -8,6 +8,7 @@ use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\HasMany; use Hypervel\Database\Eloquent\Relations\MorphTo; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Database\Fixtures\Models\Comment; @@ -72,6 +73,21 @@ public function testWithMorphCountLoading(): void $this->assertNull($comments[1]->commentable->views_count); } + public function testWithMorphCountLoadingWithMorphMap(): void + { + Relation::morphMap(['post' => Post::class]); + + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo): void { + $morphTo->morphWithCount(['post' => ['likes']]); + }]) + ->orderBy('id') + ->get(); + + $this->assertEquals(2, $comments[0]->commentable->likes_count); + $this->assertNull($comments[1]->commentable->views_count); + } + public function testWithMorphCountLoadingWithSingleRelation(): void { $comments = Comment::query() diff --git a/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php index d70fb51b3b..6ac2574d54 100644 --- a/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphCountLazyEagerLoadingTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Integration\Database\EloquentMorphCountLazyEagerLoadingTest; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Database\Fixtures\Models\Comment; @@ -52,4 +53,19 @@ public function testLazyEagerLoading(): void $this->assertTrue($comment->relationLoaded('commentable')); $this->assertEquals(2, $comment->commentable->likes_count); } + + public function testLazyEagerLoadingWithMorphMap(): void + { + Relation::morphMap(['post' => Post::class]); + + $comment = Comment::first(); + $comment->loadMorphCount('commentable', ['post' => ['likes']]); + + $this->assertEquals(2, $comment->commentable->likes_count); + + $comments = Comment::all(); + $comments->loadMorphCount('commentable', ['post' => ['likes']]); + + $this->assertEquals(2, $comments[0]->commentable->likes_count); + } } diff --git a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php index f7ebeeed4e..355cab6cf0 100644 --- a/tests/Integration/Database/EloquentMorphEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphEagerLoadingTest.php @@ -7,6 +7,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; use Hypervel\Database\Eloquent\Relations\MorphTo; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Eloquent\SoftDeletes; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; @@ -78,6 +79,23 @@ public function testWithMorphLoading(): void $this->assertInstanceOf(Video::class, $comments[1]->getRelation('commentable')); } + public function testWithMorphLoadingWithMorphMap(): void + { + Relation::morphMap(['post' => Post::class]); + + Comment::where('commentable_type', Post::class)->update(['commentable_type' => 'post']); + + $comments = Comment::query() + ->with(['commentable' => function (MorphTo $morphTo): void { + $morphTo->morphWith(['post' => ['user']]); + }]) + ->orderBy('id') + ->get(); + + $this->assertInstanceOf(Post::class, $comments[0]->commentable); + $this->assertTrue($comments[0]->commentable->relationLoaded('user')); + } + public function testWithMorphLoadingWithSingleRelation(): void { $comments = Comment::query() diff --git a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php index 6897f476b7..009848adf4 100644 --- a/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php +++ b/tests/Integration/Database/EloquentMorphLazyEagerLoadingTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Relations\BelongsTo; +use Hypervel\Database\Eloquent\Relations\Relation; use Hypervel\Database\Schema\Blueprint; use Hypervel\Support\Facades\Schema; use Hypervel\Tests\Database\Fixtures\Models\Comment; @@ -52,6 +53,21 @@ public function testLazyEagerLoading(): void $this->assertTrue($comment->relationLoaded('commentable')); $this->assertTrue($comment->commentable->relationLoaded('user')); } + + public function testLazyEagerLoadingWithMorphMap(): void + { + Relation::morphMap(['post' => Post::class]); + + $comment = Comment::first(); + $comment->loadMorph('commentable', ['post' => ['user']]); + + $this->assertTrue($comment->commentable->relationLoaded('user')); + + $comments = Comment::all(); + $comments->loadMorph('commentable', ['post' => ['user']]); + + $this->assertTrue($comments[0]->commentable->relationLoaded('user')); + } } class Post extends Model diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index 0af60e8471..efab3797fd 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Hypervel\Contracts\Database\Query\Expression; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; use function PHPStan\Testing\assertType; @@ -35,43 +36,37 @@ assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => ['foo' => fn ($q) => $q]], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string', 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => function (Builder $query) { }], 'string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => function (Builder $query) { }])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => ['foo' => fn ($q) => $q]], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => ['foo' => fn ($q) => $q]], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => ['foo' => fn ($q) => $q]], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string'], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => ['foo' => fn ($q) => $q]], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => function (Builder $query) { }], 'string')); /** @@ -91,8 +86,7 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function (Builder $query) { }])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMissing('string')); @@ -104,15 +98,11 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); -}])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorph('string', ['string' => 'foo'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string'])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string' => ['foo' => fn ($q) => $q]])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string' => function ($query) { - // assertType('Hypervel\Database\Eloquent\Relations\Relation<*,*,*>', $query); -}])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMorphCount('string', ['string' => 'foo'])); assertType('bool', $collection->contains(function ($user) { assertType('User', $user); diff --git a/types/Database/Eloquent/Model.php b/types/Database/Eloquent/Model.php index 93e61eb83a..782225c118 100644 --- a/types/Database/Eloquent/Model.php +++ b/types/Database/Eloquent/Model.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Database\Query\Expression; use Hypervel\Database\Eloquent\Attributes\CollectedBy; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\HasCollection; use Hypervel\Database\Eloquent\Model; @@ -55,6 +56,9 @@ function test(User $user, Post $post, Comment $comment, Article $article, Databa assertType('Hypervel\Types\Model\Articles<(int|string), Hypervel\Types\Model\Article>', $article->newCollection([new Article])); assertType('Hypervel\Types\Model\Comments', $comment->newCollection([new Comment])); + assertType('User', $user->load(['posts' => fn ($query) => $query])); + assertType('User', $user->loadMorph('parentable', ['post' => ['comments' => fn ($query) => $query]])); + assertType('User', $user->loadCount(['posts' => fn (Builder $query) => $query->where('published', true)])); assertType('User', $user->loadAggregate('posts', $expression, 'sum')); assertType('User', $user->loadMax('posts', $expression)); assertType('User', $user->loadMin('posts', $expression)); From bd22992770981958125f943b597e2dae06f87530 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:35 +0000 Subject: [PATCH 08/16] Keep discovered and closure commands on their console kernel Port Laravel framework #61931 at c7deadb83979330f6b9cf523d9145b854a949306. Store commands registered through load and command on the kernel instead of process-wide console startup callbacks, so another kernel does not inherit them. Resolve those commands when constructing the console application. Preserve immediate registration on an existing application and retain late commands when that application is rebuilt. Document the registration lifetime on the public command method. Port the closure ownership assertions and extend existing discovery and rebuild tests. These cases reproduce the old leakage and missing rebuilt commands. Console/kernel tests, package-mode Testbench, affected parallel tests, formatting and static analysis pass. Regenerating the Artisan facade produced no changes. --- src/foundation/src/Console/Kernel.php | 23 +++++++++++++++-------- tests/Foundation/Console/KernelTest.php | 13 +++++++++++++ 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/src/foundation/src/Console/Kernel.php b/src/foundation/src/Console/Kernel.php index 54c7271700..9d13bc7bc6 100644 --- a/src/foundation/src/Console/Kernel.php +++ b/src/foundation/src/Console/Kernel.php @@ -80,6 +80,13 @@ class Kernel implements KernelContract */ protected array $loadedPaths = []; + /** + * The commands registered through the "load" and "command" methods. + * + * @var array|SymfonyCommand> + */ + protected array $kernelCommands = []; + /** * All of the registered command duration handlers. */ @@ -345,15 +352,17 @@ protected function commands(): void /** * Register a Closure based command with the application. + * + * Boot or tests only. Commands remain registered for the kernel's lifetime. */ public function command(string $signature, Closure $callback): ClosureCommand { $command = new ClosureCommand($signature, $callback); + $this->kernelCommands[] = $command; - if ($this->commandsLoaded) { - $this->getArtisan()->add($command); - } else { - ConsoleApplication::starting(fn (ConsoleApplication $artisan) => $artisan->add($command)); + // Late registrations must also reach an already-created console application. + if (isset($this->artisan)) { + $this->artisan->add($command); } return $command; @@ -395,9 +404,7 @@ protected function load(array|string $paths): void }; foreach ($this->findCommands($paths)->filter($filterCommands) as $file) { - ConsoleApplication::starting(function (ConsoleApplication $artisan) use ($file, $possibleCommands) { - $artisan->resolve($possibleCommands[$file]); - }); + $this->kernelCommands[] = $possibleCommands[$file]; } } @@ -552,7 +559,7 @@ public function getArtisan(): ApplicationContract $this->bootstrap(); $artisan = (new ConsoleApplication($this->app, $this->events, $this->app->version())) - ->resolveCommands($this->commands) + ->resolveCommands([...$this->kernelCommands, ...$this->commands]) ->setContainerCommandLoader(); if ($this->symfonyDispatcher instanceof EventDispatcher) { diff --git a/tests/Foundation/Console/KernelTest.php b/tests/Foundation/Console/KernelTest.php index ec7b6a8888..0c813ecf74 100644 --- a/tests/Foundation/Console/KernelTest.php +++ b/tests/Foundation/Console/KernelTest.php @@ -258,6 +258,7 @@ public function testCommand(): void $this->assertContains('App\Console\Commands\Discovery\ExampleCommand', $commands); $this->assertContains('App\Console\Commands\Discovery\ExampleCommandTest', $commands); $this->assertNotContains('App\Console\Commands\Discovery\ExampleCommandUnitTest', $commands); + $this->assertFalse((new Kernel($this->app, $this->app->make('events')))->getArtisan()->has('example')); } finally { $loader->unregister(); $files->deleteDirectory($directory); @@ -346,6 +347,8 @@ public function testClearingDirectlyConstructedArtisanLetsTheNextAccessRebuildIt { $kernel = $this->app->make(KernelContract::class); $first = $kernel->getArtisan(); + $kernel->command('kernel-test-late-command', fn (): int => 0); + $this->assertTrue($first->has('kernel-test-late-command')); $kernel->setArtisan(null); @@ -355,6 +358,7 @@ public function testClearingDirectlyConstructedArtisanLetsTheNextAccessRebuildIt $this->assertNotSame($first, $fresh); $this->assertSame($fresh, $this->app->make(ConsoleApplicationContract::class)); + $this->assertTrue($fresh->has('kernel-test-late-command')); } public function testReportExceptionDelegatesToExceptionHandler(): void @@ -533,6 +537,15 @@ public function testFindCommandReturnsTheSameInstanceOnSubsequentCalls(): void $this->assertSame(1, KernelTestLazyCommand::$constructionAttempts); } + public function testClosureCommandsAreOnlyRegisteredWithTheKernelThatDefinedThem(): void + { + $kernel = $this->makeKernel(); + $kernel->command('kernel-test-closure-command', fn (): int => 0); + + $this->assertTrue($kernel->getArtisan()->has('kernel-test-closure-command')); + $this->assertFalse($this->makeKernel()->getArtisan()->has('kernel-test-closure-command')); + } + /** * Create a console kernel. */ From b0e13d98207cc252cd467f451c10e61f5028a113 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:45 +0000 Subject: [PATCH 09/16] Keep escaped user text inside secured Markdown links Port Laravel framework #61755 at d73760a87900a7425d16313a3e568e46b9e3113b. Escape backslashes and closing brackets alongside existing delimiters, using simultaneous replacements so one replacement cannot be processed again. Decode secured output after rendering without changing the ordinary encoding path. Retain render-local encoders, view namespaces and concurrent rendering isolation. Port the escaped-link and image datasets, link-text template fixture and parser cases, preserving the stronger formatted-subcopy assertion already present. The template regressions fail before the correction. Complete changed files, Markdown unit and coroutine tests, affected parallel tests, formatting and full static analysis pass. --- src/mail/src/Markdown.php | 13 +++-- .../Fixtures/link-with-template.blade.php | 4 ++ .../Mail/MailableWithSecuredEncodingTest.php | 47 +++++++++++++++++++ tests/Integration/Mail/MarkdownParserTest.php | 7 +++ 4 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 tests/Integration/Mail/Fixtures/link-with-template.blade.php diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php index 3655c7bc1c..59c7903f79 100644 --- a/src/mail/src/Markdown.php +++ b/src/mail/src/Markdown.php @@ -77,12 +77,14 @@ function () use ($viewFactory, $view, $data) { return EncodedHtmlString::withEncoding( function ($value) { $replacements = [ + '\\' => '\\\\', '[' => '\[', + ']' => '\]', '<' => '<', '>' => '>', ]; - return str_replace(array_keys($replacements), array_values($replacements), $value); + return strtr($value, $replacements); }, $render ); @@ -99,8 +101,12 @@ function ($value) { : 'mail::themes.' . $theme; } + $contents = static::$withSecuredEncoding === true + ? strtr($contents, ['\\\\' => '\\', '\[' => '[', '\]' => ']']) + : str_replace('\[', '[', $contents); + return new HtmlString(($inliner ?: new CssToInlineStyles)->convert( - str_replace('\[', '[', $contents), + $contents, $viewFactory->make($theme, $data)->render() )); } @@ -131,11 +137,12 @@ public static function parse(Stringable|string $text, bool $encoded = false): Ht return new HtmlString(EncodedHtmlString::withEncoding( function ($value) { $replacements = [ + '\\' => '\\\\', '[' => '\[', '<' => '\<', ]; - $html = str_replace(array_keys($replacements), array_values($replacements), $value); + $html = strtr($value, $replacements); return static::converter([ 'html_input' => 'escape', diff --git a/tests/Integration/Mail/Fixtures/link-with-template.blade.php b/tests/Integration/Mail/Fixtures/link-with-template.blade.php new file mode 100644 index 0000000000..ff73d7acbd --- /dev/null +++ b/tests/Integration/Mail/Fixtures/link-with-template.blade.php @@ -0,0 +1,4 @@ +@component('mail::message') +*Hi* [{{ $user->name }}](https://hypervel.org) + +@endcomponent diff --git a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php index 32fb6facfe..2a95a7ec6a 100644 --- a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php @@ -119,5 +119,52 @@ public static function markdownEncodedTemplateDataProvider(): iterable 'Visit https://hypervel.org/docs to browse the documentation', 'Hi Visit <span>https://hypervel.org/docs</span> to browse the documentation', ]; + + yield ['\[Hypervel](https://hypervel.org)', 'Hi [Hypervel](https://hypervel.org)']; + + yield ['!\[Hypervel](https://hypervel.org)', 'Hi ![Hypervel](https://hypervel.org)']; + } + + #[WithMigration] + #[DataProvider('markdownEncodedLinkTextTemplateDataProvider')] + public function testItCanAssertMarkdownEncodedStringUsingTemplateWithLinkText(string $given, string $expected): void + { + $user = UserFactory::new()->create([ + 'name' => $given, + ]); + + $mailable = new class($user) extends Mailable { + public ?string $theme = 'taylor'; + + /** + * Create the mailable for the given user. + */ + public function __construct(public User $user) + { + } + + /** + * Build the mailable. + */ + public function build(): static + { + return $this->markdown('link-with-template'); + } + }; + + $mailable->assertSeeInHtml($expected, false); + $mailable->assertDontSeeInHtml('https://example.com"', false); + } + + /** + * Provide user text that must stay inside the template's link. + */ + public static function markdownEncodedLinkTextTemplateDataProvider(): iterable + { + yield ['Taylor', 'Taylor']; + + yield ['Taylor](https://example.com)', 'Taylor](https://example.com)']; + + yield ['Taylor\](https://example.com)', 'Taylor](https://example.com)']; } } diff --git a/tests/Integration/Mail/MarkdownParserTest.php b/tests/Integration/Mail/MarkdownParserTest.php index ba90bedc4a..55666baf6c 100644 --- a/tests/Integration/Mail/MarkdownParserTest.php +++ b/tests/Integration/Mail/MarkdownParserTest.php @@ -71,6 +71,13 @@ public static function markdownEncodedDataProvider(): iterable '

Visit <span>https://hypervel.org/docs</span> to browse the documentation

', ]; + yield [new EncodedHtmlString('\[Hypervel](https://hypervel.org)'), '

\[Hypervel](https://hypervel.org)

']; + + yield [ + new EncodedHtmlString('!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)'), + '

!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)

', + ]; + yield [ new EncodedHtmlString(new HtmlString('Visit https://hypervel.org/docs to browse the documentation')), '

Visit https://hypervel.org/docs to browse the documentation

', From 4a0d19f322c571e80a9eb53120c7b054e3941b57 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:30:59 +0000 Subject: [PATCH 10/16] Add pooled Vitess analytical workload scopes Port Laravel framework #61932 at 68e78fe401c8c2d71da8b8c06bcd585b6da4339c. Add isVitess and olap to MySqlConnection, applying the analytical workload to both configured endpoints and restoring OLTP after the callback. Other MySQL servers execute the callback unchanged. Use the existing session pin to prevent early release during external waits. Nested calls share the outer scope. Reconnects reapply the workload before continuing, and a failed reconnect leaves lazy resolvers that preserve both workload and read routing on the next access. Partial setup or failed restoration marks the physical sessions unusable through the existing pool state. Normal query execution gains no hooks or session synchronization machinery. Port the upstream tests and documentation, then cover nesting, sticky reads, successful retry, failed reconnects, raw handle access, partial setup and cancellation cleanup. Connection tests, affected parallel tests, formatting and full static analysis pass. Vitess switching semantics were checked against its official source tests; this change has not been exercised against a live Vitess server. --- src/database/src/MySqlConnection.php | 116 +++++++++++++ src/docs/database.md | 26 +++ tests/Database/DatabaseConnectionTest.php | 195 ++++++++++++++++++++++ 3 files changed, 337 insertions(+) diff --git a/src/database/src/MySqlConnection.php b/src/database/src/MySqlConnection.php index b423778155..4e621dcb5d 100755 --- a/src/database/src/MySqlConnection.php +++ b/src/database/src/MySqlConnection.php @@ -17,6 +17,7 @@ use PDO; use PDOException; use RuntimeException; +use Throwable; class MySqlConnection extends PdoConnection { @@ -25,6 +26,11 @@ class MySqlConnection extends PdoConnection */ protected string|int|null $lastInsertId = null; + /** + * Whether an OLAP workload scope owns the current session. + */ + protected bool $usingOlapWorkload = false; + /** * Get a human-readable name for the given connection driver. */ @@ -67,6 +73,108 @@ public function insert(string $query, array $bindings = [], ?string $sequence = }); } + /** + * Execute the given callback using OLAP workload. + * + * @template TReturn + * + * @param Closure($this): TReturn $callback + * @return TReturn + */ + public function olap(Closure $callback): mixed + { + if ($this->usingOlapWorkload || ! $this->isVitess()) { + return $callback($this); + } + + // External waits must not return the OLAP session to another borrower. + return $this->withPinnedSession(function () use ($callback) { + $this->setWorkload('olap'); + $this->usingOlapWorkload = true; + + try { + return $callback($this); + } finally { + $this->usingOlapWorkload = false; + $this->setWorkload('oltp'); + } + }); + } + + /** + * Set the Vitess workload for the connection. + * + * @param 'olap'|'oltp' $workload + */ + protected function setWorkload(string $workload): void + { + try { + $this->run("set workload = '{$workload}'", [], function ($query) { + if ($this->pretending()) { + return true; + } + + $this->getPdo()->exec($query); + + if ($this->getRawReadPdo() !== null) { + // Configure the read endpoint even when sticky reads use the writer. + $this->resolveReadPdo()->exec($query); + } + + return true; + }); + } catch (Throwable $exception) { + // Partial setup or restoration must never return an OLAP session to the pool. + foreach ([$this->getRawPdo(), $this->getRawReadPdo()] as $pdo) { + if ($pdo instanceof PDO) { + $this->markSessionStateUnknown($pdo); + } + } + + throw $exception; + } + } + + /** + * Reconnect to the database, preserving an active OLAP workload. + */ + public function reconnect(): mixed + { + if (! $this->usingOlapWorkload) { + return parent::reconnect(); + } + + $hasReadEndpoint = $this->getRawReadPdo() !== null; + + // A failed SET may itself retry; it must not recursively reapply the workload. + $this->usingOlapWorkload = false; + + try { + $result = parent::reconnect(); + $this->setWorkload('olap'); + + return $result; + } catch (Throwable $exception) { + // Retain lazy recovery without letting the lease reopen an OLTP session. + $this->setPdo(fn (): PDO => $this->recoverOlapSession(false)) + ->setReadPdo($hasReadEndpoint ? fn (): PDO => $this->recoverOlapSession(true) : null); + + throw $exception; + } finally { + $this->usingOlapWorkload = true; + } + } + + /** + * Recover a session after a failed reconnect within an OLAP scope. + */ + private function recoverOlapSession(bool $read): PDO + { + $this->reconnect(); + + return $this->resolveRawPdo($read); + } + /** * Escape a binary value for safe SQL embedding. */ @@ -176,6 +284,14 @@ public function isMaria(): bool return str_contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), 'MariaDB'); } + /** + * Determine if the connected database is a Vitess database. + */ + public function isVitess(): bool + { + return Str::contains($this->getPdo()->getAttribute(PDO::ATTR_SERVER_VERSION), ['Vitess', 'PlanetScale'], ignoreCase: true); + } + /** * Get the server version for the connection. */ diff --git a/src/docs/database.md b/src/docs/database.md index 70fb82e3fc..10ca985ec0 100644 --- a/src/docs/database.md +++ b/src/docs/database.md @@ -18,6 +18,7 @@ - [Static Analysis](#static-analysis) - [Running SQL Queries](#running-queries) - [Using Multiple Database Connections](#using-multiple-database-connections) + - [Running Analytical Queries on Vitess](#running-analytical-queries-on-vitess) - [Listening for Query Events](#listening-for-query-events) - [Monitoring Cumulative Query Time](#monitoring-cumulative-query-time) - [Database Transactions](#database-transactions) @@ -870,6 +871,31 @@ The driver-neutral `Connection` class does not expose PDO methods because native If you are building a low-level PDO extension, you may use `getRawPdo` to access the connection parameter without resolving or configuring it. This value may be a PDO, a lazy connection closure, or `null`. Applications should use `getPdo` or Hypervel's query APIs instead. + +### Running Analytical Queries on Vitess + +When using a Vitess database, such as PlanetScale, you may use the `olap` method to execute analytical queries using the OLAP workload: + +```php +use Hypervel\Support\Facades\DB; + +$totals = DB::connection('mysql')->olap(fn ($connection) => $connection->table('orders') + ->selectRaw('customer_id, sum(total) as total') + ->groupBy('customer_id') + ->get() +); +``` + +Hypervel sets the workload on both the read and write connections, then resets it to OLTP when the callback finishes, even if an exception is thrown. The `olap` method returns the callback's result. On MySQL connections that are not using Vitess, the callback is executed without changing the workload. + +The connection remains [pinned](#releasing-and-pinning-connections) until the callback finishes, including while waiting for external services. Complete any lazy queries inside the callback. Nested `olap` calls share the outer scope, and a recovered connection receives the OLAP workload before queries resume. + +You may determine whether a MySQL connection is using Vitess via the `isVitess` method: + +```php +$isVitess = DB::connection('mysql')->isVitess(); +``` + ### Listening for Query Events diff --git a/tests/Database/DatabaseConnectionTest.php b/tests/Database/DatabaseConnectionTest.php index 3c3eea5b03..a833dffc26 100755 --- a/tests/Database/DatabaseConnectionTest.php +++ b/tests/Database/DatabaseConnectionTest.php @@ -10,6 +10,7 @@ use Exception; use Generator; use Hypervel\Contracts\Events\Dispatcher; +use Hypervel\Coroutine\Coroutine; use Hypervel\Database\Connection; use Hypervel\Database\DatabaseTransactionsManager; use Hypervel\Database\DeadlockException; @@ -41,6 +42,7 @@ use Mockery as m; use PDO; use PDOException; +use PDOStatement; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestWith; use ReflectionClass; @@ -2359,6 +2361,199 @@ public function testPretendRestoresQueryLoggingWhenTheCallbackFails(): void $this->assertFalse($connection->pretending()); } + public function testOlapRunsCallbackUsingOlapWorkloadOnVitess(): void + { + $pdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['getAttribute'])->getMock(); + $pdo->expects($this->once())->method('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->willReturn('8.0.34-PlanetScale'); + + $queries = (new MySqlConnection($pdo))->pretend(function ($connection) { + $connection->olap(fn ($connection) => $connection->select('foo bar')); + }); + + $this->assertSame([ + "set workload = 'olap'", + 'foo bar', + "set workload = 'oltp'", + ], array_column($queries, 'query')); + } + + public function testOlapSetsWorkloadOnReadAndWriteConnections(): void + { + $writePdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['getAttribute', 'exec'])->getMock(); + $writePdo->expects($this->once())->method('getAttribute')->willReturn('8.0.34-PlanetScale'); + $writePdo->expects($this->exactly(2))->method('exec'); + + $readPdo = $this->getMockBuilder(PDOStub::class)->onlyMethods(['exec'])->getMock(); + $readPdo->expects($this->exactly(2))->method('exec'); + + $connection = (new MySqlConnection($writePdo, config: ['sticky' => true]))->setReadPdo($readPdo); + $connection->recordsHaveBeenModified(); + + $this->assertSame('foo', $connection->olap(function () use ($connection): string { + $this->assertTrue($connection->hasPinnedSession()); + + return 'foo'; + })); + } + + public function testOlapLeavesOtherMySqlServersUnchanged(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34'); + $pdo->shouldNotReceive('exec'); + $connection = new MySqlConnection($pdo); + + $this->assertSame($connection, $connection->olap(static fn ($connection) => $connection)); + } + + public function testNestedOlapScopesRestoreTheWorkloadOnlyAfterTheOuterCallback(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $connection = new MySqlConnection($pdo); + + $queries = $connection->pretend(function () use ($connection): void { + $connection->olap(function () use ($connection): void { + $connection->olap(fn () => $connection->select('inner query')); + $connection->select('outer query'); + }); + }); + + $this->assertSame([ + "set workload = 'olap'", + 'inner query', + 'outer query', + "set workload = 'oltp'", + ], array_column($queries, 'query')); + } + + public function testOlapReappliesTheWorkloadBeforeRetryingALostConnection(): void + { + $original = m::mock(PDO::class); + $original->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $original->expects('exec')->with("set workload = 'olap'"); + $original->expects('prepare')->with('select total')->andThrow(new PDOException('server has gone away')); + $replacement = m::mock(PDO::class); + $replacement->expects('exec')->with("set workload = 'olap'")->ordered(); + $statement = m::mock(PDOStatement::class); + $statement->expects('setFetchMode')->with(PDO::FETCH_OBJ); + $statement->expects('execute'); + $statement->expects('fetchAll')->andReturn([42]); + $replacement->expects('prepare')->with('select total')->andReturn($statement)->ordered(); + $replacement->expects('exec')->with("set workload = 'oltp'")->ordered(); + $connection = new MySqlConnection($original); + $reconnects = 0; + $connection->setReconnector(static function (MySqlConnection $connection) use ($replacement, &$reconnects): void { + ++$reconnects; + $connection->setPdo($replacement); + }); + + $this->assertSame([42], $connection->olap(fn () => $connection->select('select total'))); + $this->assertSame(1, $reconnects); + } + + #[TestWith([false], 'raw writer')] + #[TestWith([true], 'read query')] + public function testOlapRecoversItsWorkloadAndEndpointAfterAFailedReconnect(bool $read): void + { + $original = m::mock(PDO::class); + $original->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $original->expects('exec')->with("set workload = 'olap'"); + $originalRead = m::mock(PDO::class); + $originalRead->expects('exec')->with("set workload = 'olap'"); + $replacement = m::mock(PDO::class); + $replacement->expects('exec')->with("set workload = 'olap'")->globally()->ordered(); + $replacementRead = m::mock(PDO::class); + $replacementRead->expects('exec')->with("set workload = 'olap'")->globally()->ordered(); + + if ($read) { + $statement = m::mock(PDOStatement::class); + $statement->expects('setFetchMode')->with(PDO::FETCH_OBJ); + $statement->expects('execute'); + $statement->expects('fetchAll')->andReturn([42]); + $replacementRead->expects('prepare')->with('select total')->andReturn($statement)->globally()->ordered(); + $replacement->shouldNotReceive('prepare'); + } + + $replacement->expects('exec')->with("set workload = 'oltp'")->globally()->ordered(); + $replacementRead->expects('exec')->with("set workload = 'oltp'")->globally()->ordered(); + $connection = (new MySqlConnection($original))->setReadPdo($originalRead); + $failure = new RuntimeException('Database unavailable.'); + $reconnects = 0; + $connection->setReconnector(static function (MySqlConnection $connection) use ($replacement, $replacementRead, $failure, &$reconnects): void { + $connection->setPdo(static fn (): PDO => $replacement) + ->setReadPdo(static fn (): PDO => $replacementRead); + + if (++$reconnects === 1) { + throw $failure; + } + }); + + $connection->olap(function () use ($connection, $failure, $replacement, $read): void { + try { + $connection->reconnect(); + $this->fail('Expected the reconnect to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + if ($read) { + $this->assertSame([42], $connection->select('select total')); + } else { + $this->assertSame($replacement, $connection->getPdo()); + } + }); + + $this->assertSame(2, $reconnects); + $this->assertSame($replacementRead, $connection->getReadPdo()); + } + + public function testPartialOlapSetupPreventsReusingEitherSession(): void + { + $writePdo = m::mock(PDO::class); + $writePdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $writePdo->expects('exec')->with("set workload = 'olap'"); + $readPdo = m::mock(PDO::class); + $failure = new PDOException('Workload change refused.'); + $readPdo->expects('exec')->with("set workload = 'olap'")->andThrow($failure); + $connection = (new MySqlConnection($writePdo))->setReadPdo($readPdo); + + try { + $connection->olap(fn () => $this->fail('A partially configured callback must not run.')); + $this->fail('Expected the workload change to fail.'); + } catch (QueryException $exception) { + $this->assertSame($failure, $exception->getPrevious()); + } + + $this->assertFalse((new MySqlConnection($writePdo))->isReusable()); + $this->assertFalse((new MySqlConnection($readPdo))->isReusable()); + } + + public function testOlapRestorationFailurePreservesCancellationAndDiscardsTheSession(): void + { + $pdo = m::mock(PDO::class); + $pdo->expects('getAttribute')->with(PDO::ATTR_SERVER_VERSION)->andReturn('8.0.34-vitess'); + $pdo->expects('exec')->with("set workload = 'olap'"); + $connection = new MySqlConnection($pdo); + $cancellation = new CanceledException('Analytics canceled.'); + $failure = new RuntimeException('Restore hook failed.'); + $connection->beforeExecuting(static function (string $query) use ($failure): void { + if ($query === "set workload = 'oltp'") { + throw $failure; + } + }); + + try { + $connection->olap(static fn (): never => throw $cancellation); + $this->fail('Expected the restoration failure.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + $this->assertTrue(Coroutine::causedByCancellation($exception)); + } + + $this->assertFalse($connection->isReusable()); + } + public function testSchemaBuilderCanBeCreated(): void { $connection = $this->getMockConnection(); From ff769f87e963c6d4420a1e5b0da104cd5e9289c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:36:28 +0000 Subject: [PATCH 11/16] Describe supported aggregate relationship constraints precisely Narrow aggregate-loading annotations to relation names, builder callbacks and empty constraint arrays. Nonempty nested relationship arrays flatten into dotted method names that aggregate loading cannot execute; empty arrays remain valid and compile to unconstrained relation aggregates. Apply the same type to the Model and Collection aggregate methods. Replace the existing invalid nested type examples with empty-constraint examples, preserving coverage for accepted input without changing runtime behavior. The old nested examples fail static analysis under the corrected types. The updated type fixtures, full source/type/skeleton analysis, formatting and diff checks pass. --- src/database/src/Eloquent/Collection.php | 14 +++++++------- src/database/src/Eloquent/Model.php | 14 +++++++------- types/Database/Eloquent/Collection.php | 14 +++++++------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 7a032db5bf..09a2d1b308 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -127,7 +127,7 @@ public function load(array|string $relations): static /** * Load a set of aggregations over relationship's column onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations * * @throws MissingAttributeException */ @@ -189,7 +189,7 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Load a set of relationship counts onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadCount(array|string $relations): static { @@ -199,7 +199,7 @@ public function loadCount(array|string $relations): static /** * Load a set of relationship's max column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -209,7 +209,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's min column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -219,7 +219,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's column summations onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -229,7 +229,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Load a set of relationship's average column values onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -239,7 +239,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Load a set of related existences onto the collection. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadExists(array|string $relations): static { diff --git a/src/database/src/Eloquent/Model.php b/src/database/src/Eloquent/Model.php index 7fda1a3bae..1a86c69d28 100644 --- a/src/database/src/Eloquent/Model.php +++ b/src/database/src/Eloquent/Model.php @@ -1033,7 +1033,7 @@ public function loadMissing(array|string $relations): static /** * Eager load relation's column aggregations on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAggregate(array|string $relations, Expression|string $column, ?string $function = null): static { @@ -1045,7 +1045,7 @@ public function loadAggregate(array|string $relations, Expression|string $column /** * Eager load relation counts on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadCount(array|string $relations): static { @@ -1057,7 +1057,7 @@ public function loadCount(array|string $relations): static /** * Eager load relation max column values on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMax(array|string $relations, Expression|string $column): static { @@ -1067,7 +1067,7 @@ public function loadMax(array|string $relations, Expression|string $column): sta /** * Eager load relation min column values on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadMin(array|string $relations, Expression|string $column): static { @@ -1077,7 +1077,7 @@ public function loadMin(array|string $relations, Expression|string $column): sta /** * Eager load relation's column summations on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadSum(array|string $relations, Expression|string $column): static { @@ -1087,7 +1087,7 @@ public function loadSum(array|string $relations, Expression|string $column): sta /** * Eager load relation average column values on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadAvg(array|string $relations, Expression|string $column): static { @@ -1097,7 +1097,7 @@ public function loadAvg(array|string $relations, Expression|string $column): sta /** * Eager load related model existence values on the model. * - * @param array): mixed)|string>|string $relations + * @param array): mixed)|string>|string $relations */ public function loadExists(array|string $relations): static { diff --git a/types/Database/Eloquent/Collection.php b/types/Database/Eloquent/Collection.php index efab3797fd..ff3b74d997 100644 --- a/types/Database/Eloquent/Collection.php +++ b/types/Database/Eloquent/Collection.php @@ -34,38 +34,38 @@ assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => ['foo' => fn ($q) => $q]], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string'], 'string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAggregate(['string' => function (Builder $query) { }], 'string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string'])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => ['foo' => fn ($q) => $q]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => []])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadCount(['string' => function (Builder $query) { }])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string'], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => ['foo' => fn ($q) => $q]], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMax(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string'], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => ['foo' => fn ($q) => $q]], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadMin(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string'], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => ['foo' => fn ($q) => $q]], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadSum(['string' => function (Builder $query) { }], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg('string', 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string'], 'string')); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => ['foo' => fn ($q) => $q]], 'string')); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => []], 'string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadAvg(['string' => function (Builder $query) { }], 'string')); @@ -85,7 +85,7 @@ function assertEloquentCollectionAggregateExpressionTypes(Collection $collection assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists('string')); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string'])); -assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => ['foo' => fn ($q) => $q]])); +assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => []])); assertType('Hypervel\Database\Eloquent\Collection', $collection->loadExists(['string' => function (Builder $query) { }])); From e20539cc5279f44713867d373c36d470583144c2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:44:28 +0000 Subject: [PATCH 12/16] Preserve scoped casts and cursor query behavior Keep the model selected by global scopes when hydrating cursor rows and plucking values, and keep relationship creation aligned with query-time casts without mutating the original relationship. Reuse one cursor hydration prototype as introduced by Laravel #62001 (43a4488e5fe6e53e1d5619a8a4425b726bd4c853), creating it after scopes so their casts survive. This completes the cast isolation behavior introduced by Laravel #61883. Prepare cursor bindings and results through one scoped builder. Retain the cursor conditions on the caller as well, preserve callback ownership, and inspect aliases through the underlying grammar without running scopes again. Group explicit OR branches so they cannot return rows before the page boundary, including union members. Verified scoped casts, retained query behavior, scope counts, binary pivot ordering and forward/backward pagination. Database and affected framework tests pass, with binary/cursor integration checked on SQLite, PostgreSQL and MySQL. Formatting and full static analysis pass. --- src/database/src/Concerns/BuildsQueries.php | 29 +++++++++--- src/database/src/Eloquent/Builder.php | 44 +++++++++++++----- .../src/Eloquent/Relations/Relation.php | 13 ++++++ src/database/src/Query/Builder.php | 15 +++++++ .../DatabaseEloquentIntegrationTest.php | 33 ++++++++++++++ .../DatabaseEloquentWithCastsTest.php | 28 ++++++++++++ ...atabaseEloquentAsBinaryIntegrationTest.php | 38 ++++++++++++++++ .../Database/EloquentCursorPaginateTest.php | 45 +++++++++++++++++-- 8 files changed, 223 insertions(+), 22 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index 93b7dd699e..148bc5879a 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -508,6 +508,8 @@ protected function paginateUsingCursor( } $orders = $this->ensureOrderForCursorPagination(! is_null($cursor) && $cursor->pointsToPreviousItems()); + $this->limit($perPage + 1); + $builder = $this; if (! is_null($cursor)) { $query = $this instanceof Builder ? $this->getQuery() : $this; @@ -525,6 +527,7 @@ protected function paginateUsingCursor( $query->columns = $originalColumns; } + $builder = $this->getBuilderForCursorPage(); $comparisons = []; $bindings = []; foreach ($orders as $order) { @@ -533,23 +536,35 @@ protected function paginateUsingCursor( $order['original'] = $original !== $column && Str::contains($original, ['(', ')']) ? new Expression($original) : $original; $comparisons[] = $order; - $bindings[$column] = $this->getCursorParameterBinding($cursor, $column); + $bindings[$column] = $builder->getCursorParameterBinding($cursor, $column); } // Members are resolved by their output positions during SELECT preparation; // keep their select/join bindings and never rewrite a shared union builder. $query->addCursorPaginationConditions($comparisons, $bindings); - } - $this->limit($perPage + 1); + $pageQuery = $builder instanceof Builder ? $builder->getQuery() : $builder; + if ($pageQuery !== $query) { + // Keep the caller's page conditions as well as the scoped execution copy's. + $pageQuery->addCursorPaginationConditions($comparisons, $bindings); + } + } - return $this->cursorPaginator($this->get($columns), $perPage, $cursor, [ + return $this->cursorPaginator($builder->get($columns), $perPage, $cursor, [ 'path' => Paginator::resolveCurrentPath(), 'cursorName' => $cursorName, 'parameters' => $orders->pluck('column')->toArray(), ]); } + /** + * Get the builder that owns cursor binding and page execution. + */ + protected function getBuilderForCursorPage(): static + { + return $this; + } + /** * Get a cursor parameter for database binding. */ @@ -565,15 +580,15 @@ protected function getCursorParameterBinding(Cursor $cursor, string $parameter): */ protected function getOriginalColumnNameForCursorPagination(QueryBuilder|Builder $builder, string $parameter): string { - $columns = $builder instanceof Builder ? $builder->getQuery()->getColumns() : $builder->getColumns(); + $query = $builder instanceof Builder ? $builder->getQuery() : $builder; - foreach ($columns as $column) { + foreach ($query->getColumns() as $column) { if (($position = strripos($column, ' as ')) !== false) { $original = substr($column, 0, $position); $alias = substr($column, $position + 4); - if ($parameter === $alias || $builder->getGrammar()->wrap($parameter) === $alias) { + if ($parameter === $alias || $query->getGrammar()->wrap($parameter) === $alias) { return $original; } } diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 84feff07c6..63297666e1 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1038,10 +1038,14 @@ public function applyAfterQueryCallbacks(BaseCollection $result): BaseCollection */ public function cursor(): LazyCollection { - return $this->applyScopes()->query->cursor()->map(function ($record) { - $model = $this->newModelInstance()->newFromBuilder($record); + $builder = $this->applyScopes(); + // Scopes can replace the model when adding query-time casts. + $instance = $builder->newModelInstance(); + + return $builder->query->cursor()->map(function ($record) use ($instance) { + $model = $instance->newFromBuilder($record); - return $this->applyAfterQueryCallbacks($this->newModelInstance()->newCollection([$model]))->first(); + return $this->applyAfterQueryCallbacks($instance->newCollection([$model]))->first(); })->reject(fn ($model) => is_null($model)); } @@ -1062,28 +1066,31 @@ protected function enforceOrderBy(): void */ public function pluck(Expression|string $column, ?string $key = null): BaseCollection { + $builder = $this->applyScopes(); + $model = $builder->getModel(); + if ($column instanceof Expression) { // Casts and accessors use the returned field name, which cannot be // recovered from the values alone or reliably inferred from raw SQL. - [$results, $column] = $this->toBase()->pluckWithColumn($column, $key); + [$results, $column] = $builder->query->pluckWithColumn($column, $key); } else { - $results = $this->toBase()->pluck($column, $key); + $results = $builder->query->pluck($column, $key); - $column = Str::after($column, "{$this->model->getTable()}."); + $column = Str::after($column, "{$model->getTable()}."); } // If the model has a mutator for the requested column, we will spin through // the results and mutate the values so that the mutated version of these // columns are returned as you would expect from these Eloquent models. - if (is_null($column) || (! $this->model->hasAnyGetMutator($column) - && ! $this->model->hasCast($column) - && ! in_array($column, $this->model->getDates()))) { + if (is_null($column) || (! $model->hasAnyGetMutator($column) + && ! $model->hasCast($column) + && ! in_array($column, $model->getDates()))) { return $this->applyAfterQueryCallbacks($results); } return $this->applyAfterQueryCallbacks( - $results->map(function ($value) use ($column) { - return $this->model->newFromBuilder([$column => $value])->{$column}; + $results->map(function ($value) use ($column, $model) { + return $model->newFromBuilder([$column => $value])->{$column}; }) ); } @@ -1194,6 +1201,21 @@ protected function ensureOrderForCursorPagination(bool $shouldReverse = false): ->values(); } + /** + * Get the builder that owns cursor binding and page execution. + */ + protected function getBuilderForCursorPage(): static + { + $builder = $this->applyScopes(); + + // Scope-added casts must encode the cursor and hydrate the page together. + // Avoid a second scope pass in get(), preserving its original callback ownership. + $builder->scopes = []; + $builder->afterQueryCallbacks = $this->afterQueryCallbacks; + + return $builder; + } + /** * Get a cursor parameter for database binding. */ diff --git a/src/database/src/Eloquent/Relations/Relation.php b/src/database/src/Eloquent/Relations/Relation.php index 467b11f5c9..690adb0283 100644 --- a/src/database/src/Eloquent/Relations/Relation.php +++ b/src/database/src/Eloquent/Relations/Relation.php @@ -422,6 +422,19 @@ public function getRelated(): Model return $this->related; } + /** + * Apply query-time casts to the related model instance. + */ + public function withCasts(array $casts): static + { + $this->query->withCasts($casts); + + // Creation helpers use this model, so follow the query's isolated cast changes. + $this->related = $this->query->getModel(); + + return $this; + } + /** * Get the class name of the related model. * diff --git a/src/database/src/Query/Builder.php b/src/database/src/Query/Builder.php index e98d5643c5..3b08ea8b38 100644 --- a/src/database/src/Query/Builder.php +++ b/src/database/src/Query/Builder.php @@ -3397,6 +3397,7 @@ public function prepareCursorOrdering(): void */ public function addCursorPaginationConditions(array $orders, array $bindings): static { + $this->groupOrWheresForKeyset(); $offset = count($this->bindings['where']); $this->addCursorComparisons($orders, $bindings, 0); $this->markKeysetCondition($offset); @@ -3406,6 +3407,18 @@ public function addCursorPaginationConditions(array $orders, array $bindings): s return $this; } + /** + * Keep existing "or" conditions from bypassing the page boundary. + */ + protected function groupOrWheresForKeyset(): void + { + if (array_any($this->wheres, static fn (array $where): bool => str_contains($where['boolean'], 'or'))) { + $existing = $this->forNestedWhere(); + $existing->wheres = $this->wheres; + $this->wheres = [['type' => 'Nested', 'query' => $existing, 'boolean' => 'and']]; + } + } + /** * Add the ordered comparisons for a compound cursor. */ @@ -3771,6 +3784,8 @@ protected function applyUnionKeysetConditions(self $root, array $conditions, arr */ protected function appendKeysetConditions(array $conditions, array $mapping = [], bool $output = false): static { + $this->groupOrWheresForKeyset(); + foreach ($conditions as $condition) { $this->wheres[] = $this->rebindKeysetColumns($condition['where'], $mapping, $output); $this->addBinding($condition['bindings'], 'where'); diff --git a/tests/Database/DatabaseEloquentIntegrationTest.php b/tests/Database/DatabaseEloquentIntegrationTest.php index f42e851e41..7fda64c621 100644 --- a/tests/Database/DatabaseEloquentIntegrationTest.php +++ b/tests/Database/DatabaseEloquentIntegrationTest.php @@ -1978,6 +1978,24 @@ public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverChunkM $this->assertEquals($friend->id, $results->first()->pivot->friend_id); } + public function testCursorDoesNotCreateExtraModelInstancesForEachRecord(): void + { + User::insert([ + ['email' => 'taylorotwell@gmail.com'], + ['email' => 'abigailotwell@gmail.com'], + ['email' => 'foo@gmail.com'], + ]); + + $query = UserWithInstanceCount::query(); + + UserWithInstanceCount::$instances = 0; + + $models = $query->cursor()->all(); + + $this->assertCount(3, $models); + $this->assertSame(4, UserWithInstanceCount::$instances); + } + public function testBelongsToManyRelationshipModelsAreProperlyHydratedOverCursorRequest(): void { $user = User::create(['email' => 'taylorotwell@gmail.com']); @@ -3243,6 +3261,21 @@ public function achievements() } } +class UserWithInstanceCount extends User +{ + public static int $instances = 0; + + /** + * Count model instances created while consuming the cursor. + */ + public function __construct(array $attributes = []) + { + ++static::$instances; + + parent::__construct($attributes); + } +} + class UserWithCustomFriendPivot extends User { public function friends() diff --git a/tests/Database/DatabaseEloquentWithCastsTest.php b/tests/Database/DatabaseEloquentWithCastsTest.php index 38516505bd..4949be1919 100644 --- a/tests/Database/DatabaseEloquentWithCastsTest.php +++ b/tests/Database/DatabaseEloquentWithCastsTest.php @@ -7,9 +7,11 @@ use Carbon\CarbonInterface; use Hypervel\Database\Capsule\Manager as DB; use Hypervel\Database\Connection; +use Hypervel\Database\Eloquent\Builder as EloquentBuilder; use Hypervel\Database\Eloquent\MissingAttributeException; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\Model as Eloquent; +use Hypervel\Database\Query\Expression; use Hypervel\Database\Schema\Builder; use Hypervel\Tests\TestCase; @@ -102,6 +104,32 @@ public function testWithCastsOnClonedQueryDoesNotAffectOriginalQuery(): void $this->assertTrue($default->hasCast('time', 'datetime')); } + public function testGlobalScopeCastsApplyToHydrationAndPlucks(): void + { + Time::query()->insert(['time' => '07:30']); + + $query = Time::query()->withGlobalScope('time', function (EloquentBuilder $builder): void { + $builder->withCasts(['time' => 'string']); + }); + + $this->assertSame('07:30', $query->get()->first()->time); + $this->assertSame('07:30', $query->cursor()->first()->time); + $this->assertSame('07:30', $query->pluck('time')->first()); + $this->assertSame('07:30', $query->pluck(new Expression('time'))->first()); + } + + public function testRelationshipCastsApplyToNewRelatedModelsWithoutChangingTheOriginalRelation(): void + { + Time::query()->insert(['id' => 1, 'time' => '07:30']); + $relation = Time::find(1)->hasMany(Time::class, 'id'); + $castRelation = (clone $relation)->withCasts(['time' => 'string']); + + $this->assertSame($castRelation->getQuery()->getModel(), $castRelation->getRelated()); + $this->assertSame('08:30', $castRelation->make(['time' => '08:30'])->time); + $this->assertSame('09:30', $castRelation->firstOrNew(['time' => '09:30'])->time); + $this->assertInstanceOf(CarbonInterface::class, $relation->make(['time' => '08:30'])->time); + } + public function testThrowsExceptionIfCastableAttributeWasNotRetrievedAndPreventMissingAttributesIsEnabled(): void { Time::create(['time' => now()]); diff --git a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php index c983d4a887..a782e6cf90 100644 --- a/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php +++ b/tests/Integration/Database/DatabaseEloquentAsBinaryIntegrationTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Integration\Database; use Hypervel\Database\BinaryParameter; +use Hypervel\Database\Eloquent\Builder; use Hypervel\Database\Eloquent\Casts\AsBinary; use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Concerns\HasUuids; @@ -287,6 +288,32 @@ public function testBinaryPrimaryKeysCanBeCursorPaginatedInBothDirections(bool $ $this->assertSame([$ids[0], $ids[1]], $previous->pluck('id')->all()); } + public function testCursorPaginationUsesBinaryCastsAddedByGlobalScopes(): void + { + $ids = [ + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + ]; + + foreach ($ids as $id) { + AsBinaryPrimaryKeyModel::create(['id' => $id, 'name' => $id]); + } + + $query = fn () => ScopedAsBinaryPrimaryKeyModel::query()->withGlobalScope('binary-id', function (Builder $builder): void { + $builder->withCasts(['id' => AsBinary::uuid()]); + }); + + $first = $query()->cursorPaginate(1); + $nextQuery = $query(); + $second = $nextQuery->cursorPaginate(1, cursor: $first->nextCursor()->encode()); + + $this->assertSame([$ids[0]], $first->pluck('id')->all()); + $this->assertSame([$ids[1]], $second->pluck('id')->all()); + $this->assertSame([$ids[1]], $nextQuery->get()->pluck('id')->all()); + $this->assertNull($second->nextCursor()); + $this->assertSame([$ids[0]], $query()->cursorPaginate(1, cursor: $second->previousCursor()->encode())->pluck('id')->all()); + } + public function testCompoundCursorPaginationPreservesBinaryEqualityAcrossUnions(): void { foreach ([1, 2, 3] as $id) { @@ -466,6 +493,17 @@ protected function casts(): array } } +class ScopedAsBinaryPrimaryKeyModel extends AsBinaryPrimaryKeyModel +{ + /** + * Leave cast selection to the query's global scope. + */ + protected function casts(): array + { + return []; + } +} + class AsBinaryFactoryPrimaryKeyModel extends AsBinaryPrimaryKeyModel { use HasUuids; diff --git a/tests/Integration/Database/EloquentCursorPaginateTest.php b/tests/Integration/Database/EloquentCursorPaginateTest.php index 040bb522dc..9dd165101a 100644 --- a/tests/Integration/Database/EloquentCursorPaginateTest.php +++ b/tests/Integration/Database/EloquentCursorPaginateTest.php @@ -106,6 +106,35 @@ public function testCursorPaginationWithNumericExpressionProjection(): void $this->assertEquals(1.5, array_first($page->items()[0]->getAttributes())); } + #[TestWith([false], 'single select')] + #[TestWith([true], 'union members')] + public function testCursorBoundaryAppliesToAllOrWhereBranches(bool $union): void + { + TestPost::fillAndInsert([[], [], [], [], []]); + $query = function () use ($union): QueryBuilder { + $query = DB::table('test_posts')->select('id')->where('id', 1); + + if ($union) { + $query->orWhere('id', 3)->unionAll( + DB::table('test_posts')->select('id')->where('id', 4)->orWhere('id', 5) + ); + } else { + $query->orWhere('id', '>=', 3); + } + + return $query->orderBy('id'); + }; + + $first = $query()->cursorPaginate(2); + $second = $query()->cursorPaginate(2, cursor: $first->nextCursor()); + $previous = $query()->cursorPaginate(2, cursor: $second->previousCursor()); + + $this->assertSame([1, 3], $first->pluck('id')->all()); + $this->assertSame([4, 5], $second->pluck('id')->all()); + $this->assertSame([1, 3], $previous->pluck('id')->all()); + $this->assertNull($second->nextCursor()); + } + public function testPaginationWithUnion(): void { TestPost::fillAndInsert([ @@ -497,14 +526,22 @@ public function testCursorPaginationUsesTheBinaryPivotCast(): void ]); } - $relation = fn (): BelongsToMany => $user->favoritePosts() - ->using(CursorBinaryPivot::class) - ->withPivot('position') - ->orderBy('test_post_user.position'); + $scopeCalls = 0; + $relation = function () use ($user, &$scopeCalls): BelongsToMany { + return $user->favoritePosts() + ->using(CursorBinaryPivot::class) + ->withPivot('position') + ->withGlobalScope('visible', function (Builder $builder) use (&$scopeCalls): void { + ++$scopeCalls; + $builder->whereNotNull('test_posts.id'); + }) + ->orderBy('test_post_user.position'); + }; $first = $relation()->cursorPaginate(2); $next = $relation()->cursorPaginate(2, cursor: $first->nextCursor()->encode()); $this->assertSame([3], $next->pluck('id')->all()); + $this->assertSame(2, $scopeCalls); } public function testRelationsAcceptCursorObjects(): void From 5a349682c4338dbf1440bd88eac4c8ee9e8e39ac Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:44:37 +0000 Subject: [PATCH 13/16] Keep delegated scoped services out of facade caches A transient binding or factory can return a scoped service even though the accessor itself is not scoped. Caching that result in a facade retained one coroutine's object across later requests. Add scoped-instance inspection to the container contract and check actual resolved identity before keeping a facade result. Remember positive accessor decisions so subsequent calls avoid scanning the scoped store. Explicit swaps still take precedence, and clearing facade instances also clears the lifetime decisions. Read the current scoped store rather than recording object publications, so replicated context values are handled correctly. Regenerate the App facade annotation for the added container operation. Verified direct and delegated bindings, scoped attributes, replicated contexts, ordinary facade caching and cache resets. Affected framework, Testbench and package-consumer checks pass, along with formatting and full static analysis. --- src/container/src/Container.php | 18 +++++ src/contracts/src/Container/Container.php | 5 ++ src/support/src/Facades/App.php | 1 + src/support/src/Facades/Facade.php | 23 +++++- tests/Support/SupportFacadeTest.php | 86 ++++++++++++++++++++++- 5 files changed, 128 insertions(+), 5 deletions(-) diff --git a/src/container/src/Container.php b/src/container/src/Container.php index a542aeb31e..e7fb70eb1b 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -389,6 +389,24 @@ public function isScoped(string $abstract): bool return false; } + /** + * Determine if the object is a resolved scoped instance in this coroutine. + */ + public function isScopedInstance(mixed $instance): bool + { + if (! is_object($instance)) { + return false; + } + + foreach ($this->scopedInstances as $abstract => $_) { + if (CoroutineContext::get(self::SCOPED_CONTEXT_PREFIX . $abstract) === $instance) { + return true; + } + } + + return false; + } + /** * Determine if a scoped binding has already been resolved in this coroutine. * diff --git a/src/contracts/src/Container/Container.php b/src/contracts/src/Container/Container.php index bee49a9d7b..feba3993ba 100644 --- a/src/contracts/src/Container/Container.php +++ b/src/contracts/src/Container/Container.php @@ -81,6 +81,11 @@ public function scopedIf(Closure|string $abstract, Closure|string|null $concrete */ public function isScoped(string $abstract): bool; + /** + * Determine if the object is a resolved scoped instance in this coroutine. + */ + public function isScopedInstance(mixed $instance): bool; + /** * "Extend" an abstract type in the container. * diff --git a/src/support/src/Facades/App.php b/src/support/src/Facades/App.php index 860d3cbfd9..585ae150c0 100644 --- a/src/support/src/Facades/App.php +++ b/src/support/src/Facades/App.php @@ -83,6 +83,7 @@ * @method static bool isLocale(string $locale) * @method static bool isProduction() * @method static bool isScoped(string $abstract) + * @method static bool isScopedInstance(mixed $instance) * @method static bool isShared(string $abstract) * @method static string joinPaths(string|null $basePath, string $path = '') * @method static string langPath(string $path = '') diff --git a/src/support/src/Facades/Facade.php b/src/support/src/Facades/Facade.php index 9ed05d0609..d7aa1047b8 100644 --- a/src/support/src/Facades/Facade.php +++ b/src/support/src/Facades/Facade.php @@ -37,6 +37,13 @@ abstract class Facade */ protected static array $resolvedInstance; + /** + * Accessors whose resolved objects belong to a coroutine scope. + * + * @var array + */ + protected static array $scopedAccessors = []; + /** * Indicates if the resolved instance should be cached. * @@ -239,8 +246,15 @@ protected static function resolveFacadeInstance(string $name): mixed if (static::$app) { $instance = static::$app->make($name); - // Scoped services belong to one coroutine, so the worker-wide cache must not keep them. - if (static::$cached && ! static::$app->isScoped($name)) { + if (! static::$cached || isset(static::$scopedAccessors[$name]) || static::$app->isScoped($name)) { + return $instance; + } + + // A transient factory can return a scoped service. Remember its lifetime + // so later calls neither retain the object nor repeat the scoped-store scan. + if (static::$app->isScopedInstance($instance)) { + static::$scopedAccessors[$name] = true; + } else { static::$resolvedInstance[$name] = $instance; } @@ -258,7 +272,9 @@ protected static function resolveFacadeInstance(string $name): mixed */ public static function clearResolvedInstance(?string $name = null): void { - unset(static::$resolvedInstance[$name ?? static::getFacadeAccessor()]); + $name ??= static::getFacadeAccessor(); + + unset(static::$resolvedInstance[$name], static::$scopedAccessors[$name]); } /** @@ -270,6 +286,7 @@ public static function clearResolvedInstance(?string $name = null): void public static function clearResolvedInstances(): void { static::$resolvedInstance = []; + static::$scopedAccessors = []; } /** diff --git a/tests/Support/SupportFacadeTest.php b/tests/Support/SupportFacadeTest.php index c65e7f95fc..13b280fc97 100755 --- a/tests/Support/SupportFacadeTest.php +++ b/tests/Support/SupportFacadeTest.php @@ -4,12 +4,16 @@ namespace Hypervel\Tests\Support\SupportFacadeTest; +use Closure; +use Hypervel\Container\Attributes\Scoped; use Hypervel\Container\Container; +use Hypervel\Context\ReplicableContext; use Hypervel\Support\Facades\Facade; use Hypervel\Support\Testing\Fakes\Fake; use Hypervel\Tests\TestCase; use Mockery as m; use Mockery\MockInterface; +use PHPUnit\Framework\Attributes\DataProvider; use stdClass; use function Hypervel\Coroutine\parallel; @@ -206,10 +210,11 @@ public function testIsFakeReturnsFalseForNonFakeInstance(): void $this->assertFalse(FacadeStub::isFake()); } - public function testScopedFacadeResolvesWithinEachCoroutine(): void + #[DataProvider('scopedBindings')] + public function testScopedFacadeResolvesWithinEachCoroutine(Closure $register): void { $app = new Container; - $app->scoped('foo', static fn (): stdClass => new stdClass); + $register($app); FacadeStub::setFacadeApplication($app); $resolve = function (): object { @@ -223,6 +228,63 @@ public function testScopedFacadeResolvesWithinEachCoroutine(): void [$first, $second] = parallel([$resolve, $resolve]); $this->assertNotSame($first, $second); + + $app->bind('foo', static fn (): stdClass => new stdClass); + FacadeStub::clearResolvedInstance(); + + $this->assertSame(FacadeStub::getFacadeRoot(), FacadeStub::getFacadeRoot()); + } + + /** + * Provide bindings that resolve to scoped services. + */ + public static function scopedBindings(): iterable + { + yield 'direct' => [static function (Container $app): void { + $app->scoped('foo', static fn (): stdClass => new stdClass); + }]; + + yield 'concrete delegate' => [static function (Container $app): void { + $app->scoped(stdClass::class); + $app->bind('foo', stdClass::class); + }]; + + yield 'contract delegate' => [static function (Container $app): void { + $app->scoped(ScopedServiceContract::class, ScopedService::class); + $app->bind('foo', ScopedServiceContract::class); + }]; + + yield 'attribute delegate' => [static function (Container $app): void { + $app->bind('foo', ScopedService::class); + }]; + + yield 'factory delegate' => [static function (Container $app): void { + $app->scoped(stdClass::class); + $app->bind('foo', static fn (Container $container): stdClass => $container->make(stdClass::class)); + }]; + } + + public function testFacadeKeepsReplicatedScopedObjectsInTheirCopiedContexts(): void + { + $app = new Container; + $app->scoped(ReplicatedService::class); + $app->bind('foo', ReplicatedService::class); + FacadeStub::setFacadeApplication($app); + $parent = $app->make(ReplicatedService::class); + + [$first, $second] = parallel([ + fn () => FacadeStub::getFacadeRoot(), + fn () => FacadeStub::getFacadeRoot(), + ], copyContext: true); + + $this->assertNotSame($parent, $first); + $this->assertNotSame($first, $second); + $this->assertSame($parent, FacadeStub::getFacadeRoot()); + + $app->bind('foo', static fn (): stdClass => new stdClass); + Facade::clearResolvedInstances(); + + $this->assertSame(FacadeStub::getFacadeRoot(), FacadeStub::getFacadeRoot()); } public function testCachedFacadeResolvesOnce(): void @@ -262,6 +324,26 @@ protected static function getFacadeAccessor(): string } } +interface ScopedServiceContract +{ +} + +#[Scoped] +class ScopedService implements ScopedServiceContract +{ +} + +class ReplicatedService implements ReplicableContext +{ + /** + * Copy the scoped service for a child coroutine. + */ + public function replicate(): static + { + return clone $this; + } +} + class ApplicationStub extends Container { /** From f2460e2e00fd999771574c11e6f27412ede5e59c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:44:44 +0000 Subject: [PATCH 14/16] Isolate notification fake locale overrides by coroutine Keep a notification fake's one-send locale in coroutine-local storage, matching the notification manager. A concurrent send must not consume an override selected by another coroutine. Preserve the public nullable locale property through property hooks. Key overrides by weak object identity so replacement fakes cannot inherit stale object-ID entries. Clone the small map on writes so copied child contexts inherit the locale and then consume it independently of their parent. The regression exercises both fresh and copied child contexts, overlapping sends and the return to the default after one send. Notification-fake and affected framework tests pass, with formatting and full static analysis. --- .../src/Testing/Fakes/NotificationFake.php | 30 ++++++++++++++++++- .../SupportTestingNotificationFakeTest.php | 27 +++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/support/src/Testing/Fakes/NotificationFake.php b/src/support/src/Testing/Fakes/NotificationFake.php index 03ef5b3ac1..0795a3aa41 100644 --- a/src/support/src/Testing/Fakes/NotificationFake.php +++ b/src/support/src/Testing/Fakes/NotificationFake.php @@ -6,6 +6,7 @@ use Closure; use Exception; +use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Notifications\Dispatcher as NotificationDispatcher; use Hypervel\Contracts\Notifications\Factory as NotificationFactory; use Hypervel\Contracts\Queue\ShouldQueue; @@ -17,6 +18,7 @@ use Hypervel\Support\Traits\ReflectsClosures; use PHPUnit\Framework\Assert as PHPUnit; use UnitEnum; +use WeakMap; class NotificationFake implements Fake, NotificationDispatcher, NotificationFactory { @@ -24,6 +26,11 @@ class NotificationFake implements Fake, NotificationDispatcher, NotificationFact use MatchesProperties; use ReflectsClosures; + /** + * Context key for one-send locale overrides owned by individual fakes. + */ + protected const string LOCALES_CONTEXT_KEY = '__notifications.fake_locales'; + /** * All of the notifications that have been sent. */ @@ -32,7 +39,28 @@ class NotificationFake implements Fake, NotificationDispatcher, NotificationFact /** * Locale used when sending notifications. */ - public ?string $locale = null; + public ?string $locale { + get { + /** @var null|WeakMap $locales */ + $locales = CoroutineContext::get(self::LOCALES_CONTEXT_KEY); + + return $locales[$this] ?? null; + } + set { + /** @var null|WeakMap $current */ + $current = CoroutineContext::get(self::LOCALES_CONTEXT_KEY); + // Copied coroutine contexts share objects until a writer replaces them. + $locales = $current === null ? new WeakMap : clone $current; + + if ($value === null) { + unset($locales[$this]); + } else { + $locales[$this] = $value; + } + + CoroutineContext::set(self::LOCALES_CONTEXT_KEY, $locales); + } + } /** * Indicates if notifications should be serialized and restored when pushed to the queue. diff --git a/tests/Support/SupportTestingNotificationFakeTest.php b/tests/Support/SupportTestingNotificationFakeTest.php index 7ee960158d..fadce55df8 100644 --- a/tests/Support/SupportTestingNotificationFakeTest.php +++ b/tests/Support/SupportTestingNotificationFakeTest.php @@ -15,8 +15,11 @@ use Hypervel\Support\Testing\Fakes\NotificationFake; use Hypervel\Tests\Notifications\Fixtures\NotificationStub; use Hypervel\Tests\TestCase; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\ExpectationFailedException; +use function Hypervel\Coroutine\parallel; + class SupportTestingNotificationFakeTest extends TestCase { private NotificationFake $fake; @@ -269,6 +272,30 @@ public function testLocaleIsNotUsedForLaterNotifications(): void $this->assertSame(['fr', null], array_column($notifications, 'locale')); } + #[TestWith([false], 'fresh context')] + #[TestWith([true], 'copied context')] + public function testLocaleOverridesAreConsumedOnlyInTheSendingCoroutine(bool $copyContext): void + { + $this->fake->locale('fr'); + + parallel([ + function (): void { + $this->fake->locale('de'); + usleep(5000); + $this->fake->send($this->user, new NotificationStub); + }, + fn () => $this->fake->send($this->user, new NotificationStub), + ], copyContext: $copyContext); + + $this->assertSame('fr', $this->fake->locale); + $this->fake->send($this->user, new NotificationStub); + $this->fake->send($this->user, new NotificationStub); + + $notifications = $this->fake->sentNotifications()[UserStub::class][''][NotificationStub::class]; + + $this->assertSame([$copyContext ? 'fr' : null, 'de', 'fr', null], array_column($notifications, 'locale')); + } + public function testAssertSentToWhenNotifiableHasFalsyShouldSend(): void { $user = new LocalizedUserStub; From 01ccb0a1f514a525b9143a134a91e0246cab443a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:44:54 +0000 Subject: [PATCH 15/16] Preserve literal content in Markdown mail encoding The global reversal of Markdown escapes removed user backslashes and changed literal code examples. Encode secured punctuation as character references instead, then restore forms left in code and raw HTML after parsing. Keep angle brackets escaped and preserve CommonMark's normalized URL destinations. Use the same encoding tables for rendering and encoded parsing. Remove the obsolete reversal from non-secured rendering, and convert scalar interpolations to strings so numeric values do not throw under strict typing. Retain the link-injection protection from Laravel #61755 while correcting expectations that previously asserted dropped backslashes. The coroutine-isolation probe now checks whether an encoder is active without depending on its representation. Verified actual mail renders containing UNC paths, code examples, ordinary entity literals, CSS text, normalized URLs, numeric values and nested panel/subcopy content. Existing table, link safety, parser and custom-extension cases pass, along with affected framework tests, formatting and full static analysis. --- src/mail/src/Markdown.php | 62 ++++++++++++------- .../message-with-punctuation.blade.php | 21 +++++++ .../Mail/MailableWithSecuredEncodingTest.php | 31 +++++++++- .../MailableWithoutSecuredEncodingTest.php | 13 ++++ tests/Integration/Mail/MarkdownParserTest.php | 7 +++ tests/Mail/MarkdownCoroutineSafetyTest.php | 2 +- 6 files changed, 109 insertions(+), 27 deletions(-) create mode 100644 tests/Integration/Mail/Fixtures/message-with-punctuation.blade.php diff --git a/src/mail/src/Markdown.php b/src/mail/src/Markdown.php index 59c7903f79..a4c162746d 100644 --- a/src/mail/src/Markdown.php +++ b/src/mail/src/Markdown.php @@ -18,6 +18,36 @@ class Markdown { + /** + * References that keep interpolated punctuation out of Markdown syntax. + */ + protected const array ENCODED_CHARACTERS = [ + '\\' => '\', + '[' => '[', + ']' => ']', + '<' => '<', + '>' => '>', + ]; + + /** + * Restore references left escaped in code or unchanged in raw HTML. + * + * Zero padding keeps ordinary entity examples and literal backslashes intact. + * Angle brackets remain escaped so restoration cannot introduce HTML tags. + */ + protected const array DECODED_CHARACTERS = [ + '&#0000092;' => '\\', + '&#0000091;' => '[', + '&#0000093;' => ']', + '&#0000060;' => '<', + '&#0000062;' => '>', + '\' => '\\', + '[' => '[', + ']' => ']', + '<' => '<', + '>' => '>', + ]; + /** * The current theme being used when generating emails. */ @@ -75,17 +105,7 @@ function () use ($viewFactory, $view, $data) { } return EncodedHtmlString::withEncoding( - function ($value) { - $replacements = [ - '\\' => '\\\\', - '[' => '\[', - ']' => '\]', - '<' => '<', - '>' => '>', - ]; - - return strtr($value, $replacements); - }, + fn ($value) => strtr((string) $value, static::ENCODED_CHARACTERS), $render ); } @@ -101,9 +121,9 @@ function ($value) { : 'mail::themes.' . $theme; } - $contents = static::$withSecuredEncoding === true - ? strtr($contents, ['\\\\' => '\\', '\[' => '[', '\]' => ']']) - : str_replace('\[', '[', $contents); + if (static::$withSecuredEncoding === true) { + $contents = strtr($contents, static::DECODED_CHARACTERS); + } return new HtmlString(($inliner ?: new CssToInlineStyles)->convert( $contents, @@ -134,22 +154,18 @@ public static function parse(Stringable|string $text, bool $encoded = false): Ht return new HtmlString(static::converter()->convert((string) $text)->getContent()); } - return new HtmlString(EncodedHtmlString::withEncoding( + $contents = EncodedHtmlString::withEncoding( function ($value) { - $replacements = [ - '\\' => '\\\\', - '[' => '\[', - '<' => '\<', - ]; - - $html = strtr($value, $replacements); + $html = strtr((string) $value, static::ENCODED_CHARACTERS); return static::converter([ 'html_input' => 'escape', ])->convert($html)->getContent(); }, fn () => static::converter()->convert((string) $text)->getContent() - )); + ); + + return new HtmlString(strtr($contents, static::DECODED_CHARACTERS)); } /** diff --git a/tests/Integration/Mail/Fixtures/message-with-punctuation.blade.php b/tests/Integration/Mail/Fixtures/message-with-punctuation.blade.php new file mode 100644 index 0000000000..ea295817bc --- /dev/null +++ b/tests/Integration/Mail/Fixtures/message-with-punctuation.blade.php @@ -0,0 +1,21 @@ + +{{ $value }} + +`literal \[ and \] and \\ and [ and <` + +`{{ $value }}` + + +{{ $value }} + + + +{{ $value }} + + + + +[Destination](https://{{ $host }}.good.test) + +{{ $number }} + diff --git a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php index 2a95a7ec6a..289e386b19 100644 --- a/tests/Integration/Mail/MailableWithSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithSecuredEncodingTest.php @@ -12,6 +12,7 @@ use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Testbench\Factories\UserFactory; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\DomCrawler\Crawler; class MailableWithSecuredEncodingTest extends MailableTestCase { @@ -120,9 +121,12 @@ public static function markdownEncodedTemplateDataProvider(): iterable 'Hi Visit <span>https://hypervel.org/docs</span> to browse the documentation', ]; - yield ['\[Hypervel](https://hypervel.org)', 'Hi [Hypervel](https://hypervel.org)']; + // Encoding must block Markdown injection without deleting the user's backslashes. + yield ['\[Hypervel](https://hypervel.org)', 'Hi \[Hypervel](https://hypervel.org)']; - yield ['!\[Hypervel](https://hypervel.org)', 'Hi ![Hypervel](https://hypervel.org)']; + yield ['!\[Hypervel](https://hypervel.org)', 'Hi !\[Hypervel](https://hypervel.org)']; + + yield ['\\\server\share', 'Hi \\\server\share']; } #[WithMigration] @@ -165,6 +169,27 @@ public static function markdownEncodedLinkTextTemplateDataProvider(): iterable yield ['Taylor](https://example.com)', 'Taylor](https://example.com)']; - yield ['Taylor\](https://example.com)', 'Taylor](https://example.com)']; + // Encoding must block Markdown injection without deleting the user's backslashes. + yield ['Taylor\](https://example.com)', 'Taylor\](https://example.com)']; + } + + public function testSecuredRenderingPreservesLiteralContentAcrossMailComponents(): void + { + $value = '\\\server\share [x] '; + $html = $this->app->make(Markdown::class)->render('message-with-punctuation', [ + 'value' => $value, + 'punctuation' => '[x]', + 'host' => 'evil.test\\', + 'number' => 42, + ])->toHtml(); + $document = new Crawler($html); + + $this->assertSame('literal \[ and \] and \\\ and [ and <', $document->filter('code')->eq(0)->text()); + $this->assertSame($value, $document->filter('code')->eq(1)->text()); + $this->assertSame($value, $document->filter('.panel-item')->text()); + $this->assertSame($value, $document->filter('.subcopy')->text()); + $this->assertStringContainsString('content: "[x]"', $document->filter('style')->last()->text()); + $this->assertSame('https://evil.test%5C.good.test', $document->selectLink('Destination')->attr('href')); + $this->assertContains('42', $document->filter('p')->each(fn (Crawler $paragraph): string => $paragraph->text())); } } diff --git a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php index 22e90bbe9b..6dcfd9b674 100644 --- a/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php +++ b/tests/Integration/Mail/MailableWithoutSecuredEncodingTest.php @@ -12,6 +12,7 @@ use Hypervel\Testbench\Attributes\WithMigration; use Hypervel\Testbench\Factories\UserFactory; use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\DomCrawler\Crawler; class MailableWithoutSecuredEncodingTest extends MailableTestCase { @@ -120,4 +121,16 @@ public static function markdownEncodedTemplateDataProvider(): iterable 'Hi Visit <span>https://hypervel.org/docs</span> to browse the documentation', ]; } + + public function testLiteralCodeEscapesArePreservedWithoutSecuredEncoding(): void + { + $html = $this->app->make(Markdown::class)->render('message-with-punctuation', [ + 'value' => 'ordinary text', + 'punctuation' => '[x]', + 'host' => 'safe', + 'number' => 42, + ])->toHtml(); + + $this->assertSame('literal \[ and \] and \\\ and [ and <', (new Crawler($html))->filter('code')->first()->text()); + } } diff --git a/tests/Integration/Mail/MarkdownParserTest.php b/tests/Integration/Mail/MarkdownParserTest.php index 55666baf6c..5ca560f197 100644 --- a/tests/Integration/Mail/MarkdownParserTest.php +++ b/tests/Integration/Mail/MarkdownParserTest.php @@ -78,6 +78,13 @@ public static function markdownEncodedDataProvider(): iterable '

!\[Welcome to Hypervel](https://hypervel.org/assets/img/welcome/background.svg)

', ]; + yield 'literal punctuation in code' => [ + new EncodedHtmlString('`[x] \\\server `'), + '

[x] \\\server <b>

', + ]; + + yield 'numeric interpolation' => [new EncodedHtmlString(42), '

42

']; + yield [ new EncodedHtmlString(new HtmlString('Visit https://hypervel.org/docs to browse the documentation')), '

Visit https://hypervel.org/docs to browse the documentation

', diff --git a/tests/Mail/MarkdownCoroutineSafetyTest.php b/tests/Mail/MarkdownCoroutineSafetyTest.php index 2ca7fdbba4..4cf457de2d 100644 --- a/tests/Mail/MarkdownCoroutineSafetyTest.php +++ b/tests/Mail/MarkdownCoroutineSafetyTest.php @@ -245,7 +245,7 @@ protected function renderEncodingProbe(): string protected function encodingMarker(): string { - return (new EncodedHtmlString('['))->toHtml() === '\[' + return (new EncodedHtmlString('['))->toHtml() !== '[' ? 'scoped' : 'default'; } From ebc223d8c1b92213707f4f7584cdf33fc4659407 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 30 Oct 2026 18:45:02 +0000 Subject: [PATCH 16/16] Clarify facade lifetimes and notification boot defaults Explain that facade caching excludes bindings and factories that return an existing scoped service, and that custom containers must implement Hypervel's complete contract. List the added scoped-instance operation alongside the existing contract differences. Clarify that notification defaults are configured during initial application boot. Calls from providers registered after boot are execution-local, and locale overrides then apply to one send. The porting guide distinguishes that persistent boot default from Laravel's first-send consumption and links to the notification documentation. Checked the wording against container resolution and notification-manager behavior. No runtime behavior changes in this commit. --- src/contracts/README.md | 2 +- src/docs/facades.md | 2 +- src/docs/notifications.md | 4 ++-- src/docs/porting-from-laravel.md | 8 ++++++++ src/notifications/README.md | 2 +- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/contracts/README.md b/src/contracts/README.md index d3274f89fe..cb1d1fca72 100644 --- a/src/contracts/README.md +++ b/src/contracts/README.md @@ -7,6 +7,6 @@ Documentation: https://hypervel.org/docs/contracts ## Differences From Laravel -The `Container` contract also requires `isScoped()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. +The `Container` contract also requires `isScoped()`, `isScopedInstance()`, `forgetInstance()`, `makeTransient()`, `build()`, `buildWith()`, `resolveContextualParameters()`, and `rebinding()`. Custom implementations must provide these methods; extending `Hypervel\Container\Container` supplies them. Scope inspection lets consumers preserve coroutine-local lifetimes instead of caching scoped services across requests. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Contracts diff --git a/src/docs/facades.md b/src/docs/facades.md index 0e7e73c020..eafcb0269e 100644 --- a/src/docs/facades.md +++ b/src/docs/facades.md @@ -193,7 +193,7 @@ class Cache extends Facade Instead, the `Cache` facade extends the base `Facade` class and defines the method `getFacadeAccessor()`. This method's job is to return the name of a service container binding. When a user references any static method on the `Cache` facade, Hypervel resolves the `cache` binding from the [service container](/docs/{{version}}/container) and runs the requested method (in this case, `get`) against that object. -By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped bindings are the exception: register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. +By default, a facade keeps the first instance it resolves for the lifetime of the worker, even when the service uses a `bind()` binding. Scoped services are the exception, including when a `bind()` binding or factory returns a scoped instance. Register a service with `scoped()` to share one instance within each request or job coroutine. To resolve a fresh instance from a `bind()` binding on every facade call, set `protected static bool $cached = false` on the facade. ## Facade Class Reference diff --git a/src/docs/notifications.md b/src/docs/notifications.md index 7ae82eba78..4f55f904df 100644 --- a/src/docs/notifications.md +++ b/src/docs/notifications.md @@ -136,7 +136,7 @@ public function via(object $notifiable): array } ``` -You may configure the notification manager's default channel by calling `Notification::deliverVia` from a service provider's `register` or `boot` method. Calls made after the application has booted change the default only for the current execution. Application `booted` callbacks run after this default-setting window, so they are also limited to their current execution; the same timing applies to the default locale described below. +You may configure the notification manager's default channel by calling `Notification::deliverVia` from a service provider's `register` or `boot` method during initial application boot. Calls made after the application has booted change the default only for the current execution, including calls from providers registered later. Application `booted` callbacks run after this default-setting window, so they are also limited to their current execution; the same timing applies to the default locale described below. ### Queueing Notifications @@ -1731,7 +1731,7 @@ Notification::locale('es')->send( ); ``` -Calling `Notification::locale` from a service provider's `register` or `boot` method configures the default locale for later executions. Calls made after the application has booted apply only to the next `send` or `sendNow` call in the current execution. Later sends return to the boot default. Both the boot default and a per-send override take precedence over a notifiable's preferred locale. +Calling `Notification::locale` from a service provider's `register` or `boot` method during initial application boot configures the default locale for later executions. Calls made after the application has booted apply only to the next `send` or `sendNow` call in the current execution, including calls from providers registered later. Later sends return to the boot default. Both the boot default and a per-send override take precedence over a notifiable's preferred locale. #### User Preferred Locales diff --git a/src/docs/porting-from-laravel.md b/src/docs/porting-from-laravel.md index 1fb1db8da1..5b564cbd62 100644 --- a/src/docs/porting-from-laravel.md +++ b/src/docs/porting-from-laravel.md @@ -32,6 +32,7 @@ - [MCP](#mcp) - [Saloon](#saloon) - [Broadcasting](#broadcasting) + - [Notifications](#notifications) - [JSON:API Resources](#jsonapi-resources) - [CSRF Protection](#csrf-protection) - [Fortify](#fortify) @@ -416,6 +417,8 @@ Hypervel follows Laravel's named container APIs, but intentionally does not supp Use `get()` and `has()` instead when working through the PSR-11 container interface. Hypervel does not expose arbitrary binding removal; `forgetInstance()` clears a temporary instance so the original binding can resolve again. +Custom containers must implement Hypervel's full `Container` contract or extend `Hypervel\Container\Container`; Laravel's container contract alone does not provide the lifetime operations Hypervel uses. + Container lifecycles are adapted for Swoole: | Need | Method | @@ -597,6 +600,11 @@ For Pusher and Reverb, replace Guzzle's `max_host_connections` and `max_total_co Mercure applications must configure a standalone HTTP hub. Hypervel runs on Swoole and does not use FrankenPHP's in-process `mercure_publish()` integration. See [Mercure broadcasting](/docs/{{version}}/broadcasting#mercure). + +### Notifications + +`Notification::locale()` called during initial application boot sets a worker default that persists across sends. Laravel consumes that locale on the first send. For a one-send override, call it when sending the notification, after application boot. See [notification locales](/docs/{{version}}/notifications#localizing-notifications). + ### JSON:API Resources diff --git a/src/notifications/README.md b/src/notifications/README.md index 61ed955cb0..a222229c90 100644 --- a/src/notifications/README.md +++ b/src/notifications/README.md @@ -5,7 +5,7 @@ Documentation: https://hypervel.org/docs/notifications ## Differences From Laravel -- Calling `Notification::locale()` during provider registration or boot sets a worker default. Calls after boot apply only to the next send in the current execution. See [notification locales](https://hypervel.org/docs/notifications#localizing-notifications). +- Calling `Notification::locale()` during initial application boot sets a worker default. Calls after boot, including from providers registered later, apply only to the next send in the current execution. See [notification locales](https://hypervel.org/docs/notifications#localizing-notifications). - The manager creates a sender for each send so concurrent sends cannot change one another's locale. Laravel's protected `$notificationSender` cache is omitted; override `resolveNotificationSender()` to customize sender creation. Ported from: https://github.com/laravel/framework/tree/13.x/src/Illuminate/Notifications