From 01fdad57371a5fe3d3890fba34bdac0f98b6db18 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:26:30 +0000 Subject: [PATCH 01/25] Correct authentication and collection type descriptions Reconcile Laravel framework PR #62097 at 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. Describe nullable collection keys and closure-based plucks, and retain broadcast channel element types. Existing native types already cover the other applicable annotations. Allow reset-password and verification mail callbacks to return a Mailable, as the notification mail channel already supports. The previous native return types rejected those callbacks before delivery. Each notification has a regression test that fails with the old return type. Formatting, full source and type-fixture analysis, Auth and Mail tests, mail integration tests and facade documentation checks pass. --- src/auth/src/Notifications/ResetPassword.php | 7 ++++--- src/auth/src/Notifications/VerifyEmail.php | 7 ++++--- src/broadcasting/src/AnonymousEvent.php | 2 ++ src/collections/src/Enumerable.php | 5 +++-- src/collections/src/LazyCollection.php | 5 +++++ tests/Auth/ResetPasswordNotificationTest.php | 10 ++++++++++ tests/Auth/VerifyEmailNotificationTest.php | 9 +++++++++ 7 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/auth/src/Notifications/ResetPassword.php b/src/auth/src/Notifications/ResetPassword.php index 35db80786d..8293574820 100644 --- a/src/auth/src/Notifications/ResetPassword.php +++ b/src/auth/src/Notifications/ResetPassword.php @@ -10,6 +10,7 @@ use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; use Hypervel\Contracts\Config\Repository as ConfigContract; +use Hypervel\Contracts\Mail\Mailable; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; use Hypervel\Support\Facades\Lang; @@ -29,7 +30,7 @@ class ResetPassword extends Notification /** * The callback that should be used to build the mail message. * - * @var null|(Closure(mixed, string): MailMessage) + * @var null|(Closure(mixed, string): (Mailable|MailMessage)) */ public static ?Closure $toMailCallback = null; @@ -59,7 +60,7 @@ public function via(mixed $notifiable): array /** * Build the mail representation of the notification. */ - public function toMail(mixed $notifiable): MailMessage + public function toMail(mixed $notifiable): MailMessage|Mailable { if (static::$toMailCallback) { return call_user_func(static::$toMailCallback, $notifiable, $this->token); @@ -132,7 +133,7 @@ public static function createUrlUsing(Closure $callback): void * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every reset-password notification. * - * @param Closure(mixed, string): MailMessage $callback + * @param Closure(mixed, string): (Mailable|MailMessage) $callback */ public static function toMailUsing(Closure $callback): void { diff --git a/src/auth/src/Notifications/VerifyEmail.php b/src/auth/src/Notifications/VerifyEmail.php index 907d8ecf9e..5e72f35e14 100644 --- a/src/auth/src/Notifications/VerifyEmail.php +++ b/src/auth/src/Notifications/VerifyEmail.php @@ -5,6 +5,7 @@ namespace Hypervel\Auth\Notifications; use Closure; +use Hypervel\Contracts\Mail\Mailable; use Hypervel\Notifications\Messages\MailMessage; use Hypervel\Notifications\Notification; use Hypervel\Support\CarbonImmutable; @@ -24,7 +25,7 @@ class VerifyEmail extends Notification /** * The callback that should be used to build the mail message. * - * @var null|(Closure(mixed, string): MailMessage) + * @var null|(Closure(mixed, string): (Mailable|MailMessage)) */ public static ?Closure $toMailCallback = null; @@ -39,7 +40,7 @@ public function via(mixed $notifiable): array /** * Build the mail representation of the notification. */ - public function toMail(mixed $notifiable): MailMessage + public function toMail(mixed $notifiable): MailMessage|Mailable { $verificationUrl = $this->verificationUrl($notifiable); @@ -100,7 +101,7 @@ public static function createUrlUsing(Closure $callback): void * Boot-only. The callback persists in a static property for the worker * lifetime and runs on every email-verification notification. * - * @param Closure(mixed, string): MailMessage $callback + * @param Closure(mixed, string): (Mailable|MailMessage) $callback */ public static function toMailUsing(Closure $callback): void { diff --git a/src/broadcasting/src/AnonymousEvent.php b/src/broadcasting/src/AnonymousEvent.php index f5e34c2bdd..0342399381 100644 --- a/src/broadcasting/src/AnonymousEvent.php +++ b/src/broadcasting/src/AnonymousEvent.php @@ -137,6 +137,8 @@ public function broadcastWith(): array /** * Get the channels the event should broadcast on. + * + * @return Channel|Channel[]|string[] */ public function broadcastOn(): Channel|array { diff --git a/src/collections/src/Enumerable.php b/src/collections/src/Enumerable.php index 01fc40a741..e344957ebb 100644 --- a/src/collections/src/Enumerable.php +++ b/src/collections/src/Enumerable.php @@ -713,7 +713,7 @@ public function nth(int $step, int $offset = 0): static; /** * Get the items with the specified keys. * - * @param array|Enumerable|string $keys + * @param null|array|Enumerable|string $keys */ public function only(mixed $keys): static; @@ -1034,7 +1034,8 @@ public function pipeThrough(array $pipes): mixed; /** * Get the values of a given key. * - * @param array|string $value + * @param null|array|Closure|int|string $value + * @param null|array|Closure|int|string $key * @return static */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): Collection|static; diff --git a/src/collections/src/LazyCollection.php b/src/collections/src/LazyCollection.php index 24d5615b94..626533b409 100644 --- a/src/collections/src/LazyCollection.php +++ b/src/collections/src/LazyCollection.php @@ -740,6 +740,7 @@ public function last(?callable $callback = null, mixed $default = null): mixed * Get the values of a given key. * * @param null|array|Closure|int|string $value + * @param null|array|Closure|int|string $key * @return static */ public function pluck(Closure|string|int|array|null $value, Closure|string|int|array|null $key = null): static @@ -1909,6 +1910,10 @@ protected function makeIterator(IteratorAggregate|Iterator|array|callable $sourc /** * Explode the "value" and "key" arguments passed to "pluck". + * + * @param null|array|Closure|int|string $value + * @param null|array|Closure|int|string $key + * @return array{null|array|Closure|int, null|array|Closure|int} */ protected function explodePluckParameters(Closure|string|int|array|null $value, Closure|string|int|array|null $key): array { diff --git a/tests/Auth/ResetPasswordNotificationTest.php b/tests/Auth/ResetPasswordNotificationTest.php index 9d08681a3e..001ec69511 100644 --- a/tests/Auth/ResetPasswordNotificationTest.php +++ b/tests/Auth/ResetPasswordNotificationTest.php @@ -8,8 +8,10 @@ use Hypervel\Auth\Passwords\PasswordBroker; use Hypervel\Auth\Passwords\PasswordBrokerManager; use Hypervel\Context\CoroutineContext; +use Hypervel\Mail\Mailable; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Testbench\TestCase; +use stdClass; #[WithConfig('auth.passwords.admins', [ 'driver' => 'database', @@ -19,6 +21,14 @@ ])] class ResetPasswordNotificationTest extends TestCase { + public function testMailCallbackCanReturnAMailable(): void + { + $mail = new Mailable; + ResetPassword::toMailUsing(fn (): Mailable => $mail); + + $this->assertSame($mail, (new ResetPassword('token'))->toMail(new stdClass)); + } + public function testExpiryIsCapturedFromSendingBrokerContext(): void { CoroutineContext::set(PasswordBroker::SENDING_BROKER_CONTEXT_KEY, 'admins'); diff --git a/tests/Auth/VerifyEmailNotificationTest.php b/tests/Auth/VerifyEmailNotificationTest.php index 21ffc688fe..8e0c370fec 100644 --- a/tests/Auth/VerifyEmailNotificationTest.php +++ b/tests/Auth/VerifyEmailNotificationTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Auth; use Hypervel\Auth\Notifications\VerifyEmail; +use Hypervel\Mail\Mailable; use Hypervel\Routing\Router; use Hypervel\Support\CarbonImmutable; use Hypervel\Testbench\TestCase; @@ -47,6 +48,14 @@ public function testMailMessageUsesTranslatedStringMetadata(): void $this->assertSame('Verify Email Address', $mail->actionText); } + public function testMailCallbackCanReturnAMailable(): void + { + $mail = new Mailable; + VerifyEmail::toMailUsing(fn (): Mailable => $mail); + + $this->assertSame($mail, (new VerifyEmail)->toMail(new VerifyEmailNotifiableStub)); + } + /** * Read the expiration timestamp from a signed URL. */ From 841624c0b082011fe5650f07d42eca14a368554f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:26:51 +0000 Subject: [PATCH 02/25] Correct callback, resource and request annotations Reconcile Laravel framework PR #62100 at 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. Correct nullable callback syntax, identify model resource class strings, describe retry-delay arrays and use field-name keys for uploaded files. Preserve fractional retry delays supported by the HTTP client. Other applicable corrections are already expressed by native types or more precise annotations. Preserve pooled connection state and omit Laravel-only bootstrap and provider-manifest annotations. Formatting, full source and type-fixture analysis and the affected checks pass. This commit changes annotations only. --- src/container/src/Container.php | 2 +- src/database/src/Eloquent/ModelInspector.php | 3 ++- src/database/src/Schema/Builder.php | 2 +- src/http/src/Client/PendingRequest.php | 2 ++ src/http/src/Request.php | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/container/src/Container.php b/src/container/src/Container.php index d59d95873d..d3a9b06465 100755 --- a/src/container/src/Container.php +++ b/src/container/src/Container.php @@ -243,7 +243,7 @@ class Container implements ContainerContract /** * The callback used to determine the container's environment. * - * @var null|(callable(array|string): bool|string) + * @var null|(callable(array|string): (bool|string)) */ protected $environmentResolver; diff --git a/src/database/src/Eloquent/ModelInspector.php b/src/database/src/Eloquent/ModelInspector.php index 172e73ee68..515f4331cf 100644 --- a/src/database/src/Eloquent/ModelInspector.php +++ b/src/database/src/Eloquent/ModelInspector.php @@ -8,6 +8,7 @@ use Hypervel\Contracts\Container\BindingResolutionException; use Hypervel\Contracts\Foundation\Application; use Hypervel\Database\Eloquent\Relations\Relation; +use Hypervel\Http\Resources\Json\JsonResource; use Hypervel\Support\Collection as BaseCollection; use Hypervel\Support\Facades\Gate; use Hypervel\Support\Str; @@ -285,7 +286,7 @@ protected function getBuilder(Model $model): string /** * Get the class used for JSON response transforming. * - * @return null|class-string + * @return null|class-string */ protected function getResource(Model $model): ?string { diff --git a/src/database/src/Schema/Builder.php b/src/database/src/Schema/Builder.php index 87f2e058d0..b343a520bf 100755 --- a/src/database/src/Schema/Builder.php +++ b/src/database/src/Schema/Builder.php @@ -39,7 +39,7 @@ class Builder /** * The Blueprint resolver callback. * - * @var Closure(Connection, string, null|Closure): Blueprint + * @var null|(Closure(Connection, string, null|Closure): Blueprint) */ protected ?Closure $resolver = null; diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 99144d2e61..c12e724a05 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -164,6 +164,8 @@ class PendingRequest implements Transient /** * The number of times to try the request. + * + * @var array|int */ protected array|int $tries = 1; diff --git a/src/http/src/Request.php b/src/http/src/Request.php index 0d17d194b5..abcdc46910 100644 --- a/src/http/src/Request.php +++ b/src/http/src/Request.php @@ -87,7 +87,7 @@ class Request extends SymfonyRequest implements Arrayable, ArrayAccess /** * All of the converted files for the request. * - * @var null|array + * @var null|array */ protected ?array $convertedFiles = null; From 41d16a08df6e3bb4f15dbef85f78f1915e72510e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:26:52 +0000 Subject: [PATCH 03/25] Align nullable mail settings and remaining type descriptions Reconcile Laravel framework PR #62125 and its Http/Process facade annotation update 422370f89e at 13.x revision 5d7797528723107cd4f495326838a7d7d67fbb20. Correct the optional firstOrFail filter and recorded-request predicate, then regenerate Http. The Process facade already uses the shared invoked-process contract. Allow locale(null) on mailables and pending mail to clear a selected locale. Match nullable mail content properties and update test subclasses to retain compatible property types. Regression tests verify locale fallback and rendering after clearing configured views. Formatting, full source and type-fixture analysis, Auth and Mail tests, mail integration tests and facade documentation checks pass. --- src/collections/src/Collection.php | 2 +- src/contracts/src/Mail/Mailable.php | 2 +- src/http/src/Client/Factory.php | 2 ++ src/mail/src/Mailable.php | 12 +++++----- src/mail/src/PendingMail.php | 2 +- src/support/src/Facades/Http.php | 2 +- .../Mail/RenderingMailWithLocaleTest.php | 14 ++++++++++++ .../Mail/SendingMailWithLocaleTest.php | 22 +++++++++++++++++++ .../Mail/SendingMarkdownMailTest.php | 2 +- tests/Mail/MarkdownCoroutineSafetyTest.php | 2 +- 10 files changed, 50 insertions(+), 12 deletions(-) diff --git a/src/collections/src/Collection.php b/src/collections/src/Collection.php index 4be2997bf7..702d4c200b 100644 --- a/src/collections/src/Collection.php +++ b/src/collections/src/Collection.php @@ -1440,7 +1440,7 @@ public function hasSole(callable|string|null $key = null, mixed $operator = null /** * Get the first item in the collection but throw an exception if no matching items exist. * - * @param (callable(TValue, TKey): bool)|string $key + * @param null|(callable(TValue, TKey): bool)|string $key * @return TValue * * @throws ItemNotFoundException diff --git a/src/contracts/src/Mail/Mailable.php b/src/contracts/src/Mail/Mailable.php index a7231fc3b9..8380877d7e 100644 --- a/src/contracts/src/Mail/Mailable.php +++ b/src/contracts/src/Mail/Mailable.php @@ -52,7 +52,7 @@ public function to(array|object|string $address, ?string $name = null): static; * * @return $this */ - public function locale(string $locale): static; + public function locale(?string $locale): static; /** * Set the name of the mailer that should be used to send the message. diff --git a/src/http/src/Client/Factory.php b/src/http/src/Client/Factory.php index 20169ae5a5..195a5d64a2 100644 --- a/src/http/src/Client/Factory.php +++ b/src/http/src/Client/Factory.php @@ -566,6 +566,8 @@ public function assertSequencesAreEmpty(): void /** * Get a collection of the request / response pairs matching the given truth test. + * + * @param null|(callable(Request, null|Response): bool) $callback */ public function recorded(?callable $callback = null): Collection { diff --git a/src/mail/src/Mailable.php b/src/mail/src/Mailable.php index dae258e77c..658ef7db27 100644 --- a/src/mail/src/Mailable.php +++ b/src/mail/src/Mailable.php @@ -50,7 +50,7 @@ class Mailable implements MailableContract, Renderable /** * The locale of the message. */ - public string $locale = ''; + public ?string $locale = null; /** * The person the message is from. @@ -85,22 +85,22 @@ class Mailable implements MailableContract, Renderable /** * The Markdown template for the message (if applicable). */ - public string $markdown; + public ?string $markdown = null; /** * The HTML to use for the message. */ - protected string $html; + protected ?string $html = null; /** * The view to use for the message. */ - public string $view; + public ?string $view = null; /** * The plain text view to use for the message. */ - public string $textView; + public ?string $textView = null; /** * The view data for the message. @@ -543,7 +543,7 @@ protected function runCallbacks(Message $message): static * * @return $this */ - public function locale(string $locale): static + public function locale(?string $locale): static { $this->locale = $locale; diff --git a/src/mail/src/PendingMail.php b/src/mail/src/PendingMail.php index b07389ebd9..3790a902b5 100644 --- a/src/mail/src/PendingMail.php +++ b/src/mail/src/PendingMail.php @@ -46,7 +46,7 @@ public function __construct( /** * Set the locale of the message. */ - public function locale(string $locale): static + public function locale(?string $locale): static { $this->locale = $locale; diff --git a/src/support/src/Facades/Http.php b/src/support/src/Facades/Http.php index f8d9cdada3..8c7ea7f9f7 100644 --- a/src/support/src/Facades/Http.php +++ b/src/support/src/Facades/Http.php @@ -43,7 +43,7 @@ * @method static bool preventingStrayRequests() * @method static \GuzzleHttp\Psr7\Response psr7Response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) * @method static \Hypervel\Http\Client\Factory record() - * @method static \Hypervel\Support\Collection recorded(callable|null $callback = null) + * @method static \Hypervel\Support\Collection recorded(null|callable $callback = null) * @method static void recordRequestResponsePair(\Hypervel\Http\Client\Request $request, \Hypervel\Http\Client\Response|null $response) * @method static \Hypervel\Http\Client\Factory registerConnection(string $name, array $config = []) * @method static \GuzzleHttp\Promise\PromiseInterface response(null|array|resource|\Psr\Http\Message\StreamInterface|string $body = null, int $status = 200, array $headers = []) diff --git a/tests/Integration/Mail/RenderingMailWithLocaleTest.php b/tests/Integration/Mail/RenderingMailWithLocaleTest.php index 9125fadef0..cf9b6f56fb 100644 --- a/tests/Integration/Mail/RenderingMailWithLocaleTest.php +++ b/tests/Integration/Mail/RenderingMailWithLocaleTest.php @@ -33,6 +33,20 @@ public function testMailableRendersInDefaultLocale(): void $this->assertStringContainsString('name', $mail->render()); } + public function testMailableViewsCanBeCleared(): void + { + $mail = (new Mailable)->markdown('missing')->view('missing')->text('view'); + $mail->markdown = null; + $mail->view = null; + + $this->assertStringContainsString('name', $mail->render()); + + $mail->textView = null; + $mail->view('view'); + + $this->assertStringContainsString('name', $mail->render()); + } + public function testMailableRendersInSelectedLocale(): void { $mail = (new RenderedTestMail)->locale('es'); diff --git a/tests/Integration/Mail/SendingMailWithLocaleTest.php b/tests/Integration/Mail/SendingMailWithLocaleTest.php index f4012c4c50..6b85844800 100644 --- a/tests/Integration/Mail/SendingMailWithLocaleTest.php +++ b/tests/Integration/Mail/SendingMailWithLocaleTest.php @@ -79,6 +79,28 @@ public function testMailIsSentWithLocaleFromMailable(): void ); } + public function testMailableLocaleCanBeCleared(): void + { + $mailable = (new TestMail)->locale('ar')->locale(null); + + Mail::to('test@mail.com')->send($mailable); + + $this->assertStringContainsString( + 'name', + $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->toString() + ); + } + + public function testPendingMailLocaleCanBeCleared(): void + { + Mail::to('test@mail.com')->locale('ar')->locale(null)->send(new TestMail); + + $this->assertStringContainsString( + 'name', + $this->app->make('mailer')->getSymfonyTransport()->messages()[0]->toString() + ); + } + public function testMailIsSentWithLocaleUpdatedListenersCalled(): void { CarbonImmutable::setTestNow('2018-04-01'); diff --git a/tests/Integration/Mail/SendingMarkdownMailTest.php b/tests/Integration/Mail/SendingMarkdownMailTest.php index 952bbbcd2d..f1cb82e5f6 100644 --- a/tests/Integration/Mail/SendingMarkdownMailTest.php +++ b/tests/Integration/Mail/SendingMarkdownMailTest.php @@ -286,7 +286,7 @@ public function content(): Content class MarkdownBasicMailableWithTextView extends Mailable { - public string $textView = 'text'; + public ?string $textView = 'text'; public function envelope(): Envelope { diff --git a/tests/Mail/MarkdownCoroutineSafetyTest.php b/tests/Mail/MarkdownCoroutineSafetyTest.php index b5fa48b4d3..2ca7fdbba4 100644 --- a/tests/Mail/MarkdownCoroutineSafetyTest.php +++ b/tests/Mail/MarkdownCoroutineSafetyTest.php @@ -472,7 +472,7 @@ public function renderText(string $view, array $data = []): HtmlString class ThemeProbeMailable extends Mailable { - public string $markdown = 'message'; + public ?string $markdown = 'message'; public function __construct(string $theme) { From a5b510390b96fa98d175713ac0a0cdb2bcdfdd7e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:47:20 +0000 Subject: [PATCH 04/25] Remove unreachable basic authentication returns Apply laravel/framework#61887 (e6e79ba330). failedBasicResponse() already has a native never return type; its callers now invoke it without a return expression. Successful authentication still returns null. AuthGuardTest, formatting and full static analysis pass. --- src/auth/src/SessionGuard.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth/src/SessionGuard.php b/src/auth/src/SessionGuard.php index a0a1a7e373..575f5ba4ed 100644 --- a/src/auth/src/SessionGuard.php +++ b/src/auth/src/SessionGuard.php @@ -315,7 +315,7 @@ public function basic(string $field = 'email', array $extraConditions = []): ?Re return null; } - return $this->failedBasicResponse(); + $this->failedBasicResponse(); } /** @@ -328,7 +328,7 @@ public function onceBasic(string $field = 'email', array $extraConditions = []): $credentials = $this->basicCredentials($this->getRequest(), $field); if (! $this->once(array_merge($credentials, $extraConditions))) { - return $this->failedBasicResponse(); + $this->failedBasicResponse(); } return null; From 411a9ed7bc23223d3ec6161412b78ea27bc9e303 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:47:20 +0000 Subject: [PATCH 05/25] Preserve pivot types in relationship results Apply laravel/framework#61954 (73a25a4ad3). Describe getResults() with the related model and pivot intersection, retaining the base collection native type for after-query callbacks. Add the missing custom-pivot and morph-pivot type assertions; existing default-pivot assertions remain. Formatting, source and type analysis, relation unit tests and Eloquent integration checks pass. --- src/database/src/Eloquent/Relations/BelongsToMany.php | 7 ++++++- types/Database/Eloquent/Relations.php | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 7a274321a8..fd142dca43 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -944,7 +944,12 @@ public function firstOr(Closure|array $columns = ['*'], ?Closure $callback = nul return $callback(); } - public function getResults() + /** + * Get the results of the relationship. + * + * @return EloquentCollection + */ + public function getResults(): BaseCollection { return ! is_null($this->parent->{$this->parentKey}) ? $this->get() diff --git a/types/Database/Eloquent/Relations.php b/types/Database/Eloquent/Relations.php index 9c19c21e4c..e9015268f3 100644 --- a/types/Database/Eloquent/Relations.php +++ b/types/Database/Eloquent/Relations.php @@ -109,6 +109,10 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType('Hypervel\Contracts\Pagination\Paginator', $user->roles()->simplePaginate()); assertType('Hypervel\Contracts\Pagination\CursorPaginator', $user->roles()->cursorPaginate(cursor: new Cursor(['id' => 1]))); + assertType("Hypervel\\Database\\Eloquent\\Relations\\BelongsToMany", $user->tenantRoles()); + assertType('Hypervel\Database\Eloquent\Collection', $user->tenantRoles()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $user->tenantRoles()->get()); + assertType('Hypervel\Database\Eloquent\Relations\HasOneThrough', $user->car()); assertType('Hypervel\Types\Relations\Car|null', $user->car()->getResults()); assertType('Hypervel\Database\Eloquent\Collection', $user->car()->find([1])); @@ -160,6 +164,7 @@ function test(User $user, Post $post, Comment $comment, ChildUser $child): void assertType("Hypervel\\Database\\Eloquent\\Relations\\MorphToMany", $post->tags()); assertType('Hypervel\Database\Eloquent\Collection', $post->tags()->getResults()); + assertType('Hypervel\Database\Eloquent\Collection', $post->tags()->get()); assertType('42', Relation::noConstraints(fn () => 42)); } From 3b232bce1ec95992d15945bb580cff77f17c5ecf Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:47:20 +0000 Subject: [PATCH 06/25] Accept model subtype collections in relationship predicates Apply laravel/framework#62002 (78a5c48ea3). Annotate belongs-to and attached-to predicates, including their or variants, with covariant model collection parameters. Port all six builder type assertions without changing query execution. Formatting and full source and type analysis pass. --- .../src/Eloquent/Concerns/QueriesRelationships.php | 8 ++++++-- types/Database/Eloquent/Builder.php | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/database/src/Eloquent/Concerns/QueriesRelationships.php b/src/database/src/Eloquent/Concerns/QueriesRelationships.php index 7ad960d817..ca7ab0a1e3 100644 --- a/src/database/src/Eloquent/Concerns/QueriesRelationships.php +++ b/src/database/src/Eloquent/Concerns/QueriesRelationships.php @@ -699,7 +699,7 @@ protected function getMorphTypeForQuery(string $model): string /** * Add a "belongs to" relationship where clause to the query. * - * @param EloquentCollection|Model $related + * @param EloquentCollection|Model $related * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -743,6 +743,8 @@ public function whereBelongsTo(mixed $related, ?string $relationshipName = null, /** * Add a "BelongsTo" relationship with an "or where" clause to the query. + * + * @param EloquentCollection|Model $related */ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = null): static { @@ -752,7 +754,7 @@ public function orWhereBelongsTo(mixed $related, ?string $relationshipName = nul /** * Add a "belongs to many" relationship where clause to the query. * - * @param EloquentCollection|Model $related + * @param EloquentCollection|Model $related * * @throws InvalidArgumentException * @throws RelationNotFoundException @@ -793,6 +795,8 @@ public function whereAttachedTo(mixed $related, ?string $relationshipName = null /** * Add a "belongs to many" relationship with an "or where" clause to the query. * + * @param EloquentCollection|Model $related + * * @throws RuntimeException */ public function orWhereAttachedTo(mixed $related, ?string $relationshipName = null): static diff --git a/types/Database/Eloquent/Builder.php b/types/Database/Eloquent/Builder.php index 2488cb1ae6..37a4891261 100644 --- a/types/Database/Eloquent/Builder.php +++ b/types/Database/Eloquent/Builder.php @@ -191,6 +191,12 @@ function test( assertType('Hypervel\Database\Eloquent\Builder', $query->withAvg('posts', $expression)); assertType('Hypervel\Database\Eloquent\Builder', $query->whereMorphedTo($post->taggable(), new Post)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereBelongsTo($post)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereBelongsTo(Post::query()->get())); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereBelongsTo(Post::query()->get())); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereAttachedTo($post)); + assertType('Hypervel\Database\Eloquent\Builder', $query->whereAttachedTo(Post::query()->get())); + assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereAttachedTo(Post::query()->get())); assertType('Hypervel\Database\Eloquent\Builder', $query->whereNotMorphedTo($post->taggable(), new Post)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereMorphedTo($post->taggable(), new Post)); assertType('Hypervel\Database\Eloquent\Builder', $query->orWhereNotMorphedTo($post->taggable(), new Post)); From c2cc115d8cb228a30cf275d1b588083e3953d17c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:47:20 +0000 Subject: [PATCH 07/25] Track component variable types and render deferred scalar values Apply laravel/framework#62018 (5cc36e3233), including its complete type fixture. Component variables preserve the resolver result type through invocation and method forwarding. The deferred display contract incorrectly restricted raw resolver results to strings and Htmlable objects, rejecting numeric and nullable component values before escaping could handle them. Return the original unconstrained resolver value. Normalize numeric values to strings only in the default HTML encoder, preserving values passed to custom encoders. Remove redundant test cleanup already owned by the framework subscriber. Regression tests reproduce both TypeErrors before correction. Formatting, full source and type analysis, the View suite and HTML encoder tests pass. --- .../src/Support/DeferringDisplayableValue.php | 2 +- src/support/src/EncodedHtmlString.php | 2 +- src/view/src/Component.php | 2 + src/view/src/InvokableComponentVariable.php | 16 +++++- .../Support/SupportEncodedHtmlStringTest.php | 9 +-- tests/View/ViewComponentTest.php | 34 +++++++++++ types/View/InvokableComponentVariable.php | 56 +++++++++++++++++++ 7 files changed, 113 insertions(+), 8 deletions(-) create mode 100644 types/View/InvokableComponentVariable.php diff --git a/src/contracts/src/Support/DeferringDisplayableValue.php b/src/contracts/src/Support/DeferringDisplayableValue.php index 4dae74aa76..0038ebfa92 100644 --- a/src/contracts/src/Support/DeferringDisplayableValue.php +++ b/src/contracts/src/Support/DeferringDisplayableValue.php @@ -9,5 +9,5 @@ interface DeferringDisplayableValue /** * Resolve the displayable value that the class is deferring. */ - public function resolveDisplayableValue(): Htmlable|string; + public function resolveDisplayableValue(): mixed; } diff --git a/src/support/src/EncodedHtmlString.php b/src/support/src/EncodedHtmlString.php index d74d125309..d93d43bfb3 100644 --- a/src/support/src/EncodedHtmlString.php +++ b/src/support/src/EncodedHtmlString.php @@ -66,7 +66,7 @@ public function toHtml(): string $factory = CoroutineContext::get(self::ENCODER_CONTEXT_KEY) ?? static::$encodeUsingFactory; return ($factory ?? function ($value, $doubleEncode) { - return static::convert($value, doubleEncode: $doubleEncode); + return static::convert((string) ($value ?? ''), doubleEncode: $doubleEncode); })($value, $this->doubleEncode); } diff --git a/src/view/src/Component.php b/src/view/src/Component.php index bd160b31d3..05f5c98c19 100644 --- a/src/view/src/Component.php +++ b/src/view/src/Component.php @@ -267,6 +267,8 @@ protected function createVariableFromMethod(ReflectionMethod $method): mixed /** * Create an invokable, toStringable variable for the given component method. + * + * @return InvokableComponentVariable */ protected function createInvokableVariable(string $method): InvokableComponentVariable { diff --git a/src/view/src/InvokableComponentVariable.php b/src/view/src/InvokableComponentVariable.php index 3e47a08ead..08e6f0c1fa 100644 --- a/src/view/src/InvokableComponentVariable.php +++ b/src/view/src/InvokableComponentVariable.php @@ -7,16 +7,22 @@ use ArrayIterator; use Closure; use Hypervel\Contracts\Support\DeferringDisplayableValue; -use Hypervel\Contracts\Support\Htmlable; use Hypervel\Support\Enumerable; use IteratorAggregate; use Stringable; use Traversable; +/** + * @template-covariant TValue = mixed + * + * @mixin TValue + */ class InvokableComponentVariable implements DeferringDisplayableValue, IteratorAggregate, Stringable { /** * Create a new variable instance. + * + * @param Closure(): TValue $callable */ public function __construct( protected Closure $callable @@ -25,14 +31,18 @@ public function __construct( /** * Resolve the displayable value that the class is deferring. + * + * @return TValue */ - public function resolveDisplayableValue(): Htmlable|string + public function resolveDisplayableValue(): mixed { return $this->__invoke(); } /** * Get an iterator instance for the variable. + * + * @return ArrayIterator */ public function getIterator(): Traversable { @@ -59,6 +69,8 @@ public function __call(string $method, array $parameters): mixed /** * Resolve the variable. + * + * @return TValue */ public function __invoke(): mixed { diff --git a/tests/Support/SupportEncodedHtmlStringTest.php b/tests/Support/SupportEncodedHtmlStringTest.php index ee9dfb2042..5eaa35f503 100644 --- a/tests/Support/SupportEncodedHtmlStringTest.php +++ b/tests/Support/SupportEncodedHtmlStringTest.php @@ -6,16 +6,17 @@ use Hypervel\Support\EncodedHtmlString; use Hypervel\Tests\TestCase; +use Hypervel\View\InvokableComponentVariable; use function Hypervel\Coroutine\parallel; class SupportEncodedHtmlStringTest extends TestCase { - protected function tearDown(): void + public function testNumericAndDeferredValuesCanBeEncoded(): void { - EncodedHtmlString::flushState(); - - parent::tearDown(); + $this->assertSame('42', (new EncodedHtmlString(42))->toHtml()); + $this->assertSame('1.5', (new EncodedHtmlString(1.5))->toHtml()); + $this->assertSame('42', (new EncodedHtmlString(new InvokableComponentVariable(fn () => 42)))->toHtml()); } public function testScopedEncodingOverridesBootEncoderAndRestoresNestedState(): void diff --git a/tests/View/ViewComponentTest.php b/tests/View/ViewComponentTest.php index ad4fbdd0e1..5794cd52d2 100644 --- a/tests/View/ViewComponentTest.php +++ b/tests/View/ViewComponentTest.php @@ -26,6 +26,40 @@ public function testDataExposure(): void $this->assertSame('taylor', $variables['hello']('taylor')); } + public function testDeferredMethodValuesCanBeEscaped(): void + { + $component = new class extends Component { + /** + * Get the view that represents the component. + */ + public function render(): string + { + return 'test'; + } + + /** + * Get the vote count. + */ + public function votes(): int + { + return 42; + } + + /** + * Get the optional label. + */ + public function label(): ?string + { + return null; + } + }; + + $variables = $component->data(); + + $this->assertSame('42', e($variables['votes'])); + $this->assertSame('', e($variables['label'])); + } + public function testIgnoredMethodsAreNotExposedToViewData(): void { $component = new class extends Component { diff --git a/types/View/InvokableComponentVariable.php b/types/View/InvokableComponentVariable.php new file mode 100644 index 0000000000..ac3f92406a --- /dev/null +++ b/types/View/InvokableComponentVariable.php @@ -0,0 +1,56 @@ + true); + +assertType('Hypervel\View\InvokableComponentVariable', $variable); +assertType('true', $variable()); + +/** + * Verify deferred component variable types. + * + * @param InvokableComponentVariable $flag + * @param InvokableComponentVariable $pair + * @param InvokableComponentVariable $nullable + * @param InvokableComponentVariable $user + * @param InvokableComponentVariable> $users + */ +function test( + InvokableComponentVariable $flag, + InvokableComponentVariable $pair, + InvokableComponentVariable $nullable, + InvokableComponentVariable $untyped, + InvokableComponentVariable $user, + InvokableComponentVariable $users, +): void { + assertType('bool', $flag()); + assertType('array{string, int}', $pair()); + assertType('string|null', $nullable()); + assertType('mixed', $untyped()); + assertType('mixed', $untyped->anything); + + assertType('string', $user->getTable()); + assertType('int<0, max>', $users->count()); + assertType('User|null', $users->first()); + assertType('ArrayIterator', $users->getIterator()); + + acceptsNullableBool($flag); +} + +/** + * Accept a covariant nullable boolean variable. + * + * @param InvokableComponentVariable $variable + */ +function acceptsNullableBool(InvokableComponentVariable $variable): void +{ +} From eed9e51c34365eabbd9620b5720d4093f312ca69 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:47:20 +0000 Subject: [PATCH 08/25] Accept stringable conditional validation rules Apply laravel/framework#62030 (940f3452ab) to native Rule::when(), Rule::unless() and ConditionalRules signatures. Stringable builders such as Rule::in() no longer throw a TypeError. ConditionalRules now normalizes a selected rule object or callback result into the documented rule array. Previously, assigning a conditional rule directly to an attribute passed an object or string to array_filter(). Split callback-returned rule strings like ordinary rule strings, and construct Fluent input only when invoking a callback. Regression coverage exercises when/unless, primary/default rules, direct/nested placement and callback strings. Formatting, full source and type analysis and the Validation suite pass. --- src/validation/src/ConditionalRules.php | 30 +++++++++---------- src/validation/src/Rule.php | 9 +++--- tests/Validation/ValidationRuleParserTest.php | 28 +++++++++++++++++ 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/src/validation/src/ConditionalRules.php b/src/validation/src/ConditionalRules.php index fe3cdb4724..ffa110a949 100644 --- a/src/validation/src/ConditionalRules.php +++ b/src/validation/src/ConditionalRules.php @@ -7,7 +7,9 @@ use Closure; use Hypervel\Contracts\Validation\Rule; use Hypervel\Contracts\Validation\ValidationRule; +use Hypervel\Support\Arr; use Hypervel\Support\Fluent; +use Stringable; class ConditionalRules { @@ -20,13 +22,13 @@ class ConditionalRules * Create a new conditional rules instance. * * @param bool|callable $condition the boolean condition indicating if the rules should be added to the attribute - * @param array|Closure|Rule|string|ValidationRule $rules the rules to be added to the attribute - * @param array|Closure|Rule|string|ValidationRule $defaultRules the rules to be added to the attribute if the condition fails + * @param array|Closure|Rule|string|Stringable|ValidationRule $rules the rules to be added to the attribute + * @param array|Closure|Rule|string|Stringable|ValidationRule $defaultRules the rules to be added to the attribute if the condition fails */ public function __construct( bool|callable $condition, - protected array|Closure|Rule|string|ValidationRule $rules, - protected array|Closure|Rule|string|ValidationRule $defaultRules = [] + protected array|Closure|Rule|string|Stringable|ValidationRule $rules, + protected array|Closure|Rule|string|Stringable|ValidationRule $defaultRules = [] ) { $this->condition = is_bool($condition) ? $condition : $condition(...); } @@ -43,25 +45,21 @@ public function passes(array $data = []): bool /** * Get the rules. - * - * @return array */ - public function rules(array $data = []): mixed + public function rules(array $data = []): array { - return is_string($this->rules) - ? explode('|', $this->rules) - : value($this->rules, new Fluent($data)); + $rules = $this->rules instanceof Closure ? ($this->rules)(new Fluent($data)) : $this->rules; + + return is_string($rules) ? explode('|', $rules) : Arr::wrap($rules); } /** * Get the default rules. - * - * @return array */ - public function defaultRules(array $data = []): mixed + public function defaultRules(array $data = []): array { - return is_string($this->defaultRules) - ? explode('|', $this->defaultRules) - : value($this->defaultRules, new Fluent($data)); + $rules = $this->defaultRules instanceof Closure ? ($this->defaultRules)(new Fluent($data)) : $this->defaultRules; + + return is_string($rules) ? explode('|', $rules) : Arr::wrap($rules); } } diff --git a/src/validation/src/Rule.php b/src/validation/src/Rule.php index 047ad9c7cd..ed242816cb 100644 --- a/src/validation/src/Rule.php +++ b/src/validation/src/Rule.php @@ -36,6 +36,7 @@ use Hypervel\Validation\Rules\StringRule; use Hypervel\Validation\Rules\Unique; use InvalidArgumentException; +use Stringable; use UnitEnum; class Rule @@ -63,8 +64,8 @@ public static function can(string $ability, mixed ...$arguments): Can */ public static function when( bool|callable $condition, - array|Closure|RuleContract|string|ValidationRule $rules, - array|Closure|RuleContract|string|ValidationRule $defaultRules = [] + array|Closure|RuleContract|string|Stringable|ValidationRule $rules, + array|Closure|RuleContract|string|Stringable|ValidationRule $defaultRules = [] ): ConditionalRules { return new ConditionalRules($condition, $rules, $defaultRules); } @@ -74,8 +75,8 @@ public static function when( */ public static function unless( bool|callable $condition, - array|Closure|RuleContract|string|ValidationRule $rules, - array|Closure|RuleContract|string|ValidationRule $defaultRules = [] + array|Closure|RuleContract|string|Stringable|ValidationRule $rules, + array|Closure|RuleContract|string|Stringable|ValidationRule $defaultRules = [] ): ConditionalRules { return new ConditionalRules($condition, $defaultRules, $rules); } diff --git a/tests/Validation/ValidationRuleParserTest.php b/tests/Validation/ValidationRuleParserTest.php index 31bbca1746..abacb94776 100644 --- a/tests/Validation/ValidationRuleParserTest.php +++ b/tests/Validation/ValidationRuleParserTest.php @@ -116,6 +116,34 @@ public function testConditionalRulesAcceptNonClosureCallableConditions(): void ], $rules); } + #[TestWith(['when', true, 'in:"allowed"'])] + #[TestWith(['when', false, 'in:"fallback"'])] + #[TestWith(['unless', true, 'in:"fallback"'])] + #[TestWith(['unless', false, 'in:"allowed"'])] + public function testConditionalRulesAcceptStringableRules(string $method, bool $condition, string $expected): void + { + $rule = Rule::{$method}($condition, Rule::in(['allowed']), Rule::in(['fallback'])); + + $rules = ValidationRuleParser::filterConditionalRules([ + 'direct' => $rule, + 'nested' => [$rule], + ]); + + $this->assertSame($expected, (string) $rules['direct'][0]); + $this->assertSame($expected, (string) $rules['nested'][0]); + } + + #[TestWith([true])] + #[TestWith([false])] + public function testConditionalRuleCallbacksReturnRuleLists(bool $condition): void + { + $rule = Rule::when($condition, fn () => 'required|min:2', fn () => 'nullable|max:10'); + + $this->assertSame([ + 'name' => $condition ? ['required', 'min:2'] : ['nullable', 'max:10'], + ], ValidationRuleParser::filterConditionalRules(['name' => $rule])); + } + /** * Determine if the conditional test rules apply. */ From 418d252add776e065f01326f0495ac04c4812afe Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:57:07 +0000 Subject: [PATCH 09/25] Accept nullable remember tokens and passwordless generic users Apply laravel/framework#62105 (5c0486f5a5) and #61876 (df12f83ce8). The Authenticatable contract, trait and GenericUser now accept null when clearing a remember token. Update implementing fixtures and the authentication contract example to match. GenericUser returns null when its password attribute is absent, avoiding a warning during passwordless login. Port the complete upstream passwordless-login regression and add token-clearing regressions for both user implementations. Both defects reproduce before correction. Formatting, full source and type analysis, the Auth suite and every affected Broadcasting, JWT, Sanctum and Telescope test file pass. --- src/auth/src/Authenticatable.php | 2 +- src/auth/src/GenericUser.php | 4 ++-- src/contracts/src/Auth/Authenticatable.php | 2 +- src/docs/authentication.md | 2 +- tests/Auth/AuthGuardTest.php | 23 +++++++++++++++++++ tests/Auth/AuthTokenGuardTest.php | 2 +- tests/Auth/AuthenticatableTest.php | 9 ++++++++ tests/Auth/GenericUserTest.php | 8 +++++++ tests/Broadcasting/BroadcasterTest.php | 2 +- tests/Jwt/ClaimFactoryTest.php | 5 +++- tests/Jwt/JwtGuardCoroutineSafetyTest.php | 5 +++- tests/Jwt/JwtGuardSubjectLockTest.php | 5 +++- tests/Sanctum/AuthenticateSessionTest.php | 5 +++- .../Sanctum/Fixtures/DummyAuthenticatable.php | 5 +++- tests/Telescope/Http/AuthorizationTest.php | 5 +++- tests/Telescope/Http/AvatarTest.php | 5 +++- tests/Telescope/Watchers/GateWatcherTest.php | 5 +++- 17 files changed, 79 insertions(+), 15 deletions(-) diff --git a/src/auth/src/Authenticatable.php b/src/auth/src/Authenticatable.php index 9032b5d689..46509d6859 100644 --- a/src/auth/src/Authenticatable.php +++ b/src/auth/src/Authenticatable.php @@ -71,7 +71,7 @@ public function getRememberToken(): ?string /** * Set the token value for the "remember me" session. */ - public function setRememberToken(string $value): void + public function setRememberToken(?string $value): void { if (! empty($this->getRememberTokenName())) { $this->{$this->getRememberTokenName()} = $value; diff --git a/src/auth/src/GenericUser.php b/src/auth/src/GenericUser.php index 736437ca73..6ad10780df 100755 --- a/src/auth/src/GenericUser.php +++ b/src/auth/src/GenericUser.php @@ -45,7 +45,7 @@ public function getAuthPasswordName(): string */ public function getAuthPassword(): ?string { - return $this->attributes[$this->getAuthPasswordName()]; + return $this->attributes[$this->getAuthPasswordName()] ?? null; } /** @@ -59,7 +59,7 @@ public function getRememberToken(): ?string /** * Set the "remember me" token value. */ - public function setRememberToken(string $value): void + public function setRememberToken(?string $value): void { $this->attributes[$this->getRememberTokenName()] = $value; } diff --git a/src/contracts/src/Auth/Authenticatable.php b/src/contracts/src/Auth/Authenticatable.php index 7bf3497da6..d0b743dad4 100644 --- a/src/contracts/src/Auth/Authenticatable.php +++ b/src/contracts/src/Auth/Authenticatable.php @@ -34,7 +34,7 @@ public function getRememberToken(): ?string; /** * Set the token value for the "remember me" session. */ - public function setRememberToken(string $value): void; + public function setRememberToken(?string $value): void; /** * Get the column name for the "remember me" token. diff --git a/src/docs/authentication.md b/src/docs/authentication.md index f08113a5e1..53ffff24df 100644 --- a/src/docs/authentication.md +++ b/src/docs/authentication.md @@ -1123,7 +1123,7 @@ interface Authenticatable public function getAuthPasswordName(): string; public function getAuthPassword(): ?string; public function getRememberToken(): ?string; - public function setRememberToken(string $value): void; + public function setRememberToken(?string $value): void; public function getRememberTokenName(): string; } ``` diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index d65bf5ff58..33725591d9 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -288,6 +288,29 @@ public function testLoginStoresPasswordHashInSession(): void $this->assertNotSame($sessionId, $session->getId()); } + public function testLoginWithGenericUserWithoutPasswordDoesNotStorePasswordHash(): void + { + [$guard, , $session] = $this->getRealGuard(); + $user = new GenericUser(['id' => 'foo']); + $warnings = []; + + set_error_handler(function (int $level, string $message) use (&$warnings): bool { + $warnings[] = $message; + + return true; + }, E_WARNING); + + try { + $guard->login($user); + } finally { + restore_error_handler(); + } + + $this->assertSame([], $warnings); + $this->assertSame('foo', $session->get($guard->getName())); + $this->assertFalse($session->has('password_hash_default')); + } + public function testSessionGuardIsMacroable(): void { $guard = $this->getGuard(); diff --git a/tests/Auth/AuthTokenGuardTest.php b/tests/Auth/AuthTokenGuardTest.php index 14bc5d3ada..08506fb788 100644 --- a/tests/Auth/AuthTokenGuardTest.php +++ b/tests/Auth/AuthTokenGuardTest.php @@ -586,7 +586,7 @@ public function getRememberToken(): ?string /** * Set the remember token. */ - public function setRememberToken(string $value): void + public function setRememberToken(?string $value): void { } diff --git a/tests/Auth/AuthenticatableTest.php b/tests/Auth/AuthenticatableTest.php index a6700bf375..db393be304 100644 --- a/tests/Auth/AuthenticatableTest.php +++ b/tests/Auth/AuthenticatableTest.php @@ -16,6 +16,15 @@ public function testItReturnsSameRememberTokenForString(): void $this->assertSame('sample_token', $user->getRememberToken()); } + public function testRememberTokenCanBeCleared(): void + { + $user = new User; + $user->setRememberToken('sample_token'); + $user->setRememberToken(null); + + $this->assertNull($user->getAttribute($user->getRememberTokenName())); + } + // REMOVED: testItReturnsStringAsRememberTokenWhenItWasSetToTrue // Tests implicit bool-to-string coercion which is a TypeError under strict_types. diff --git a/tests/Auth/GenericUserTest.php b/tests/Auth/GenericUserTest.php index 810d17c196..bcee3db3b5 100644 --- a/tests/Auth/GenericUserTest.php +++ b/tests/Auth/GenericUserTest.php @@ -53,6 +53,14 @@ public function testSetRememberTokenUpdatesToken(): void $this->assertSame('new', $user->getRememberToken()); } + public function testRememberTokenCanBeCleared(): void + { + $user = new GenericUser(['id' => 1, 'remember_token' => 'old']); + $user->setRememberToken(null); + + $this->assertNull($user->getRememberToken()); + } + public function testGetRememberTokenNameReturnsColumnName(): void { $user = new GenericUser(['id' => 1]); diff --git a/tests/Broadcasting/BroadcasterTest.php b/tests/Broadcasting/BroadcasterTest.php index 06064fde5e..3c399b4b06 100644 --- a/tests/Broadcasting/BroadcasterTest.php +++ b/tests/Broadcasting/BroadcasterTest.php @@ -838,7 +838,7 @@ public function getRememberToken(): ?string /** * Set the remember token for the user. */ - public function setRememberToken(string $value): void + public function setRememberToken(?string $value): void { } diff --git a/tests/Jwt/ClaimFactoryTest.php b/tests/Jwt/ClaimFactoryTest.php index 8e94a9f11e..eadc113367 100644 --- a/tests/Jwt/ClaimFactoryTest.php +++ b/tests/Jwt/ClaimFactoryTest.php @@ -387,7 +387,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Jwt/JwtGuardCoroutineSafetyTest.php b/tests/Jwt/JwtGuardCoroutineSafetyTest.php index c7e36decb9..dcb0e5b5fc 100644 --- a/tests/Jwt/JwtGuardCoroutineSafetyTest.php +++ b/tests/Jwt/JwtGuardCoroutineSafetyTest.php @@ -198,7 +198,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Jwt/JwtGuardSubjectLockTest.php b/tests/Jwt/JwtGuardSubjectLockTest.php index 87f4210daa..50e0558cc4 100644 --- a/tests/Jwt/JwtGuardSubjectLockTest.php +++ b/tests/Jwt/JwtGuardSubjectLockTest.php @@ -156,7 +156,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Sanctum/AuthenticateSessionTest.php b/tests/Sanctum/AuthenticateSessionTest.php index bca9370d30..c3fcf187e1 100644 --- a/tests/Sanctum/AuthenticateSessionTest.php +++ b/tests/Sanctum/AuthenticateSessionTest.php @@ -423,7 +423,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Sanctum/Fixtures/DummyAuthenticatable.php b/tests/Sanctum/Fixtures/DummyAuthenticatable.php index cb69ea7f8c..e35ecb4b11 100644 --- a/tests/Sanctum/Fixtures/DummyAuthenticatable.php +++ b/tests/Sanctum/Fixtures/DummyAuthenticatable.php @@ -37,7 +37,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Telescope/Http/AuthorizationTest.php b/tests/Telescope/Http/AuthorizationTest.php index e308b81ae0..44c525ad68 100644 --- a/tests/Telescope/Http/AuthorizationTest.php +++ b/tests/Telescope/Http/AuthorizationTest.php @@ -143,7 +143,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Telescope/Http/AvatarTest.php b/tests/Telescope/Http/AvatarTest.php index b7d7b6f515..0d7f795ec4 100644 --- a/tests/Telescope/Http/AvatarTest.php +++ b/tests/Telescope/Http/AvatarTest.php @@ -173,7 +173,10 @@ public function getRememberToken(): ?string return null; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } diff --git a/tests/Telescope/Watchers/GateWatcherTest.php b/tests/Telescope/Watchers/GateWatcherTest.php index 540cf60dbe..c25e52877b 100644 --- a/tests/Telescope/Watchers/GateWatcherTest.php +++ b/tests/Telescope/Watchers/GateWatcherTest.php @@ -374,7 +374,10 @@ public function getRememberToken(): ?string return 'i-am-telescope'; } - public function setRememberToken(string $value): void + /** + * Set the remember token. + */ + public function setRememberToken(?string $value): void { } From ebf82cd0c5cad759c35df43c61d72eccc7ff73eb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:00:49 +0000 Subject: [PATCH 10/25] Remove duplicate assertions and unused Blade test helpers Apply laravel/framework#61976 (4008fb3620). Keep one copy of each echo, elseif and nested-directive case, remove two unused component fixtures and drop unused local assignments. The anonymous-component and view-factory cleanups were already present. All distinct assertions remain covered. Each changed test file and formatting pass; source and type fixtures are unchanged from the passing analysis. --- tests/View/Blade/BladeEchoTest.php | 2 -- tests/View/Blade/BladeElseStatementsTest.php | 15 --------------- tests/View/Blade/BladePhpStatementsTest.php | 4 ---- tests/View/ComponentTest.php | 8 -------- tests/View/ViewBladeCompilerTest.php | 6 +++--- tests/View/ViewComponentTest.php | 13 ------------- 6 files changed, 3 insertions(+), 45 deletions(-) diff --git a/tests/View/Blade/BladeEchoTest.php b/tests/View/Blade/BladeEchoTest.php index 113011d2f1..54c067c595 100644 --- a/tests/View/Blade/BladeEchoTest.php +++ b/tests/View/Blade/BladeEchoTest.php @@ -22,8 +22,6 @@ public function testEchosAreCompiled(): void }}')); $this->assertSame("\n\n", $this->compiler->compileString("{{ \$name }}\n")); $this->assertSame("\r\n\r\n", $this->compiler->compileString("{{ \$name }}\r\n")); - $this->assertSame("\n\n", $this->compiler->compileString("{{ \$name }}\n")); - $this->assertSame("\r\n\r\n", $this->compiler->compileString("{{ \$name }}\r\n")); $this->assertSame( '', diff --git a/tests/View/Blade/BladeElseStatementsTest.php b/tests/View/Blade/BladeElseStatementsTest.php index c437af0809..39f64a0ef7 100644 --- a/tests/View/Blade/BladeElseStatementsTest.php +++ b/tests/View/Blade/BladeElseStatementsTest.php @@ -17,21 +17,6 @@ public function testElseStatementsAreCompiled(): void breeze boom -'; - $this->assertEquals($expected, $this->compiler->compileString($string)); - } - - public function testElseIfStatementsAreCompiled(): void - { - $string = '@if(name(foo(bar))) -breeze -@elseif(boom(breeze)) -boom -@endif'; - $expected = ' -breeze - -boom '; $this->assertEquals($expected, $this->compiler->compileString($string)); } diff --git a/tests/View/Blade/BladePhpStatementsTest.php b/tests/View/Blade/BladePhpStatementsTest.php index b5d2038a10..a2b50a9b52 100644 --- a/tests/View/Blade/BladePhpStatementsTest.php +++ b/tests/View/Blade/BladePhpStatementsTest.php @@ -158,10 +158,6 @@ public function testNestedTagCalls(): void $expected = ''; $this->assertEquals($expected, $this->compiler->compileString($string)); - $string = " @empty(\$v), 't' => @empty(\$v1)])>"; - $expected = ''; - $this->assertEquals($expected, $this->compiler->compileString($string)); - $string = " @empty(\$v), 't' => @empty(\$v1), 'r' => @empty(\$v2)])>"; $expected = ''; $this->assertEquals($expected, $this->compiler->compileString($string)); diff --git a/tests/View/ComponentTest.php b/tests/View/ComponentTest.php index 28d2934dcd..a6fc18e919 100644 --- a/tests/View/ComponentTest.php +++ b/tests/View/ComponentTest.php @@ -604,14 +604,6 @@ public function render(): ViewContract|Htmlable|Closure|string } } -class TestInlineViewComponentWithoutDependencies extends Component -{ - public function render(): ViewContract|Htmlable|Closure|string - { - return 'alert'; - } -} - class TestStatefulInlineComponent extends Component { public static int $constructed = 0; diff --git a/tests/View/ViewBladeCompilerTest.php b/tests/View/ViewBladeCompilerTest.php index 91de42ad35..9cb5ae05f4 100644 --- a/tests/View/ViewBladeCompilerTest.php +++ b/tests/View/ViewBladeCompilerTest.php @@ -87,7 +87,7 @@ public function testIsExpiredReturnsFalseWhenUseCacheIsTrueAndNoFileModification public function testIsExpiredReturnsTrueWhenUseCacheIsFalse(): void { - $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__, shouldCache: false); + $compiler = new BladeCompiler($this->getFiles(), __DIR__, shouldCache: false); $this->assertTrue($compiler->isExpired('foo')); } @@ -256,7 +256,7 @@ public function testCompileCompilesAndGetThePath(): void public function testCompileSetAndGetThePath(): void { - $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $compiler = new BladeCompiler($this->getFiles(), __DIR__); $compiler->setPath('foo'); $this->assertSame('foo', $compiler->getPath()); } @@ -570,7 +570,7 @@ public function testIncludesZeroPath(): void public function testShouldStartFromStrictTypesDeclaration(): void { - $compiler = new BladeCompiler($files = $this->getFiles(), __DIR__); + $compiler = new BladeCompiler($this->getFiles(), __DIR__); $strictTypeDecl = "assertSame(substr( $compiler->compileString("withAttributes(['class' => 'text-red-500']); - } - - public function render(): ViewContract|Htmlable|Closure|string - { - return $this->attributes->get('id'); - } -} From 9829cf5c609ea8b1d40f8231dddbbec67348491e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 11/25] Ignore explicit null eager-loading callbacks Apply laravel/framework#61769 (a10e706f93). Remove empty positional arguments before parsing string relationship names, so with("orders", null) does not become an integer relationship name. The upstream regression reproduced the TypeError; the database suite, formatting and full analysis pass. --- src/database/src/Eloquent/Builder.php | 2 +- tests/Database/DatabaseEloquentBuilderTest.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Builder.php b/src/database/src/Eloquent/Builder.php index 9cb04ce1e7..42925cdab6 100644 --- a/src/database/src/Eloquent/Builder.php +++ b/src/database/src/Eloquent/Builder.php @@ -1673,7 +1673,7 @@ public function with(array|string $relations, Closure|string|null $callback = nu if ($callback instanceof Closure) { $eagerLoad = $this->parseWithRelations([$relations => $callback]); } else { - $eagerLoad = $this->parseWithRelations(is_string($relations) ? func_get_args() : $relations); + $eagerLoad = $this->parseWithRelations(is_string($relations) ? array_filter(func_get_args()) : $relations); } $this->eagerLoad = array_merge($this->eagerLoad, $eagerLoad); diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index cb92efaf11..4a4ee8becf 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -1046,6 +1046,13 @@ public function testEagerLoadParsingSetsProperRelationships(): void $this->assertInstanceOf(Closure::class, $eagers['orders']); $this->assertInstanceOf(Closure::class, $eagers['orders.lines']); + $builder = $this->getBuilder(); + $builder->with('orders', null); + $eagers = $builder->getEagerLoads(); + + $this->assertEquals(['orders'], array_keys($eagers)); + $this->assertInstanceOf(Closure::class, $eagers['orders']); + $builder = $this->getBuilder(); $builder->with(['orders.lines']); $eagers = $builder->getEagerLoads(); From c4dad6f97f56bd230abe17f6eb18fb9b93530641 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 12/25] Verify payload callbacks register without resolving a queue Apply laravel/framework#61773 (68b248f8da). Consolidate the existing no-resolution test under the upstream case and verify the registered callback returns its payload. Use an unconfigured connection in place of Laravel Cloud; framework cleanup already resets payload callbacks. The test file, formatting and analysis pass. --- tests/Queue/QueueManagerTest.php | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/tests/Queue/QueueManagerTest.php b/tests/Queue/QueueManagerTest.php index 96eac97d23..41999d9c2e 100644 --- a/tests/Queue/QueueManagerTest.php +++ b/tests/Queue/QueueManagerTest.php @@ -21,6 +21,7 @@ use Hypervel\Queue\Connectors\SyncConnector; use Hypervel\Queue\Jobs\Job; use Hypervel\Queue\NullQueue; +use Hypervel\Queue\Queue as BaseQueue; use Hypervel\Queue\QueueManager; use Hypervel\Queue\QueuePoolProxy; use Hypervel\Queue\SyncQueue; @@ -28,6 +29,7 @@ use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionProperty; use RuntimeException; class QueueManagerTest extends TestCase @@ -152,6 +154,21 @@ public function testEnumConnectionCanBeChecked(): void $this->assertTrue($manager->connected(QueueConnectionName::Sync)); } + public function testCreatePayloadUsingDoesNotResolveTheDefaultConnection(): void + { + $container = $this->getContainer(); + $container->make('config')->set('queue.default', 'missing'); + + $manager = new QueueManager($container); + $manager->createPayloadUsing(static fn (?string $connection, ?string $queue, ?array $payload): array => ['foo' => 'bar']); + + $this->assertFalse($manager->connected('missing')); + + $callbacks = (new ReflectionProperty(BaseQueue::class, 'createPayloadCallbacks'))->getValue(); + + $this->assertSame(['foo' => 'bar'], $callbacks[array_key_last($callbacks)](null, null, null)); + } + public function testSetDefaultDriverAcceptsBackedEnum(): void { $container = $this->getContainer(); @@ -494,18 +511,6 @@ public function testSetApplicationUpdatesCachedDirectQueueInPlace(): void $this->assertSame($queue, $manager->connection('sync')); } - public function testPayloadCallbacksCanBeRegisteredWithoutResolvingTheDefaultConnection(): void - { - $container = $this->getContainer(); - $container->make('config')->set('queue.default', 'missing'); - - $manager = new QueueManager($container); - - $manager->createPayloadUsing(static fn (string $connection, ?string $queue, array $payload): array => []); - - $this->assertFalse($manager->connected('missing')); - } - /** * Create a manager with real failover and null connectors. */ From 618a34d2d8c8f61880b30306f8d3810169814dd2 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 13/25] Include single-server scheduling in JSON output Apply laravel/framework#61798 (4543bc5fe0). schedule:list --json now exposes on_one_server for each task. Port the default-false assertion and command/callback coverage, and document the field. Scheduling command tests, formatting and full analysis pass. --- .../src/Commands/ScheduleListCommand.php | 1 + src/docs/scheduling.md | 2 +- .../Scheduling/ScheduleListCommandTest.php | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/console/src/Commands/ScheduleListCommand.php b/src/console/src/Commands/ScheduleListCommand.php index 3dd7239971..2f3760bca2 100644 --- a/src/console/src/Commands/ScheduleListCommand.php +++ b/src/console/src/Commands/ScheduleListCommand.php @@ -118,6 +118,7 @@ protected function displayJson(Collection $events, DateTimeZone $timezone): void 'has_mutex' => $event->mutex->exists($event), 'repeat_seconds' => $event->isRepeatable() ? $event->repeatSeconds : null, 'environments' => $event->environments, + 'on_one_server' => $event->onOneServer, ]; })->values()->toJson(), OutputInterface::OUTPUT_RAW); } diff --git a/src/docs/scheduling.md b/src/docs/scheduling.md index fef52fd6eb..6195364d64 100644 --- a/src/docs/scheduling.md +++ b/src/docs/scheduling.md @@ -69,7 +69,7 @@ If you would like to view an overview of your scheduled tasks and the next time php artisan schedule:list ``` -The `schedule:list` command also supports `--timezone`, `--environment`, `--next`, and `--json` options for customizing the displayed timezone, filtering by environment, sorting by the next due date, or returning the schedule as JSON. The `--environment` option may be repeated to include tasks from multiple environments: +The `schedule:list` command also supports `--timezone`, `--environment`, `--next`, and `--json` options for customizing the displayed timezone, filtering by environment, sorting by the next due date, or returning the schedule as JSON. JSON output includes an `on_one_server` field indicating whether each task is configured to run on one server. The `--environment` option may be repeated to include tasks from multiple environments: ```shell php artisan schedule:list --environment=staging --environment=production diff --git a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php index bc150a7320..f49481520c 100644 --- a/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php +++ b/tests/Integration/Console/Scheduling/ScheduleListCommandTest.php @@ -111,6 +111,7 @@ public function testDisplayScheduleAsJson(): void $this->assertFalse($data[0]['has_mutex']); $this->assertIsArray($data[0]['environments']); $this->assertEmpty($data[0]['environments']); + $this->assertFalse($data[0]['on_one_server']); $this->assertSame('* * * * *', $data[2]['expression']); $this->assertSame('php artisan foobar a=' . ProcessUtils::escapeArgument('b'), $data[2]['command']); @@ -155,6 +156,24 @@ public function testDisplayScheduleAsJsonWithEnvironmentData(): void $this->assertContains($environment, $data[0]['environments']); } + public function testDisplayScheduleAsJsonWithOnOneServerData(): void + { + $this->schedule->command(FooCommand::class)->quarterly()->onOneServer(); + $this->schedule->command('inspire')->everyMinute(); + $this->schedule->call(fn () => '')->name('foo-callback')->everyMinute()->onOneServer(); + + $this->withoutMockingConsoleOutput()->artisan(ScheduleListCommand::class, ['--json' => true]); + $output = Artisan::output(); + + $this->assertJson($output); + $data = json_decode($output, true); + + $this->assertIsArray($data); + $this->assertCount(3, $data); + + $this->assertSame([true, false, true], array_column($data, 'on_one_server')); + } + public function testDisplayScheduleWithSortAsJson(): void { $this->schedule->command(FooCommand::class)->quarterly(); From 7ef5169e143696f3e23e3a263c29813a73409013 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 14/25] Skip regular expressions for simple string prefixes and suffixes Apply laravel/framework#61803 (77b2bd618c). Str::start() and finish() return directly for empty, absent or single prefixes and suffixes; repeated values retain the existing replacement behavior. Port all upstream empty-string and trailing-slash cases. String, Stringable and type checks pass with formatting. --- src/support/src/Str.php | 24 ++++++++++++++++++++++++ tests/Support/SupportStrTest.php | 7 +++++++ 2 files changed, 31 insertions(+) diff --git a/src/support/src/Str.php b/src/support/src/Str.php index 1b0de18d8c..4d763d1ddd 100644 --- a/src/support/src/Str.php +++ b/src/support/src/Str.php @@ -427,6 +427,18 @@ public static function excerpt(string|int|float|bool|BaseStringable|null $text, */ public static function finish(string $value, string $cap): string { + if ($cap === '') { + return $value; + } + + if (! str_ends_with($value, $cap)) { + return $value . $cap; + } + + if (! str_ends_with($value, $cap . $cap)) { + return $value; + } + $quoted = preg_quote($cap, '/'); return preg_replace('/(?:' . $quoted . ')+$/u', '', $value) . $cap; @@ -1295,6 +1307,18 @@ public static function reverse(string $value): string */ public static function start(string $value, string $prefix): string { + if ($prefix === '') { + return $value; + } + + if (! str_starts_with($value, $prefix)) { + return $prefix . $value; + } + + if (! str_starts_with($value, $prefix . $prefix)) { + return $value; + } + $quoted = preg_quote($prefix, '/'); return $prefix . preg_replace('/^(?:' . $quoted . ')+/u', '', $value); diff --git a/tests/Support/SupportStrTest.php b/tests/Support/SupportStrTest.php index a8d1578d03..0c631f59cf 100644 --- a/tests/Support/SupportStrTest.php +++ b/tests/Support/SupportStrTest.php @@ -700,6 +700,8 @@ public function testStrStart(): void $this->assertSame('/test/string', Str::start('test/string', '/')); $this->assertSame('/test/string', Str::start('/test/string', '/')); $this->assertSame('/test/string', Str::start('//test/string', '/')); + $this->assertSame('test/string', Str::start('test/string', '')); + $this->assertSame('', Str::start('', '')); } public function testFlushCache(): void @@ -715,6 +717,11 @@ public function testFinish(): void $this->assertSame('abbc', Str::finish('ab', 'bc')); $this->assertSame('abbc', Str::finish('abbcbc', 'bc')); $this->assertSame('abcbbc', Str::finish('abcbbcbc', 'bc')); + $this->assertSame('test/string/', Str::finish('test/string', '/')); + $this->assertSame('test/string/', Str::finish('test/string/', '/')); + $this->assertSame('test/string/', Str::finish('test/string//', '/')); + $this->assertSame('test/string', Str::finish('test/string', '')); + $this->assertSame('', Str::finish('', '')); } public function testWrap(): void From 8a8efa1d8be8c274a1044666429fd01943db3503 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 15/25] Avoid building an eager-load query for an empty relation list Apply laravel/framework#61869 (57b3e7d76b). Collection::loadMissing([]) returns the same collection without constructing a model query. The upstream regression rejects query construction. The database suite, formatting and full analysis pass. --- src/database/src/Eloquent/Collection.php | 2 +- tests/Database/DatabaseEloquentCollectionTest.php | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Collection.php b/src/database/src/Eloquent/Collection.php index 783cf8bf44..c8c164a8e3 100644 --- a/src/database/src/Eloquent/Collection.php +++ b/src/database/src/Eloquent/Collection.php @@ -257,7 +257,7 @@ public function loadMissing(array|string $relations): static $relations = func_get_args(); } - if ($this->isNotEmpty()) { + if ($this->isNotEmpty() && $relations !== []) { $query = $this->first()->newQueryWithoutRelationships()->with($relations); foreach ($query->getEagerLoads() as $key => $value) { diff --git a/tests/Database/DatabaseEloquentCollectionTest.php b/tests/Database/DatabaseEloquentCollectionTest.php index 57d7c62f09..27b3df441e 100755 --- a/tests/Database/DatabaseEloquentCollectionTest.php +++ b/tests/Database/DatabaseEloquentCollectionTest.php @@ -349,6 +349,15 @@ public function testLoadMethodEagerLoadsGivenRelationships(): void $this->assertEquals(['results'], $c->all()); } + public function testLoadMissingWithoutRelationsDoesNotBuildAQuery(): void + { + $model = m::mock(Model::class); + $model->shouldNotReceive('newQueryWithoutRelationships'); + $collection = new Collection([$model]); + + $this->assertSame($collection, $collection->loadMissing([])); + } + public function testCollectionDictionaryReturnsModelKeys(): void { $one = (new CollectionModel)->forceFill(['id' => 1]); From 1e2e99f54a2e3ed9d6d8822f5ef52211277e240c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 16/25] Serialize HTML string casts as strings Apply laravel/framework#61854 (bbcc9fec48). AsHtmlString implements the cast serialization contract, preserving string content in model arrays and JSON instead of exposing an HtmlString object. Null remains null. The complete upstream regression reproduced the old result; custom-cast integration tests, formatting and full analysis pass. --- .../src/Eloquent/Casts/AsHtmlString.php | 18 +++++++++++++++++- .../Database/DatabaseCustomCastsTest.php | 17 +++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/database/src/Eloquent/Casts/AsHtmlString.php b/src/database/src/Eloquent/Casts/AsHtmlString.php index 458c6314ea..657c749e80 100644 --- a/src/database/src/Eloquent/Casts/AsHtmlString.php +++ b/src/database/src/Eloquent/Casts/AsHtmlString.php @@ -6,6 +6,8 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Contracts\Database\Eloquent\SerializesCastableAttributes; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\HtmlString; class AsHtmlString implements Castable @@ -17,16 +19,30 @@ class AsHtmlString implements Castable */ public static function castUsing(array $arguments): CastsAttributes { - return new class implements CastsAttributes { + return new class implements CastsAttributes, SerializesCastableAttributes { + /** + * Cast the stored value to an HTML string. + */ public function get(mixed $model, string $key, mixed $value, array $attributes): ?HtmlString { return isset($value) ? new HtmlString($value) : null; } + /** + * Prepare the HTML string for storage. + */ public function set(mixed $model, string $key, mixed $value, array $attributes): ?string { return isset($value) ? (string) $value : null; } + + /** + * Serialize the HTML string for an array or JSON response. + */ + public function serialize(Model $model, string $key, mixed $value, array $attributes): ?string + { + return isset($value) ? (string) $value : null; + } }; } } diff --git a/tests/Integration/Database/DatabaseCustomCastsTest.php b/tests/Integration/Database/DatabaseCustomCastsTest.php index c10b4b457c..aee6e9ef73 100644 --- a/tests/Integration/Database/DatabaseCustomCastsTest.php +++ b/tests/Integration/Database/DatabaseCustomCastsTest.php @@ -6,6 +6,7 @@ use Hypervel\Database\Eloquent\Casts\AsArrayObject; use Hypervel\Database\Eloquent\Casts\AsCollection; +use Hypervel\Database\Eloquent\Casts\AsHtmlString; use Hypervel\Database\Eloquent\Casts\AsStringable; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Schema\Blueprint; @@ -88,6 +89,22 @@ public function testCustomCasting(): void ); } + public function testAsHtmlStringIsSerializedAsString(): void + { + $model = new class extends Model { + protected array $casts = ['body' => AsHtmlString::class]; + }; + + $model->body = 'Taylor'; + + $this->assertSame(['body' => 'Taylor'], $model->toArray()); + $this->assertSame('{"body":"Taylor<\/b>"}', $model->toJson()); + + $model->body = null; + + $this->assertSame(['body' => null], $model->toArray()); + } + public function testCustomCastingUsingCreate(): void { $model = TestEloquentModelWithCustomCasts::create([ From 327dabc438913bca16ab5d3a347583c4b5cb4941 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:31 +0000 Subject: [PATCH 17/25] Recognize PgBouncer SSL EOF connection failures Apply laravel/framework#61918 (38006c1ca5). Match the SSL SYSCALL EOF fragment even when PostgreSQL adds a server-crash or idle-timeout message before it. Preserve the PDO/explicit-driver boundary and query-context unwrapping. Port all three messages and the unrelated-EOF negative case. The database suite, formatting and full analysis pass. --- src/database/src/LostConnectionDetector.php | 2 +- tests/Database/DatabaseConnectionLostTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/database/src/LostConnectionDetector.php b/src/database/src/LostConnectionDetector.php index c2f3448761..5796fb5949 100644 --- a/src/database/src/LostConnectionDetector.php +++ b/src/database/src/LostConnectionDetector.php @@ -66,7 +66,7 @@ public function causedByLostConnection(Throwable $e): bool 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again', 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Name or service not known', 'SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo for', - 'SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected', + 'SSL SYSCALL error: EOF detected', 'SSL error: unexpected eof', 'SQLSTATE[HY000] [2002] Connection timed out', 'SSL: Connection timed out', diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index 0637c497a1..e2da709a9d 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Database; use ErrorException; +use Exception; use Hypervel\Database\LostConnectionDetector; use Hypervel\Database\LostConnectionException; use Hypervel\Database\QueryException; @@ -23,9 +24,15 @@ public function testLostConnectionDetectorMatchesExceptionMessage(string $messag $this->assertTrue($detector->causedByLostConnection(new PDOException($message))); } + /** + * Provide lost database connection messages. + */ public static function shouldBeLostProvider(): array { return [ + 'SSL EOF' => ['SQLSTATE[HY000]: General error: 7 SSL SYSCALL error: EOF detected'], + 'PgBouncer crashed server' => ["SQLSTATE[HY000]: General error: 7 FATAL: server conn crashed?\nSSL SYSCALL error: EOF detected"], + 'PgBouncer idle timeout' => ["SQLSTATE[HY000]: General error: 7 FATAL: client_idle_timeout\nSSL SYSCALL error: EOF detected"], 'DNS failure' => ['SQLSTATE[HY000] [2002] php_network_getaddresses: getaddrinfo failed: Try again'], 'PostgreSQL connection refused' => ["SQLSTATE[08006] [7] connection to server at \"example.database.com\" (10.0.1.7), port 5432 failed: Connection refused\nIs the server running on that host and accepting TCP/IP connections? (Connection: pgsql, Host: example.database.com, Port: 5432, Database: forge, SQL: select * from \"cache\" where \"key\" in (hypervel:queue:restart))"], 'MySQL server gone away' => ['SQLSTATE[HY000]: General error: 2006 MySQL server has gone away'], @@ -33,6 +40,13 @@ public static function shouldBeLostProvider(): array ]; } + public function testLostConnectionDetectorDoesNotMatchUnrelatedEofMessage(): void + { + $detector = new LostConnectionDetector; + + $this->assertFalse($detector->causedByLostConnection(new Exception('Unexpected EOF while parsing application data'))); + } + public function testQueryContextCannotForgeALostConnection(): void { $failure = new QueryException('test', 'insert into logs values (?)', ['Connection reset by peer'], new PDOException('Unique violation')); From acc11444673adcc33229a7e96e9904ffaa6da490 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:32 +0000 Subject: [PATCH 18/25] Keep the final endpoint when building number pairs Apply laravel/framework#61921 (09c6dc3d90). Add the final singleton interval when the upper limit starts a new pair. Preserve integer/float numeric equality, zero-step rejection and negative-step normalization. Port the updated fractional case and all six endpoint cases. Number tests, formatting and full analysis pass. --- src/support/src/Number.php | 4 ++++ tests/Support/SupportNumberTest.php | 12 +++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/support/src/Number.php b/src/support/src/Number.php index a1811d2620..2fbbcf28c6 100644 --- a/src/support/src/Number.php +++ b/src/support/src/Number.php @@ -311,6 +311,10 @@ public static function pairs(float|int $to, float|int $by, float|int $start = 0, $output[] = [$lower, $upper]; } + if ($output !== [] && $lower == $to && end($output)[1] < $to) { + $output[] = [$lower, $to]; + } + return $output; } diff --git a/tests/Support/SupportNumberTest.php b/tests/Support/SupportNumberTest.php index 7485b04ba7..570dc647ac 100644 --- a/tests/Support/SupportNumberTest.php +++ b/tests/Support/SupportNumberTest.php @@ -412,12 +412,22 @@ public function testPairs(): void $this->assertSame([[1, 1001], [1001, 2001], [2001, 2500]], Number::pairs(2500, 1000, 1, 0)); $this->assertSame([[1, 1000], [1001, 2000], [2001, 2500]], Number::pairs(2500, 1000, 1, 1)); $this->assertSame([[0, 2.5], [2.5, 5.0], [5.0, 7.5], [7.5, 10.0]], Number::pairs(10, 2.5, 0, 0)); - $this->assertSame([[0, 2.0], [2.5, 4.5], [5.0, 7.0], [7.5, 9.5]], Number::pairs(10, 2.5, 0, 0.5)); + $this->assertSame([[0, 2.0], [2.5, 4.5], [5.0, 7.0], [7.5, 9.5], [10.0, 10]], Number::pairs(10, 2.5, 0, 0.5)); $this->assertSame([[0.5, 3.0], [3.0, 5.5], [5.5, 8.0], [8.0, 10]], Number::pairs(10, 2.5, 0.5, 0)); $this->assertSame([[0.5, 2.5], [3.0, 5.0], [5.5, 7.5], [8.0, 10.0]], Number::pairs(10, 2.5, 0.5, 0.5)); $this->assertSame([[0, 9], [10, 19], [20, 25]], Number::pairs(25, 10)); } + public function testPairsIncludesUpperBoundWhenItStartsANewPair(): void + { + $this->assertSame([[0, 9], [10, 19], [20, 20]], Number::pairs(20, 10)); + $this->assertSame([[0, 9], [10, 19], [20, 21]], Number::pairs(21, 10)); + $this->assertSame([[1, 3], [4, 6], [7, 9], [10, 10]], Number::pairs(10, 3, 1)); + $this->assertSame([[0, 10], [10, 20]], Number::pairs(20, 10, 0, 0)); + $this->assertSame([], Number::pairs(0, 10)); + $this->assertSame([], Number::pairs(5, 10, 10)); + } + public function testPairsThrowsWhenByIsZero(): void { $this->expectException(InvalidArgumentException::class); From cd80f3c2cc2850ab4bf886b419110b9cb9fbcec6 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:32 +0000 Subject: [PATCH 19/25] Throw the intended exception for missing context stacks Apply laravel/framework#61936 (01ac36d93a). Check empty or absent visible and hidden stacks without reading an undefined key or calling count() on null. Both upstream regressions reproduce the old failure. Context integration tests, formatting and full analysis pass. --- src/log/src/Context/Repository.php | 4 ++-- tests/Integration/Log/ContextTest.php | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/log/src/Context/Repository.php b/src/log/src/Context/Repository.php index 298730b81d..bc27680543 100644 --- a/src/log/src/Context/Repository.php +++ b/src/log/src/Context/Repository.php @@ -237,7 +237,7 @@ public function push(string $key, mixed ...$values): static */ public function pop(string $key): mixed { - if (! $this->isStackable($key) || ! count($this->data[$key])) { + if (! $this->isStackable($key) || empty($this->data[$key])) { throw new RuntimeException("Unable to pop value from context stack for key [{$key}]."); } @@ -446,7 +446,7 @@ public function pushHidden(string $key, mixed ...$values): static */ public function popHidden(string $key): mixed { - if (! $this->isHiddenStackable($key) || ! count($this->hidden[$key])) { + if (! $this->isHiddenStackable($key) || empty($this->hidden[$key])) { throw new RuntimeException("Unable to pop value from hidden context stack for key [{$key}]."); } diff --git a/tests/Integration/Log/ContextTest.php b/tests/Integration/Log/ContextTest.php index 55c172d8cd..9f3bf94053 100644 --- a/tests/Integration/Log/ContextTest.php +++ b/tests/Integration/Log/ContextTest.php @@ -240,6 +240,13 @@ public function testThrowsWhenPoppingFromEmptyList(): void Context::pop('breadcrumbs'); } + public function testThrowsWhenPoppingFromMissingKey(): void + { + $this->expectExceptionObject(new RuntimeException('Unable to pop value from context stack for key [breadcrumbs].')); + + Context::pop('breadcrumbs'); + } + public function testThrowsWhenPoppingFromNonListArray(): void { Context::add('breadcrumbs', ['foo' => 'bar']); @@ -267,6 +274,13 @@ public function testThrowsWhenPoppingFromEmptyHiddenList(): void Context::popHidden('breadcrumbs'); } + public function testThrowsWhenPoppingFromMissingHiddenKey(): void + { + $this->expectExceptionObject(new RuntimeException('Unable to pop value from hidden context stack for key [breadcrumbs].')); + + Context::popHidden('breadcrumbs'); + } + public function testThrowsWhenPoppingFromHiddenNonListArray(): void { Context::addHidden('breadcrumbs', ['foo' => 'bar']); From 1e8d7456a9eff74f81b0709181df521f04ce99f3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:32 +0000 Subject: [PATCH 20/25] Cover floating-point values in unique rule constraints Apply laravel/framework#62059 (67905cc363). The native mixed parameter and existing normalization already accept floats; port the missing 0.13 serialization assertion. ValidationUniqueRuleTest and formatting pass. --- tests/Validation/ValidationUniqueRuleTest.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/Validation/ValidationUniqueRuleTest.php b/tests/Validation/ValidationUniqueRuleTest.php index 7f5aa9c505..148de3bd57 100644 --- a/tests/Validation/ValidationUniqueRuleTest.php +++ b/tests/Validation/ValidationUniqueRuleTest.php @@ -194,6 +194,10 @@ public function testItHandlesWhereWithSpecialValues(): void $rule = new Unique('table', 'column'); $rule->where('foo', 0); $this->assertSame('unique:table,column,NULL,id,foo,"0"', (string) $rule); + + $rule = new Unique('table', 'column'); + $rule->where('foo', 0.13); + $this->assertSame('unique:table,column,NULL,id,foo,"0.13"', (string) $rule); } public function testItValidatesUniqueRuleWithWhereInAndWhereNotIn(): void From 148f91303107a689e6aadbad155e7c1aebe4237e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:32 +0000 Subject: [PATCH 21/25] Verify simple pagination resolves its configured page name Apply laravel/framework#62128 (0ee9ca28ce). Paginator already forwards its configured page name. Consolidate the existing test under the upstream case, resolving distinct values for page and comments while retaining the stronger URL and argument assertions. Framework cleanup owns the resolver reset. PaginatorTest and formatting pass. --- tests/Pagination/PaginatorTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/Pagination/PaginatorTest.php b/tests/Pagination/PaginatorTest.php index 906e345705..692cadb712 100644 --- a/tests/Pagination/PaginatorTest.php +++ b/tests/Pagination/PaginatorTest.php @@ -277,24 +277,24 @@ public function testCurrentPageResolverReceivesTheDefaultPageName(): void $this->assertSame(2, $paginator->currentPage()); } - public function testCurrentPageResolverAndUrlsUseTheConfiguredPageName(): void + public function testCurrentPageIsResolvedUsingPageName(): void { $resolvedPageName = null; Paginator::currentPageResolver(function (string $pageName) use (&$resolvedPageName): int { $resolvedPageName = $pageName; - return 2; + return ['page' => 3, 'comments' => 2][$pageName]; }); $paginator = new Paginator( ['first', 'second'], 1, - options: ['pageName' => 'users'], + options: ['pageName' => 'comments'], ); - $this->assertSame('users', $resolvedPageName); + $this->assertSame('comments', $resolvedPageName); $this->assertSame(2, $paginator->currentPage()); - $this->assertSame('/?users=2', $paginator->url($paginator->currentPage())); + $this->assertSame('/?comments=2', $paginator->url($paginator->currentPage())); } public function testPaginatorJsonThrowsForInvalidUtf8(): void From 841ce7de94011403d510c39f2876b981eaed670d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:13:32 +0000 Subject: [PATCH 22/25] Ignore non-string validation error bag names Apply laravel/framework#62147 (f1c10664f4). Use the submitted error bag only when it is a string; otherwise retain the exception default. Preserve sessionless response handling. Port both upstream cases using an initialized redirect response for Hypervel response finalization, and align the existing input mock. The regression reproduces the array TypeError without the fix. Handler tests, formatting and full analysis pass. --- src/foundation/src/Exceptions/Handler.php | 4 +- .../FoundationExceptionsHandlerTest.php | 40 ++++++++++++++++++- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index 87723491f6..e127ab7fff 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -1023,8 +1023,10 @@ protected function invalid(Request $request, ValidationException $exception): Re $redirect = redirect($exception->redirectTo ?? url()->previous()); if (CoroutineContext::get(Store::CONTEXT_KEY)) { + $errorBag = $request->input('_error_bag'); + $redirect->withInput(Arr::except($request->input(), $this->dontFlash)) - ->withErrors($exception->errors(), $request->input('_error_bag', $exception->errorBag)); + ->withErrors($exception->errors(), is_string($errorBag) ? $errorBag : $exception->errorBag); } return $redirect; diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index b68621666c..e4d893aea8 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -819,6 +819,44 @@ function (array $argument) use (&$argumentActual): bool { $this->assertEquals($argumentExpected, $argumentActual); } + public function testInvalidUsesErrorBagWhenStringAndFallsBackWhenNotString(): void + { + $errorBagActual = null; + CoroutineContext::set(Store::CONTEXT_KEY, m::mock(Store::class)); + + $this->container->singleton('redirect', function () use (&$errorBagActual): Redirector { + $redirector = m::mock(Redirector::class); + $responder = m::mock(RedirectResponse::class . '[withInput,withErrors]', ['/']); + $redirector->expects('to')->twice()->andReturn($responder); + $responder->expects('withInput')->twice()->andReturn($responder); + $responder->expects('withErrors')->twice()->with( + m::any(), + m::on(function (string $errorBag) use (&$errorBagActual): bool { + $errorBagActual = $errorBag; + + return true; + }) + )->andReturn($responder); + + return $redirector; + }); + + $validator = m::mock(Validator::class); + $validator->expects('errors')->times(3)->andReturn(new MessageBag(['error' => 'My custom validation exception'])); + + $validationException = new ValidationException($validator); + $validationException->redirectTo = '/'; + $validationException->errorBag = 'default_bag'; + + $request = Request::create('/', 'POST', ['_error_bag' => 'custom_bag']); + $this->handler->render($request, $validationException); + $this->assertSame('custom_bag', $errorBagActual); + + $request = Request::create('/', 'POST', ['_error_bag' => ['invalid_array_bag']]); + $this->handler->render($request, $validationException); + $this->assertSame('default_bag', $errorBagActual); + } + public function testSuspiciousOperationReturns400WithoutReporting(): void { $this->config->set('app.debug', true); @@ -1551,7 +1589,7 @@ public function testValidateFailed(): void { $this->request->expects('expectsJson')->andReturn(false); $this->request->shouldReceive('input')->withNoArgs()->andReturn(['foo' => 'bar']); - $this->request->shouldReceive('input')->with('_error_bag', m::any())->andReturn('default'); + $this->request->shouldReceive('input')->with('_error_bag')->andReturn('default'); $session = m::mock(Store::class); $session->shouldReceive('get')->with('errors', m::type(ViewErrorBag::class))->andReturn(new MessageBag(['error' => 'My custom validation exception'])); From b33d11475173d3a2ef6a617deabe20e466107b2c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:42:36 +0000 Subject: [PATCH 23/25] Match model parameters to the Eloquent cast contract Use Model parameters for the HTML string, stringable, URI and binary casts, matching CastsAttributes and the models supplied by Eloquent. Add missing method title docblocks on the touched stringable and URI methods. Existing cast behavior is unchanged. Formatting, full source and type-fixture analysis, and the affected model, custom-cast, binary-cast and database notification tests pass. --- src/database/src/Eloquent/Casts/AsBinary.php | 5 +++-- src/database/src/Eloquent/Casts/AsHtmlString.php | 4 ++-- src/database/src/Eloquent/Casts/AsStringable.php | 11 +++++++++-- src/database/src/Eloquent/Casts/AsUri.php | 11 +++++++++-- 4 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/database/src/Eloquent/Casts/AsBinary.php b/src/database/src/Eloquent/Casts/AsBinary.php index 98a44e9cd8..c827dad7d0 100644 --- a/src/database/src/Eloquent/Casts/AsBinary.php +++ b/src/database/src/Eloquent/Casts/AsBinary.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\BinaryCodec; use InvalidArgumentException; @@ -43,7 +44,7 @@ public function __construct(protected array $arguments) /** * Transform the attribute from the underlying model values. */ - public function get(mixed $model, string $key, mixed $value, array $attributes): ?string + public function get(Model $model, string $key, mixed $value, array $attributes): ?string { $attribute = $attributes[$key] ?? null; @@ -61,7 +62,7 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): /** * Transform the attribute to its underlying model values. */ - public function set(mixed $model, string $key, mixed $value, array $attributes): array + public function set(Model $model, string $key, mixed $value, array $attributes): array { return [$key => BinaryCodec::encode($value, $this->format)]; } diff --git a/src/database/src/Eloquent/Casts/AsHtmlString.php b/src/database/src/Eloquent/Casts/AsHtmlString.php index 657c749e80..217a5d0e4d 100644 --- a/src/database/src/Eloquent/Casts/AsHtmlString.php +++ b/src/database/src/Eloquent/Casts/AsHtmlString.php @@ -23,7 +23,7 @@ public static function castUsing(array $arguments): CastsAttributes /** * Cast the stored value to an HTML string. */ - public function get(mixed $model, string $key, mixed $value, array $attributes): ?HtmlString + public function get(Model $model, string $key, mixed $value, array $attributes): ?HtmlString { return isset($value) ? new HtmlString($value) : null; } @@ -31,7 +31,7 @@ public function get(mixed $model, string $key, mixed $value, array $attributes): /** * Prepare the HTML string for storage. */ - public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + public function set(Model $model, string $key, mixed $value, array $attributes): ?string { return isset($value) ? (string) $value : null; } diff --git a/src/database/src/Eloquent/Casts/AsStringable.php b/src/database/src/Eloquent/Casts/AsStringable.php index 45c8ad5ab9..1bac8bdce8 100644 --- a/src/database/src/Eloquent/Casts/AsStringable.php +++ b/src/database/src/Eloquent/Casts/AsStringable.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Stringable; use Stringable as BaseStringable; @@ -19,12 +20,18 @@ class AsStringable implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Stringable + /** + * Cast the stored value to a stringable instance. + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Stringable { return isset($value) ? new Stringable($value) : null; } - public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + /** + * Prepare the stringable value for storage. + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string { return isset($value) ? (string) $value : null; } diff --git a/src/database/src/Eloquent/Casts/AsUri.php b/src/database/src/Eloquent/Casts/AsUri.php index 5619192885..f97b15d5dd 100644 --- a/src/database/src/Eloquent/Casts/AsUri.php +++ b/src/database/src/Eloquent/Casts/AsUri.php @@ -6,6 +6,7 @@ use Hypervel\Contracts\Database\Eloquent\Castable; use Hypervel\Contracts\Database\Eloquent\CastsAttributes; +use Hypervel\Database\Eloquent\Model; use Hypervel\Support\Uri; class AsUri implements Castable @@ -18,12 +19,18 @@ class AsUri implements Castable public static function castUsing(array $arguments): CastsAttributes { return new class implements CastsAttributes { - public function get(mixed $model, string $key, mixed $value, array $attributes): ?Uri + /** + * Cast the stored value to a URI. + */ + public function get(Model $model, string $key, mixed $value, array $attributes): ?Uri { return isset($value) ? new Uri($value) : null; } - public function set(mixed $model, string $key, mixed $value, array $attributes): ?string + /** + * Prepare the URI for storage. + */ + public function set(Model $model, string $key, mixed $value, array $attributes): ?string { return isset($value) ? (string) $value : null; } From dcb477839da27fb4c370c4fdd088988af47f57ce Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:05:22 +0000 Subject: [PATCH 24/25] Exercise message matching in the unrelated EOF test Use PDOException so the lost-connection detector reaches its message checks instead of returning at the exception-type gate. Remove the unused Exception import. The complete test file and formatting pass. --- tests/Database/DatabaseConnectionLostTest.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/Database/DatabaseConnectionLostTest.php b/tests/Database/DatabaseConnectionLostTest.php index e2da709a9d..5b6b6da6c9 100755 --- a/tests/Database/DatabaseConnectionLostTest.php +++ b/tests/Database/DatabaseConnectionLostTest.php @@ -5,7 +5,6 @@ namespace Hypervel\Tests\Database; use ErrorException; -use Exception; use Hypervel\Database\LostConnectionDetector; use Hypervel\Database\LostConnectionException; use Hypervel\Database\QueryException; @@ -44,7 +43,7 @@ public function testLostConnectionDetectorDoesNotMatchUnrelatedEofMessage(): voi { $detector = new LostConnectionDetector; - $this->assertFalse($detector->causedByLostConnection(new Exception('Unexpected EOF while parsing application data'))); + $this->assertFalse($detector->causedByLostConnection(new PDOException('Unexpected EOF while parsing application data'))); } public function testQueryContextCannotForgeALostConnection(): void From 9df34d118beae0a8c4ab3217d7032089b043d892 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:05:22 +0000 Subject: [PATCH 25/25] Coordinate the retry after the competing transaction commits The nested transaction regression expected one deadlock, but the victim could retry before the surviving transaction finished and encounter a second deadlock. Wait on a bounded completion channel before replaying the outer callback, preserving every existing retry, persisted-data, callback and connection-reuse assertion. The test passes on PostgreSQL 17 with PHP 8.4 and PHP 8.5, including ten repeated PHP 8.5 runs, and on MariaDB. Formatting passes. No production transaction behavior changes. --- .../Database/NestedTransactionRetryTest.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/Integration/Database/NestedTransactionRetryTest.php b/tests/Integration/Database/NestedTransactionRetryTest.php index 4042453406..ea6dcacaa3 100644 --- a/tests/Integration/Database/NestedTransactionRetryTest.php +++ b/tests/Integration/Database/NestedTransactionRetryTest.php @@ -103,12 +103,19 @@ public function testOnlyTheOuterTransactionRetriesAfterAManualSavepointRollback( DB::table('retry_records')->insert([['id' => 0, 'value' => 0], ['id' => 1, 'value' => 0]]); $ready = new Channel(2); $release = new Channel(2); + $finished = new Channel(2); $outerAttempts = $innerAttempts = [0, 0]; $committed = []; - $work = static function (int $worker) use ($ready, $release, &$outerAttempts, &$innerAttempts, &$committed): array { + $work = static function (int $worker) use ($ready, $release, $finished, &$outerAttempts, &$innerAttempts, &$committed): array { $connection = DB::connection(); - $connection->transaction(static function (Connection $connection) use ($worker, $ready, $release, &$outerAttempts, &$innerAttempts, &$committed): void { + $connection->transaction(static function (Connection $connection) use ($worker, $ready, $release, $finished, &$outerAttempts, &$innerAttempts, &$committed): void { $attempt = ++$outerAttempts[$worker]; + + // Let the surviving transaction commit before retrying, so the test causes exactly one deadlock. + if ($attempt > 1 && $finished->pop(5) !== true) { + throw new RuntimeException('The competing transaction did not finish before the retry.'); + } + $connection->table('retry_attempts')->insert(['worker' => $worker, 'attempt' => $attempt]); $connection->afterCommit(static function () use ($worker, $attempt, &$committed): void { $committed[$worker][] = $attempt; @@ -146,6 +153,8 @@ public function testOnlyTheOuterTransactionRetriesAfterAManualSavepointRollback( }, 3); }, 3); + $finished->push(true); + return [$connection->transactionLevel(), $connection->isReusable()]; };