|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 '];
+ }
+
+ #[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 = [
+ '\' => '\\',
+ '[' => '[',
+ ']' => ']',
+ '<' => '<',
+ '>' => '>',
+ '\' => '\\',
+ '[' => '[',
+ ']' => ']',
+ '<' => '<',
+ '>' => '>',
+ ];
+
/**
* 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 '];
+ 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