From e67758d0c25aea3b66f893a436b8a06774fd41ec Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:43 +0000 Subject: [PATCH 1/9] Add chaperone support for many-to-many pivot models Link custom pivot models back to their declaring and related models when chaperone is enabled, including eager-loaded relationships. Port the upstream relationship-name inference, explicit names, opt-out, regression coverage and usage documentation with native types. Upstream: https://github.com/laravel/framework/pull/61152 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Documentation revision: ec28ad6ee78ebeada6095e05d33da7e8c42dd6dc Validated with the relationship tests, full parallel suite, PHPStan and formatting. --- .../src/Eloquent/Relations/BelongsToMany.php | 24 +- .../SupportsPivotInverseRelations.php | 117 +++++++++ src/docs/eloquent-relationships.md | 44 ++++ .../Database/EloquentBelongsToManyTest.php | 242 ++++++++++++++++++ 4 files changed, 423 insertions(+), 4 deletions(-) create mode 100644 src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php diff --git a/src/database/src/Eloquent/Relations/BelongsToMany.php b/src/database/src/Eloquent/Relations/BelongsToMany.php index 6d5107f57..85cd71d08 100644 --- a/src/database/src/Eloquent/Relations/BelongsToMany.php +++ b/src/database/src/Eloquent/Relations/BelongsToMany.php @@ -16,6 +16,7 @@ use Hypervel\Database\Eloquent\Relations\Concerns\AsPivot; use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithDictionary; use Hypervel\Database\Eloquent\Relations\Concerns\InteractsWithPivotTable; +use Hypervel\Database\Eloquent\Relations\Concerns\SupportsPivotInverseRelations; use Hypervel\Database\MultipleRecordsFoundException; use Hypervel\Database\Query\Builder as QueryBuilder; use Hypervel\Database\Query\Grammars\MySqlGrammar; @@ -44,6 +45,7 @@ class BelongsToMany extends Relation { use InteractsWithDictionary; use InteractsWithPivotTable; + use SupportsPivotInverseRelations; /** * The intermediate table for the relation. @@ -256,9 +258,21 @@ public function match(array $models, EloquentCollection $results, string $relati $key = $this->getDictionaryKey($model->{$this->parentKey}); if ($key !== null && isset($dictionary[$key])) { + $items = $dictionary[$key]; + + // Correct $this->parent to the actual parent for each group of results... + if ($this->declaringInverseRelationship) { + foreach ($items as $item) { + $item->{$this->accessor}?->setRelation( + $this->declaringInverseRelationship, + $model + ); + } + } + $model->setRelation( $relation, - $this->related->newCollection($dictionary[$key]) + $this->related->newCollection($items) ); } } @@ -1202,9 +1216,11 @@ protected function hydratePivotRelation(array $models): void // and create a new Pivot model, which is basically a dynamic model that we // will set the attributes, table, and connections on it so it will work. foreach ($models as $model) { - $model->setRelation($this->accessor, $this->newExistingPivot( - $this->migratePivotAttributes($model) - )); + $pivot = $this->newExistingPivot($this->migratePivotAttributes($model)); + + $this->applyChaperonesToPivot($pivot, $this->parent, $model); + + $model->setRelation($this->accessor, $pivot); } } diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php new file mode 100644 index 000000000..338b2f86f --- /dev/null +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php @@ -0,0 +1,117 @@ +using) { + return $this; + } + + $pivotModel = new $this->using; + + $this->declaringInverseRelationship = $this->resolvePivotInverseRelation( + $pivotModel, + $declaring, + $this->foreignPivotKey, + $this->parent + ); + + $this->relatedInverseRelationship = $this->resolvePivotInverseRelation( + $pivotModel, + $related, + $this->relatedPivotKey, + $this->related + ); + + return $this; + } + + /** + * Remove the chaperone relationships for this query. + * + * @return $this + */ + public function withoutChaperone(): static + { + $this->declaringInverseRelationship = null; + $this->relatedInverseRelationship = null; + + return $this; + } + + /** + * Resolve the inverse relation name on the pivot for a given model. + * + * If an explicit name is provided and invalid, an exception is thrown. + * If guessing fails, null is returned. + * + * @throws RelationNotFoundException + */ + protected function resolvePivotInverseRelation(Model $pivotModel, ?string $relation, string $foreignKey, Model $model): ?string + { + if ($relation !== null) { + if (! $pivotModel->isRelation($relation)) { + throw RelationNotFoundException::make($pivotModel, $relation); + } + + return $relation; + } + + return $this->guessPivotInverseRelation($pivotModel, $foreignKey, $model); + } + + /** + * Attempt to guess the inverse relation name on the pivot for a given model. + */ + protected function guessPivotInverseRelation(Model $pivotModel, string $foreignKey, Model $model): ?string + { + $candidates = array_filter(array_unique([ + Str::camel(Str::beforeLast($foreignKey, $model->getKeyName())), + Str::camel(class_basename($model)), + ])); + + return Arr::first( + $candidates, + fn (string $relation): bool => $pivotModel->isRelation($relation) + ); + } + + /** + * Apply chaperone relationships to a pivot model instance. + */ + protected function applyChaperonesToPivot(Model $pivot, Model $declaring, Model $related): void + { + if ($this->declaringInverseRelationship) { + $pivot->setRelation($this->declaringInverseRelationship, $declaring); + } + + if ($this->relatedInverseRelationship) { + $pivot->setRelation($this->relatedInverseRelationship, $related); + } + } +} diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index a803df984..cdb20e50e 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -14,6 +14,7 @@ - [Filtering Queries via Intermediate Table Columns](#filtering-queries-via-intermediate-table-columns) - [Ordering Queries via Intermediate Table Columns](#ordering-queries-via-intermediate-table-columns) - [Defining Custom Intermediate Table Models](#defining-custom-intermediate-table-models) + - [Automatically Hydrating Pivot Relationships](#automatically-hydrating-pivot-relationships) - [Polymorphic Relationships](#polymorphic-relationships) - [One to One](#one-to-one-polymorphic-relations) - [One to Many](#one-to-many-polymorphic-relations) @@ -1035,6 +1036,49 @@ class RoleUser extends Pivot } ``` + +#### Automatically Hydrating Pivot Relationships + +When a custom pivot model defines `belongsTo` relationships for the declaring and related models, you may invoke `chaperone` to automatically hydrate those relationships on each pivot model. This avoids additional queries when accessing the models through the pivot: + +```php +use Hypervel\Database\Eloquent\Model; +use Hypervel\Database\Eloquent\Relations\BelongsTo; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; +use Hypervel\Database\Eloquent\Relations\Pivot; + +class RoleUser extends Pivot +{ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} + +class Role extends Model +{ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->using(RoleUser::class) + ->chaperone(); + } +} +``` + +Eloquent will attempt to infer the pivot relationship names. If your pivot model uses non-standard names, pass the declaring and related relationship names to `chaperone`: + +```php +return $this->belongsToMany(User::class) + ->using(RoleUser::class) + ->chaperone(declaring: 'role', related: 'user'); +``` + ## Polymorphic Relationships diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index 7befddb73..b71711ff4 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -8,6 +8,9 @@ use Hypervel\Database\Eloquent\Collection; use Hypervel\Database\Eloquent\Model; use Hypervel\Database\Eloquent\ModelNotFoundException; +use Hypervel\Database\Eloquent\RelationNotFoundException; +use Hypervel\Database\Eloquent\Relations\BelongsTo; +use Hypervel\Database\Eloquent\Relations\BelongsToMany; use Hypervel\Database\Eloquent\Relations\Pivot; use Hypervel\Database\RecordsNotFoundException; use Hypervel\Database\Schema\Blueprint; @@ -1511,6 +1514,171 @@ public function testFirstOrCreateQueryBuilderIsolation() $instance->toArray(), ); } + + public function testChaperoneSetsDeclaringAndRelatedOnPivot(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tagsWithChaperoneCustomPivot()->attach($tag); + + $post = Post::first(); + $tags = $post->tagsWithChaperoneCustomPivot; + + $this->assertCount(1, $tags); + $pivot = $tags[0]->pivot; + $this->assertInstanceOf(ChaperonePostTagPivot::class, $pivot); + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertTrue($pivot->relationLoaded('tag')); + $this->assertTrue($post->is($pivot->post)); + $this->assertTrue($tag->is($pivot->tag)); + } + + public function testChaperoneWithExplicitRelationNames(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tagsWithChaperoneExplicit()->attach($tag); + + $post = Post::first(); + $tags = $post->tagsWithChaperoneExplicit; + + $pivot = $tags[0]->pivot; + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertTrue($pivot->relationLoaded('tag')); + $this->assertTrue($post->is($pivot->post)); + $this->assertTrue($tag->is($pivot->tag)); + } + + public function testChaperoneGuessesOnlyExistingRelations(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tagsWithChaperonePartialPivot()->attach($tag); + + $post = Post::first(); + $tags = $post->tagsWithChaperonePartialPivot; + + $pivot = $tags[0]->pivot; + $this->assertInstanceOf(ChaperonePartialPivot::class, $pivot); + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertFalse($pivot->relationLoaded('tag')); + $this->assertTrue($post->is($pivot->post)); + } + + public function testChaperoneWithEagerLoading(): void + { + $post1 = Post::create(['title' => Str::random()]); + $post2 = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post1->tagsWithChaperoneCustomPivot()->attach($tag); + $post2->tagsWithChaperoneCustomPivot()->attach($tag); + + $posts = Post::with('tagsWithChaperoneCustomPivot')->get(); + + foreach ($posts as $post) { + $this->assertCount(1, $post->tagsWithChaperoneCustomPivot); + $pivot = $post->tagsWithChaperoneCustomPivot[0]->pivot; + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertTrue($pivot->relationLoaded('tag')); + $this->assertTrue($post->is($pivot->post)); + $this->assertTrue($tag->is($pivot->tag)); + } + } + + public function testChaperoneWithoutCustomPivotIsNoop(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach($tag, ['flag' => 'test']); + + $post = Post::first(); + $relation = $post->tags()->chaperone(); + + $this->assertInstanceOf(BelongsToMany::class, $relation); + } + + public function testWithoutChaperoneClearsInverseRelations(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tagsWithChaperoneCustomPivot()->attach($tag); + + $post = Post::first(); + $tags = $post->tagsWithChaperoneCustomPivot()->withoutChaperone()->get(); + + $pivot = $tags[0]->pivot; + $this->assertFalse($pivot->relationLoaded('post')); + $this->assertFalse($pivot->relationLoaded('tag')); + } + + public function testChaperoneThrowsForExplicitInvalidRelation(): void + { + $this->expectException(RelationNotFoundException::class); + + $post = Post::create(['title' => Str::random()]); + $post->tagsWithChaperoneCustomPivot()->chaperone('nonexistent'); + } + + public function testChaperoneFromRelatedSide(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $tag->postsWithChaperone()->attach($post); + + $tag = Tag::first(); + $posts = $tag->postsWithChaperone; + + $this->assertCount(1, $posts); + $pivot = $posts[0]->pivot; + $this->assertInstanceOf(ChaperonePostTagPivot::class, $pivot); + $this->assertTrue($pivot->relationLoaded('tag')); + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertTrue($tag->is($pivot->tag)); + $this->assertTrue($post->is($pivot->post)); + } + + public function testChaperoneFromRelatedSideWithEagerLoading(): void + { + $post = Post::create(['title' => Str::random()]); + $tag1 = Tag::create(['name' => Str::random()]); + $tag2 = Tag::create(['name' => Str::random()]); + + $tag1->postsWithChaperone()->attach($post); + $tag2->postsWithChaperone()->attach($post); + + $tags = Tag::with('postsWithChaperone')->get(); + + foreach ($tags as $tag) { + $this->assertCount(1, $tag->postsWithChaperone); + $pivot = $tag->postsWithChaperone[0]->pivot; + $this->assertTrue($pivot->relationLoaded('tag')); + $this->assertTrue($pivot->relationLoaded('post')); + $this->assertTrue($tag->is($pivot->tag)); + $this->assertTrue($post->is($pivot->post)); + } + } + + public function testChaperoneWithoutCustomPivotStillReturnsResults(): void + { + $post = Post::create(['title' => Str::random()]); + $tag = Tag::create(['name' => Str::random()]); + + $post->tags()->attach($tag, ['flag' => 'test']); + + $post = Post::first(); + $tags = $post->tags()->chaperone()->get(); + + $this->assertCount(1, $tags); + $this->assertTrue($tag->is($tags[0])); + $this->assertInstanceOf(Pivot::class, $tags[0]->pivot); + } } class User extends Model @@ -1673,6 +1841,36 @@ public function tagsWithGlobalScope() { return $this->belongsToMany(TagWithGlobalScope::class, 'posts_tags', 'post_id', 'tag_id'); } + + /** + * Get tags with automatically hydrated pivot relationships. + */ + public function tagsWithChaperoneCustomPivot(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') + ->using(ChaperonePostTagPivot::class) + ->chaperone(); + } + + /** + * Get tags with explicit inverse relationship names. + */ + public function tagsWithChaperoneExplicit(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') + ->using(ChaperonePostTagPivot::class) + ->chaperone('post', 'tag'); + } + + /** + * Get tags whose pivot defines only the declaring relationship. + */ + public function tagsWithChaperonePartialPivot(): BelongsToMany + { + return $this->belongsToMany(Tag::class, 'posts_tags', 'post_id', 'tag_id') + ->using(ChaperonePartialPivot::class) + ->chaperone(); + } } class Tag extends Model @@ -1687,6 +1885,16 @@ public function posts() { return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id'); } + + /** + * Get posts with automatically hydrated pivot relationships. + */ + public function postsWithChaperone(): BelongsToMany + { + return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id') + ->using(ChaperonePostTagPivot::class) + ->chaperone(); + } } class UniqueTag extends Model @@ -1776,3 +1984,37 @@ public static function boot(): void }); } } + +class ChaperonePostTagPivot extends Pivot +{ + protected ?string $table = 'posts_tags'; + + /** + * Get the post associated with the pivot. + */ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } + + /** + * Get the tag associated with the pivot. + */ + public function tag(): BelongsTo + { + return $this->belongsTo(Tag::class); + } +} + +class ChaperonePartialPivot extends Pivot +{ + protected ?string $table = 'posts_tags'; + + /** + * Get the post associated with the pivot. + */ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class); + } +} From 9f2f481fce33edb0341ff5bb50d4ce4f431b16fb Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:59 +0000 Subject: [PATCH 2/9] Preserve query builders during chunk and lazy iteration Run ordering and pagination on a clone so chunk() and lazy() leave the original builder reusable. Preserve existing limit, offset and size handling, and port both upstream builder-reuse regressions with the writable-connection mock adaptation. Upstream: https://github.com/laravel/framework/pull/61411 https://github.com/laravel/framework/pull/61428 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated with the affected database suite, full parallel suite, PHPStan and formatting. --- src/database/src/Concerns/BuildsQueries.php | 18 +++--- .../Database/DatabaseEloquentBuilderTest.php | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/database/src/Concerns/BuildsQueries.php b/src/database/src/Concerns/BuildsQueries.php index b66970eef..823cdb2c9 100644 --- a/src/database/src/Concerns/BuildsQueries.php +++ b/src/database/src/Concerns/BuildsQueries.php @@ -45,10 +45,12 @@ public function chunk(int $count, callable $callback): bool throw new InvalidArgumentException('The chunk size should be at least 1'); } - $this->enforceOrderBy(); + $clone = clone $this; - $skip = $this->getOffset(); - $remaining = $this->getLimit(); + $clone->enforceOrderBy(); + + $skip = $clone->getOffset(); + $remaining = $clone->getLimit(); $page = 1; @@ -61,7 +63,7 @@ public function chunk(int $count, callable $callback): bool break; } - $results = $this->offset($offset)->limit($limit)->get(); + $results = $clone->offset($offset)->limit($limit)->get(); $countResults = $results->count(); @@ -246,12 +248,14 @@ public function lazy(int $chunkSize = 1000): LazyCollection throw new InvalidArgumentException('The chunk size should be at least 1'); } - $this->enforceOrderBy(); - $skip = $this->getOffset(); $remaining = $this->getLimit(); return new LazyCollection(function () use ($chunkSize, $skip, $remaining): Generator { + $clone = clone $this; + + $clone->enforceOrderBy(); + $page = 1; while (true) { @@ -263,7 +267,7 @@ public function lazy(int $chunkSize = 1000): LazyCollection return; } - $results = $this->offset($offset)->limit($limit)->get(); + $results = $clone->offset($offset)->limit($limit)->get(); foreach ($results as $result) { yield $result; diff --git a/tests/Database/DatabaseEloquentBuilderTest.php b/tests/Database/DatabaseEloquentBuilderTest.php index 256d06e52..6810e4766 100755 --- a/tests/Database/DatabaseEloquentBuilderTest.php +++ b/tests/Database/DatabaseEloquentBuilderTest.php @@ -614,6 +614,36 @@ public function testChunkPaginatesUsingIdWithCountZero(): void } } + public function testChunkDoesNotMutateTheOriginalBuilder(): void + { + $model = new Stub; + + $connection = $this->mockConnectionForModel($model, ''); + $connection->shouldReceive('getName')->andReturn('default'); + $connection->shouldReceive('getWritableName')->andReturn('default'); + $connection->shouldReceive('select')->andReturn( + [(object) ['id' => 1], (object) ['id' => 2]], + [(object) ['id' => 3]], + [(object) ['id' => 1], (object) ['id' => 2]], + [(object) ['id' => 3]], + ); + + $builder = $model->newQuery(); + + $builder->chunk(2, fn (Collection $results): bool => true); + + $this->assertNull($builder->getQuery()->offset); + $this->assertNull($builder->getQuery()->limit); + $this->assertEmpty($builder->getQuery()->orders); + + $results = []; + $builder->chunk(2, function (Collection $chunk) use (&$results): void { + $results = array_merge($results, $chunk->pluck('id')->all()); + }); + + $this->assertSame([1, 2, 3], $results); + } + public function testLazyWithLastChunkComplete(): void { $builder = m::mock(Builder::class . '[getOffset,getLimit,offset,limit,get]', [$this->getMockQueryBuilder()]); @@ -672,6 +702,31 @@ public function testLazyIsLazy(): void $this->assertEquals(['foo1', 'foo2'], $builder->lazy(2)->take(2)->all()); } + public function testLazyDoesNotMutateTheOriginalBuilder(): void + { + $model = new Stub; + + $connection = $this->mockConnectionForModel($model, ''); + $connection->shouldReceive('getName')->andReturn('default'); + $connection->shouldReceive('getWritableName')->andReturn('default'); + $connection->shouldReceive('select')->andReturn( + [(object) ['id' => 1], (object) ['id' => 2]], + [(object) ['id' => 3]], + [(object) ['id' => 1], (object) ['id' => 2]], + [(object) ['id' => 3]], + ); + + $builder = $model->newQuery(); + + $builder->lazy(2)->all(); + + $this->assertNull($builder->getQuery()->offset); + $this->assertNull($builder->getQuery()->limit); + $this->assertEmpty($builder->getQuery()->orders); + + $this->assertSame([1, 2, 3], $builder->lazy(2)->pluck('id')->all()); + } + public function testLazyByIdWithLastChunkComplete(): void { $builder = m::mock(Builder::class . '[getOffset,getLimit,forPageAfterId,get]', [$this->getMockQueryBuilder()]); From 2fdc60ff78b97e8388d20ff1ca2e78e37258814c Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:52:59 +0000 Subject: [PATCH 3/9] Expose exception context and Blade source mapping helpers Add contextForException() to retrieve the complete logging context without reporting an exception. Make the compiled-view and line-number mapping helpers public, regenerate the Exceptions facade and document the new context API. Extend the existing context test and call the public line-mapping method directly. Upstream: https://github.com/laravel/framework/pull/61362 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated with handler, renderer and generated-facade tests, the full parallel suite, PHPStan and formatting. --- src/docs/errors.md | 2 ++ src/foundation/src/Exceptions/Handler.php | 8 ++++++++ .../src/Exceptions/Renderer/Mappers/BladeMapper.php | 4 ++-- src/support/src/Facades/Exceptions.php | 1 + tests/Foundation/FoundationExceptionsHandlerTest.php | 6 +++++- .../Foundation/Exceptions/RenderBladeFilesTest.php | 5 +---- 6 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/docs/errors.md b/src/docs/errors.md index 37d763b07..7038923de 100644 --- a/src/docs/errors.md +++ b/src/docs/errors.md @@ -109,6 +109,8 @@ class InvalidOrderException extends Exception } ``` +You may use `Exceptions::contextForException($exception)` to get an exception's complete log context without reporting it. The returned array includes global context, the exception's context, and the exception itself. + #### The `report` Helper diff --git a/src/foundation/src/Exceptions/Handler.php b/src/foundation/src/Exceptions/Handler.php index b8e9c7dd0..c36b02c1e 100644 --- a/src/foundation/src/Exceptions/Handler.php +++ b/src/foundation/src/Exceptions/Handler.php @@ -713,6 +713,14 @@ public function stopIgnoring(array|string $exceptions): static return $this; } + /** + * Create the context array for logging the given exception. + */ + public function contextForException(Throwable $e): array + { + return $this->buildExceptionContext($e); + } + /** * Create the context array for logging the given exception. * diff --git a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php index 0260fc4c4..b76ac90e6 100644 --- a/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php +++ b/src/foundation/src/Exceptions/Renderer/Mappers/BladeMapper.php @@ -84,7 +84,7 @@ public function map(FlattenException $exception): FlattenException /** * Find the compiled view file for the given compiled path. */ - protected function findCompiledView(string $compiledPath): ?string + public function findCompiledView(string $compiledPath): ?string { return once(fn () => $this->getKnownPaths())[$compiledPath] ?? null; } @@ -135,7 +135,7 @@ protected function filterViewData(array $data): array /** * Detect the line number in the original blade file. */ - protected function detectLineNumber(string $filename, int $compiledLineNumber): int + public function detectLineNumber(string $filename, int $compiledLineNumber): int { $source = @file_get_contents($filename); diff --git a/src/support/src/Facades/Exceptions.php b/src/support/src/Facades/Exceptions.php index 83766f45f..beb7ad978 100644 --- a/src/support/src/Facades/Exceptions.php +++ b/src/support/src/Facades/Exceptions.php @@ -13,6 +13,7 @@ * @method static void afterResponse(callable $callback) * @method static array buildContextForException(Throwable $e) * @method static \Hypervel\Foundation\Exceptions\Handler buildContextUsing(\Closure $contextCallback) + * @method static array contextForException(Throwable $e) * @method static \Hypervel\Foundation\Exceptions\Handler dontFlash(array|string $attributes) * @method static \Hypervel\Foundation\Exceptions\Handler dontReport(array|string $exceptions) * @method static \Hypervel\Foundation\Exceptions\Handler dontReportDuplicates() diff --git a/tests/Foundation/FoundationExceptionsHandlerTest.php b/tests/Foundation/FoundationExceptionsHandlerTest.php index ae146c325..4ecdee0e3 100644 --- a/tests/Foundation/FoundationExceptionsHandlerTest.php +++ b/tests/Foundation/FoundationExceptionsHandlerTest.php @@ -243,7 +243,11 @@ public function testHandlerCallsContextMethodIfPresent(): void $this->container->instance(LoggerInterface::class, $logger); $logger->expects('error')->withArgs(['Exception message', m::subset(['foo' => 'bar'])]); - $this->handler->report(new ContextProvidingException('Exception message')); + $exception = new ContextProvidingException('Exception message'); + + $this->assertSame(['foo' => 'bar', 'exception' => $exception], $this->handler->contextForException($exception)); + + $this->handler->report($exception); } public function testHandlerMergesInlineContextIntoLogContext(): void diff --git a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php index 7f550024f..63d674f01 100644 --- a/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php +++ b/tests/Integration/Foundation/Exceptions/RenderBladeFilesTest.php @@ -128,12 +128,9 @@ public function testBladeMapperSkipsMissingOriginalAndCompiledPaths(): void public function testBladeMapperPreservesCompiledLineWhenOriginalDisappears(): void { - $method = new ReflectionMethod(BladeMapper::class, 'detectLineNumber'); - $this->assertSame( 37, - $method->invoke( - $this->app->make(BladeMapper::class), + $this->app->make(BladeMapper::class)->detectLineNumber( ParallelTesting::tempDir('MissingBladeSource') . '/missing.blade.php', 37, ), From f7f78fbe8a7becf0330c075c19a21d39793970ac Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:13 +0000 Subject: [PATCH 4/9] Cover object results from MySQL query explanations Port the upstream explain() regression using the MySQL integration base and directory so service CI discovers it. Verify that explain returns a collection containing an object row. Upstream: https://github.com/laravel/framework/pull/61363 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated against a real MySQL database and with formatting. The default suite skips this service-specific test when MySQL is not selected. --- .../Database/MySql/DatabaseExplainTest.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/Integration/Database/MySql/DatabaseExplainTest.php diff --git a/tests/Integration/Database/MySql/DatabaseExplainTest.php b/tests/Integration/Database/MySql/DatabaseExplainTest.php new file mode 100644 index 000000000..fd944ee35 --- /dev/null +++ b/tests/Integration/Database/MySql/DatabaseExplainTest.php @@ -0,0 +1,45 @@ +id(); + $table->string('name')->nullable(); + $table->timestamps(); + }); + } + } + + /** + * Remove the table for query explanations. + */ + protected function destroyDatabaseMigrations(): void + { + Schema::dropIfExists('db_explain_tbl'); + } + + public function testResultIsAnObject(): void + { + DB::table('db_explain_tbl')->insert(['name' => 'taylor']); + + $result = DB::table('db_explain_tbl')->where('name', 'taylor')->explain(); + + $this->assertIsObject($result); + $this->assertSame(1, $result->count()); + $this->assertIsObject($result->first()); + } +} From dc352b1ed5b9c0a494e68b819a2a10821eebbf90 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:13 +0000 Subject: [PATCH 5/9] Cover application state after generating the route cache Port regressions asserting that route caching preserves the facade application, facade roots and analyzable routes. Align the container-instance assertion with upstream while retaining subprocess isolation for generating cached routes. Upstream: https://github.com/laravel/framework/pull/61346 https://github.com/laravel/framework/pull/61405 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated with the route-cache integration tests and full parallel suite. --- .../Console/RouteCacheCommandTest.php | 44 +++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php index c90d1a412..0bac7732b 100644 --- a/tests/Integration/Foundation/Console/RouteCacheCommandTest.php +++ b/tests/Integration/Foundation/Console/RouteCacheCommandTest.php @@ -7,6 +7,9 @@ use Hypervel\Container\Container; use Hypervel\Filesystem\Filesystem; use Hypervel\Routing\CompiledRouteCollection; +use Hypervel\Routing\Controller; +use Hypervel\Support\Facades\Facade; +use Hypervel\Support\Facades\Route; use Hypervel\Testbench\TestCase; use Hypervel\Tests\Testing\Fixtures\CleanupActions; use Mockery as m; @@ -197,7 +200,14 @@ public function testRouteClearRemovesCacheFile(): void $this->assertFileDoesNotExist($this->app->getCachedRoutesPath()); } - public function testRouteCacheDoesNotOverwriteGlobalContainerInstance(): void + public function testItRestoresTheFacadeApplicationAfterBootingAFreshApplication(): void + { + $this->artisan('route:cache')->assertSuccessful(); + + $this->assertSame($this->app, Facade::getFacadeApplication()); + } + + public function testItRestoresTheContainerInstanceAfterBootingAFreshApplication(): void { $this->defineTestbenchRoutes( <<<'PHP' @@ -205,11 +215,28 @@ public function testRouteCacheDoesNotOverwriteGlobalContainerInstance(): void PHP ); - $originalInstance = Container::getInstance(); + $this->artisan('route:cache')->assertSuccessful(); + + $this->assertSame($this->app, Container::getInstance()); + } + public function testItLeavesTheFacadeRootsPointingAtTheCurrentApplication(): void + { $this->artisan('route:cache')->assertSuccessful(); - $this->assertSame($originalInstance, Container::getInstance()); + $this->assertSame($this->app->make('router'), Route::getFacadeRoot()); + } + + public function testRoutesRemainAnalyzableAfterCaching(): void + { + Route::get('/posts', [RouteCacheCommandTestController::class, 'index']); + + $this->artisan('route:cache')->assertSuccessful(); + + $route = collect(Route::getRoutes())->first(fn ($route): bool => $route->uri() === 'posts'); + + $this->assertNotNull($route, 'The registered route is no longer reachable through the route facade.'); + $this->assertInstanceOf(RouteCacheCommandTestController::class, $route->getController()); } public function testRouteCacheRebuildsFromSourceWhenApplicationBootedWithExistingCachedRoutes(): void @@ -423,3 +450,14 @@ protected function assertCleanTestbenchRouteSources(): void } } } + +class RouteCacheCommandTestController extends Controller +{ + /** + * Handle the request. + */ + public function index(): string + { + return 'ok'; + } +} From bc910c93764786c26c2e35687f0796491e83fb07 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:31 +0000 Subject: [PATCH 6/9] Align queue worker event metadata and add custom termination callbacks Use connectionName on pause/resume events and align stop/kill arguments and WorkerStopping metadata with upstream. Preserve coroutine timeout monitoring, immediate-termination metadata and native process termination. Add the boot-time killUsing callback after WorkerStopping dispatch, with normal forced termination if the callback returns. Reset it through existing static-state cleanup. Port callback and event regressions using the safe worker termination fixture, and explain timeout termination in the queue documentation. Upstream: https://github.com/laravel/framework/pull/61388 https://github.com/laravel/framework/pull/61392 https://github.com/laravel/framework/pull/61387 https://github.com/laravel/framework/pull/61408 https://github.com/laravel/framework/pull/61714 https://github.com/laravel/framework/pull/61717 https://github.com/laravel/framework/pull/61622 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated with worker, event and pooled-resource tests, full parallel suite, PHPStan and formatting. --- src/docs/queues.md | 4 +- src/queue/src/Events/QueuePaused.php | 2 +- src/queue/src/Events/QueueResumed.php | 2 +- src/queue/src/Events/WorkerStopping.php | 6 +- src/queue/src/Worker.php | 55 ++++++++++++++----- .../Sqlite/WorkerResourceLifetimeTest.php | 2 +- tests/Queue/QueuePauseResumeTest.php | 6 +- tests/Queue/QueueWorkerTest.php | 54 +++++++++++++----- 8 files changed, 93 insertions(+), 38 deletions(-) diff --git a/src/docs/queues.md b/src/docs/queues.md index e1c0285ce..b7aee9b1d 100644 --- a/src/docs/queues.md +++ b/src/docs/queues.md @@ -3047,7 +3047,7 @@ The `pause`, `pauseFor`, and `resume` methods also accept enums for queue and co Queue workers report paused and resumed queues in their console output. -Pausing or resuming a queue dispatches the `Hypervel\Queue\Events\QueuePaused` or `Hypervel\Queue\Events\QueueResumed` event in the process that made the change. Their `connection` and `queue` properties identify the queue, and the `QueuePaused` event's `ttl` property is `null` unless the queue was paused for a limited time. Pausing or resuming every queue with the `--all` option dispatches the `QueuesPaused` or `QueuesResumed` event instead. Running workers dispatch the `WorkerQueuePaused` and `WorkerQueueResumed` events, with `connectionName` and `queue` properties, when they detect that one of their queues has been paused or resumed. +Pausing or resuming a queue dispatches the `Hypervel\Queue\Events\QueuePaused` or `Hypervel\Queue\Events\QueueResumed` event in the process that made the change. Their `connectionName` and `queue` properties identify the queue, and the `QueuePaused` event's `ttl` property is `null` unless the queue was paused for a limited time. Pausing or resuming every queue with the `--all` option dispatches the `QueuesPaused` or `QueuesResumed` event instead. Running workers dispatch the `WorkerQueuePaused` and `WorkerQueueResumed` events, with `connectionName` and `queue` properties, when they detect that one of their queues has been paused or resumed. A worker process may also be paused as a whole by sending it the `SIGUSR2` signal and resumed with `SIGCONT`. The worker dispatches the `WorkerPausing` and `WorkerResuming` events when it receives these signals. Both events provide the worker's `connectionName`, `queue`, and `workerOptions`. @@ -4038,3 +4038,5 @@ Event::listen(function (WorkerIdle $event) { When an interrupting signal is delivered to running jobs, Hypervel dispatches a `Hypervel\Queue\Events\JobInterrupted` event once for each job that was notified. Its `connectionName`, `job`, and `signal` properties identify the interrupted work. Queue workers also dispatch a `WorkerStopping` event before they stop. You may register a listener using `Queue::stopping` in the `boot` method of a service provider. Its `connectionName` and `queue` properties identify the worker, while `terminatesImmediately` is `true` when the process will be terminated as soon as the listeners return. In that case, listeners should not start cleanup that must finish after the listener returns. + +When a job times out, the worker normally ends its own process with `SIGKILL`, so a process supervisor sees the signal rather than an exit status. If you need to control how the worker terminates, you may register a callback using `Hypervel\Queue\Worker::killUsing` in a service provider's `boot` method. The callback receives the worker's exit status after the `WorkerStopping` event is dispatched. If the callback returns, the worker is terminated as usual. diff --git a/src/queue/src/Events/QueuePaused.php b/src/queue/src/Events/QueuePaused.php index 7ad8411b9..a5cf63222 100644 --- a/src/queue/src/Events/QueuePaused.php +++ b/src/queue/src/Events/QueuePaused.php @@ -13,7 +13,7 @@ class QueuePaused * Create a new event instance. */ public function __construct( - public string $connection, + public string $connectionName, public string $queue, public DateInterval|DateTimeInterface|int|null $ttl = null, ) { diff --git a/src/queue/src/Events/QueueResumed.php b/src/queue/src/Events/QueueResumed.php index 878e14b4c..153cb08ad 100644 --- a/src/queue/src/Events/QueueResumed.php +++ b/src/queue/src/Events/QueueResumed.php @@ -10,7 +10,7 @@ class QueueResumed * Create a new event instance. */ public function __construct( - public string $connection, + public string $connectionName, public string $queue, ) { } diff --git a/src/queue/src/Events/WorkerStopping.php b/src/queue/src/Events/WorkerStopping.php index 729c7c810..eedd2b5d7 100644 --- a/src/queue/src/Events/WorkerStopping.php +++ b/src/queue/src/Events/WorkerStopping.php @@ -13,17 +13,17 @@ class WorkerStopping * Create a new event instance. * * @param null|float|int $memoryUsage the memory usage of the worker in megabytes - * @param bool $terminatesImmediately whether the process terminates as soon as listeners return; listeners must not start cleanup that must finish before returning when this is true + * @param bool $terminatesImmediately whether the process terminates as soon as listeners return; when true, listeners must not start cleanup that must finish after they return */ public function __construct( + public ?string $connectionName = null, + public ?string $queue = null, public int $status = 0, public ?WorkerOptions $workerOptions = null, public ?WorkerStopReason $reason = null, public ?int $jobsProcessed = null, public float|int|null $lastJobProcessedAt = null, public float|int|null $memoryUsage = null, - public ?string $connectionName = null, - public ?string $queue = null, public bool $terminatesImmediately = false, ) { } diff --git a/src/queue/src/Worker.php b/src/queue/src/Worker.php index 74a86a234..18671a21b 100644 --- a/src/queue/src/Worker.php +++ b/src/queue/src/Worker.php @@ -191,6 +191,13 @@ class Worker */ public static ?int $timedOutExitCode = null; + /** + * The callback used to kill the worker process. + * + * @var null|(callable(int): mixed) + */ + protected static mixed $killCallback = null; + /** * Indicates if the worker should report job exceptions. * @@ -309,7 +316,7 @@ function () use ($connectionName, $queue, $options): ?int { [$status, $reason] = $stop; $this->waitForRunningJobs($concurrent); - return $this->stop($status, $options, $reason, $connectionName, $queue); + return $this->stop($connectionName, $queue, $status, $options, $reason); } continue; @@ -345,7 +352,7 @@ function () use ($connectionName, $queue, $options): ?int { $this->waitForRunningJobs($concurrent); - return $this->stop($status, $options, $reason, $connectionName, $queue); + return $this->stop($connectionName, $queue, $status, $options, $reason); } continue; @@ -410,7 +417,7 @@ function () use ($connectionName, $queue, $options): ?JobContract { $this->waitForRunningJobs($concurrent); - return $this->stop($status, $options, $reason, $connectionName, $queue); + return $this->stop($connectionName, $queue, $status, $options, $reason); } } } finally { @@ -500,8 +507,8 @@ function () use ($signal): void { */ protected function monitorTimeoutJobs( WorkerOptions $options, - ?string $connectionName = null, - ?string $queue = null, + string $connectionName, + string $queue, ): void { if ($this->monitorId !== null) { return; @@ -522,11 +529,11 @@ protected function monitorTimeoutJobs( if ($this->hasTimeoutJobs()) { $this->shouldQuit = true; $this->kill( + $connectionName, + $queue, static::$timedOutExitCode ?? static::EXIT_ERROR, $options, WorkerStopReason::TimedOut, - $connectionName, - $queue, ); } } finally { @@ -1379,22 +1386,22 @@ protected function currentTime(): float * Stop listening and bail out of the script. */ public function stop( + string $connectionName, + string $queue, int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null, - ?string $connectionName = null, - ?string $queue = null, ): int { if ($this->events->hasListeners(WorkerStopping::class)) { $this->events->dispatch(new WorkerStopping( + $connectionName, + $queue, $status, $options, $reason, $this->jobsProcessed, $this->lastJobProcessedAt, $this->currentMemoryUsage(), - $connectionName, - $queue, terminatesImmediately: false, )); } @@ -1406,26 +1413,30 @@ public function stop( * Kill the process. */ public function kill( + string $connectionName, + string $queue, int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null, - ?string $connectionName = null, - ?string $queue = null, ): never { if ($this->events->hasListeners(WorkerStopping::class)) { $this->events->dispatch(new WorkerStopping( + $connectionName, + $queue, $status, $options, $reason, $this->jobsProcessed, $this->lastJobProcessedAt, $this->currentMemoryUsage(), - $connectionName, - $queue, terminatesImmediately: true, )); } + if (static::$killCallback) { + call_user_func(static::$killCallback, $status); + } + $this->terminateProcess($status); } @@ -1507,6 +1518,19 @@ public static function popUsing(string $workerName, ?callable $callback): void } } + /** + * Register a callback to be used to kill the worker process. + * + * Boot-only. The callback persists for the worker lifetime and runs before + * forced process termination. Passing null clears it. + * + * @param null|(callable(int): mixed) $callback + */ + public static function killUsing(?callable $callback): void + { + static::$killCallback = $callback; + } + /** * Get the queue manager instance. */ @@ -1534,6 +1558,7 @@ public static function flushState(): void static::$popCallbacks = []; static::$memoryExceededExitCode = null; static::$timedOutExitCode = null; + static::$killCallback = null; static::$reportJobExceptions = true; static::$stopOnLostConnection = true; static::$restartable = true; diff --git a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php index 502b08e0e..df11a1005 100644 --- a/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php +++ b/tests/Integration/Queue/Database/Sqlite/WorkerResourceLifetimeTest.php @@ -187,7 +187,7 @@ public function testTimeoutAndSignalCallbacksReleasePooledConnectionsAfterEachBa $worker = new class(m::mock(QueueManager::class), $events, $this->app->make(ExceptionHandlerContract::class), static fn (): bool => false, $timer) extends Worker { public function startMonitorForTest(WorkerOptions $options): void { - $this->monitorTimeoutJobs($options); + $this->monitorTimeoutJobs($options, 'default', 'queue'); } public function pauseForTest(WorkerOptions $options): void diff --git a/tests/Queue/QueuePauseResumeTest.php b/tests/Queue/QueuePauseResumeTest.php index 01b74a76c..dee045c9a 100644 --- a/tests/Queue/QueuePauseResumeTest.php +++ b/tests/Queue/QueuePauseResumeTest.php @@ -181,7 +181,7 @@ public function testPauseDispatchesQueuePausedEvent(): void $this->manager->pause('default', 'redis'); $this->assertInstanceOf(QueuePaused::class, $dispatchedEvent); - $this->assertSame('redis', $dispatchedEvent->connection); + $this->assertSame('redis', $dispatchedEvent->connectionName); $this->assertSame('default', $dispatchedEvent->queue); $this->assertNull($dispatchedEvent->ttl); } @@ -197,7 +197,7 @@ public function testPauseForDispatchesQueuePausedEventWithTTL(): void $this->manager->pauseFor('emails', 60, 'redis'); $this->assertInstanceOf(QueuePaused::class, $dispatchedEvent); - $this->assertSame('redis', $dispatchedEvent->connection); + $this->assertSame('redis', $dispatchedEvent->connectionName); $this->assertSame('emails', $dispatchedEvent->queue); $this->assertSame(60, $dispatchedEvent->ttl); } @@ -213,7 +213,7 @@ public function testResumeDispatchesQueueResumedEvent(): void $this->manager->resume('notifications', 'database'); $this->assertInstanceOf(QueueResumed::class, $dispatchedEvent); - $this->assertSame('database', $dispatchedEvent->connection); + $this->assertSame('database', $dispatchedEvent->connectionName); $this->assertSame('notifications', $dispatchedEvent->queue); } diff --git a/tests/Queue/QueueWorkerTest.php b/tests/Queue/QueueWorkerTest.php index 85effe85d..4d21c05d2 100644 --- a/tests/Queue/QueueWorkerTest.php +++ b/tests/Queue/QueueWorkerTest.php @@ -534,8 +534,8 @@ public function testTimeoutMonitorUsesTheDefaultErrorExitAndTimedOutReason(?int $this->events->shouldHaveReceived('dispatch')->with(m::on( static fn (object $event): bool => $event instanceof WorkerStopping && $event->reason === WorkerStopReason::TimedOut - && $event->connectionName === null - && $event->queue === null + && $event->connectionName === 'default' + && $event->queue === 'queue' && $event->terminatesImmediately ))->once(); $this->events->shouldHaveReceived('dispatch')->with(m::on( @@ -744,7 +744,7 @@ public function testKillDoesNotWaitForUnrelatedActiveJobs(): void $worker->registerCoroutineJobForTest(new WorkerFakeJob, new WorkerOptions); try { - $worker->kill(Worker::EXIT_SUCCESS, new WorkerOptions); + $worker->kill('default', 'queue', Worker::EXIT_SUCCESS, new WorkerOptions); $this->fail('Expected the process termination seam to throw.'); } catch (WorkerKilledException $exception) { $this->assertSame(Worker::EXIT_SUCCESS, $exception->status); @@ -1544,6 +1544,7 @@ public function testFlushStateResetsWorkerStaticState(): void Worker::$stopOnLostConnection = false; Worker::$restartable = false; Worker::$pausable = false; + Worker::killUsing(static fn (): never => throw new RuntimeException('Stale kill callback.')); Worker::flushState(); @@ -1564,6 +1565,33 @@ public function testFlushStateResetsWorkerStaticState(): void $this->assertTrue($defaultJob->fired); $this->assertFalse($customJob->fired); + + $this->expectException(WorkerKilledException::class); + + (new KillTestWorker(...$this->workerDependencies()))->kill('default', 'queue'); + } + + public function testWorkerCanBeKilledUsingCustomCallback(): void + { + Worker::killUsing(function (int $status): never { + throw new RuntimeException("Killed with status [{$status}]."); + }); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Killed with status [124].'); + + try { + (new KillTestWorker(...$this->workerDependencies('default', ['queue' => []]))) + ->kill('default', 'default', 124, new WorkerOptions, WorkerStopReason::TimedOut); + } finally { + Worker::killUsing(null); + + $this->events->shouldHaveReceived('dispatch')->with(m::on(function ($event): bool { + return $event instanceof WorkerStopping + && $event->status === 124 + && $event->reason === WorkerStopReason::TimedOut; + }))->once(); + } } public function testWorkerStartingIsDispatched() @@ -1616,14 +1644,14 @@ public function testWorkerStoppingIsDispatched() $this->events->shouldHaveReceived('dispatch')->with(m::on(function ($event) use ($workerOptions) { return $event instanceof WorkerStopping + && $event->connectionName === 'default' + && $event->queue === 'queue' && $event->status === 0 && $event->workerOptions === $workerOptions && $event->reason === WorkerStopReason::QueueEmpty && $event->jobsProcessed === 2 && $event->lastJobProcessedAt !== null && $event->memoryUsage > 0 - && $event->connectionName === 'default' - && $event->queue === 'queue' && ! $event->terminatesImmediately; }))->once(); } @@ -1780,7 +1808,7 @@ public function testSignalAndStopEventsAreNotDispatchedWithoutListeners(): void $worker->handleResumeSignalForTest('default', 'queue', $workerOptions); $worker->handleInterruptionSignalForTest(SIGTERM, 'default', 'queue', $workerOptions); $worker->drainPendingSignalsForTest(); - $status = $worker->stop(7, $workerOptions, WorkerStopReason::QueueEmpty, 'default', 'queue'); + $status = $worker->stop('default', 'queue', 7, $workerOptions, WorkerStopReason::QueueEmpty); $this->assertSame(7, $status); $this->assertFalse($worker->paused); @@ -2039,13 +2067,13 @@ public function sleep(float|int $seconds): void } public function stop( + string $connectionName, + string $queue, int $status = 0, ?WorkerOptions $options = null, ?WorkerStopReason $reason = null, - ?string $connectionName = null, - ?string $queue = null, ): int { - return parent::stop($status, $options, $reason, $connectionName, $queue); + return parent::stop($connectionName, $queue, $status, $options, $reason); } public function daemonShouldRun(WorkerOptions $options, string $connectionName, string $queue): bool @@ -2233,8 +2261,8 @@ class MonitorFailureWorker extends InsomniacWorker public function startMonitorForTest( WorkerOptions $options, - ?string $connectionName = null, - ?string $queue = null, + string $connectionName = 'default', + string $queue = 'queue', ): void { $this->monitorTimeoutJobs($options, $connectionName, $queue); } @@ -2256,8 +2284,8 @@ class KillTestWorker extends InsomniacWorker { public function startMonitorForTest( WorkerOptions $options, - ?string $connectionName = null, - ?string $queue = null, + string $connectionName = 'default', + string $queue = 'queue', ): void { $this->monitorTimeoutJobs($options, $connectionName, $queue); } From 2e2b8ec52f587cb39f869cac76cada239ba23b78 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:53:31 +0000 Subject: [PATCH 7/9] Cover invalid remember cookies for passwordless users Port the null-password recaller regression without removing passwordless remember-me support. The test explicitly checks that an invalid cookie hash returns no user and does not enable remember-cookie authentication. Upstream: https://github.com/laravel/framework/pull/61532 Framework revision: 7068848dfe48fc3a433598e09ce798799d442a52 Validated with AuthGuardTest and the full parallel suite. --- tests/Auth/AuthGuardTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/Auth/AuthGuardTest.php b/tests/Auth/AuthGuardTest.php index 2c1ba6f00..6278ee293 100755 --- a/tests/Auth/AuthGuardTest.php +++ b/tests/Auth/AuthGuardTest.php @@ -695,6 +695,21 @@ public function testUserReturnsNullWhenRememberCookieTokenDoesNotMatchAnyUser(): $this->assertFalse($guard->viaRemember()); } + public function testUserReturnsNullWhenPasswordlessUserHasInvalidRememberCookieHash(): void + { + [$session, $provider, $request, $cookie, $timebox, $app] = $this->getMocks(); + $guard = new SessionGuard('default', $provider, $session, $app); + $cookieRequest = Request::create('/', 'GET', [], [$guard->getRecallerName() => 'id|recaller|baz']); + $app->shouldReceive('make')->with('request')->andReturn($cookieRequest); + $session->expects('get')->with($guard->getName())->andReturn(null); + $user = m::mock(Authenticatable::class); + $provider->expects('retrieveByToken')->with('id', 'recaller')->andReturn($user); + $user->expects('getAuthPassword')->andReturn(null); + + $this->assertNull($guard->user()); + $this->assertFalse($guard->viaRemember()); + } + #[DataProvider('invalidRememberCookieHashes')] public function testUserRejectsRememberCookieWithInvalidPasswordHash(string $cookieHash): void { From 7785b7966be5aa6493899a8ccef169a099b7d91b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:20:33 +0000 Subject: [PATCH 8/9] Disambiguate self-referencing pivot chaperones A shared model basename cannot identify which side of a pivot an inverse belongs to. Keep key-derived guesses and explicit names, but omit the ambiguous class-name fallback when both sides share a basename. This prevents the related model from overwriting the declaring inverse without adding queries or per-row work. Reuse the partial-pivot fixture for a regression that checks the exact parent instance. Clarify that using() precedes chaperone() and that inverse names may be specified explicitly. Follow-up to https://github.com/laravel/framework/pull/61152 at framework revision 7068848dfe48fc3a433598e09ce798799d442a52. Validated with the complete many-to-many integration test file, full source and type-fixture analysis, and formatting. --- .../Concerns/SupportsPivotInverseRelations.php | 3 ++- src/docs/eloquent-relationships.md | 4 ++-- .../Database/EloquentBelongsToManyTest.php | 15 +++++++++++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php index 338b2f86f..dee5927a1 100644 --- a/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php @@ -92,7 +92,8 @@ protected function guessPivotInverseRelation(Model $pivotModel, string $foreignK { $candidates = array_filter(array_unique([ Str::camel(Str::beforeLast($foreignKey, $model->getKeyName())), - Str::camel(class_basename($model)), + // A shared model name cannot distinguish the two sides of the pivot. + class_basename($this->parent) !== class_basename($this->related) ? Str::camel(class_basename($model)) : null, ])); return Arr::first( diff --git a/src/docs/eloquent-relationships.md b/src/docs/eloquent-relationships.md index cdb20e50e..122b09deb 100644 --- a/src/docs/eloquent-relationships.md +++ b/src/docs/eloquent-relationships.md @@ -1039,7 +1039,7 @@ class RoleUser extends Pivot #### Automatically Hydrating Pivot Relationships -When a custom pivot model defines `belongsTo` relationships for the declaring and related models, you may invoke `chaperone` to automatically hydrate those relationships on each pivot model. This avoids additional queries when accessing the models through the pivot: +When a custom pivot model defines `belongsTo` relationships for the declaring and related models, you may invoke `chaperone` after `using` to automatically hydrate those relationships on each pivot model. This avoids additional queries when accessing the models through the pivot: ```php use Hypervel\Database\Eloquent\Model; @@ -1071,7 +1071,7 @@ class Role extends Model } ``` -Eloquent will attempt to infer the pivot relationship names. If your pivot model uses non-standard names, pass the declaring and related relationship names to `chaperone`: +Eloquent infers the relationship names from the pivot keys and model names. You may also pass the declaring and related relationship names to `chaperone` explicitly: ```php return $this->belongsToMany(User::class) diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index b71711ff4..c97afe39c 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -1568,6 +1568,21 @@ public function testChaperoneGuessesOnlyExistingRelations(): void $this->assertTrue($post->is($pivot->post)); } + public function testChaperoneOnSelfReferencingRelationGuessesFromPivotKeys(): void + { + $post = Post::create(['title' => Str::random()]); + $relatedPost = Post::create(['title' => Str::random()]); + + $relation = $post->belongsToMany(Post::class, 'posts_tags', 'post_id', 'tag_id') + ->using(ChaperonePartialPivot::class) + ->chaperone(); + $relation->attach($relatedPost); + + $pivot = $relation->first()->pivot; + + $this->assertSame($post, $pivot->post); + } + public function testChaperoneWithEagerLoading(): void { $post1 = Post::create(['title' => Str::random()]); From 3d83952db7100693c0cc9095d7fbc318447c3452 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:48:55 +0000 Subject: [PATCH 9/9] Preserve unambiguous pivot inverse name inference Restore the model-name fallback and resolve only actual inverse-name collisions. Explicit names take precedence; an inferred shared name remains only on the side identified by its pivot key. Valid distinct guesses remain unchanged, without constructing relation objects or adding hydration work. Cover unambiguous model-name inference, explicit declaring names and both eager-loading paths that must clear the declaring guess. Use pivot fixtures with the correct model and foreign-key mappings. Follow-up to https://github.com/laravel/framework/pull/61152 at framework revision 7068848dfe48fc3a433598e09ce798799d442a52. Validated with the complete many-to-many integration test file, source and type-fixture analysis, and formatting. --- .../SupportsPivotInverseRelations.php | 28 ++++- .../Database/EloquentBelongsToManyTest.php | 101 ++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php index dee5927a1..d65aa770b 100644 --- a/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php +++ b/src/database/src/Eloquent/Relations/Concerns/SupportsPivotInverseRelations.php @@ -48,6 +48,21 @@ public function chaperone(?string $declaring = null, ?string $related = null): s $this->related ); + // Both sides can guess the same model name, so keep it only on a side named + // explicitly or identified by its pivot key. + if ($this->declaringInverseRelationship !== null + && $this->declaringInverseRelationship === $this->relatedInverseRelationship) { + $relation = $this->declaringInverseRelationship; + + if ($declaring === null && ($related !== null || $this->relationNameFromPivotKey($this->foreignPivotKey, $this->parent) !== $relation)) { + $this->declaringInverseRelationship = null; + } + + if ($related === null && ($declaring !== null || $this->relationNameFromPivotKey($this->relatedPivotKey, $this->related) !== $relation)) { + $this->relatedInverseRelationship = null; + } + } + return $this; } @@ -91,9 +106,8 @@ protected function resolvePivotInverseRelation(Model $pivotModel, ?string $relat protected function guessPivotInverseRelation(Model $pivotModel, string $foreignKey, Model $model): ?string { $candidates = array_filter(array_unique([ - Str::camel(Str::beforeLast($foreignKey, $model->getKeyName())), - // A shared model name cannot distinguish the two sides of the pivot. - class_basename($this->parent) !== class_basename($this->related) ? Str::camel(class_basename($model)) : null, + $this->relationNameFromPivotKey($foreignKey, $model), + Str::camel(class_basename($model)), ])); return Arr::first( @@ -102,6 +116,14 @@ class_basename($this->parent) !== class_basename($this->related) ? Str::camel(cl ); } + /** + * Derive the inverse relationship name from a pivot key. + */ + protected function relationNameFromPivotKey(string $pivotKey, Model $model): string + { + return Str::camel(Str::beforeLast($pivotKey, $model->getKeyName())); + } + /** * Apply chaperone relationships to a pivot model instance. */ diff --git a/tests/Integration/Database/EloquentBelongsToManyTest.php b/tests/Integration/Database/EloquentBelongsToManyTest.php index c97afe39c..b5d4e4ae9 100644 --- a/tests/Integration/Database/EloquentBelongsToManyTest.php +++ b/tests/Integration/Database/EloquentBelongsToManyTest.php @@ -1583,6 +1583,53 @@ public function testChaperoneOnSelfReferencingRelationGuessesFromPivotKeys(): vo $this->assertSame($post, $pivot->post); } + public function testChaperonePreservesUnambiguousSelfReferencingModelName(): void + { + $post = Post::create(['title' => Str::random()]); + $relatedPost = Post::create(['title' => Str::random()]); + + Schema::table('posts_tags', function (Blueprint $table): void { + $table->renameColumn('post_id', 'owner_id'); + }); + + $relation = $post->belongsToMany(Post::class, 'posts_tags', 'owner_id', 'tag_id') + ->using(ChaperoneNamedSelfPivot::class) + ->chaperone(); + $relation->attach($relatedPost); + $result = $relation->first(); + + $this->assertSame($post, $result->pivot->post); + $this->assertSame($result, $result->pivot->tag); + } + + public function testChaperoneExplicitDeclaringNameTakesPrecedenceOverKeyGuess(): void + { + $post = Post::create(['title' => Str::random()]); + $relatedPost = Post::create(['title' => Str::random()]); + + $relation = $post->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id') + ->using(ChaperoneReversedSelfPivot::class) + ->chaperone(declaring: 'post'); + $relation->attach($relatedPost); + $result = $relation->first(); + + $this->assertSame($post, $result->pivot->post); + } + + #[TestWith(['postsWithChaperoneExplicitRelated'])] + #[TestWith(['postsWithChaperoneRelatedPivotKey'])] + public function testChaperoneKeepsRelatedInverseWhenEagerLoading(string $relation): void + { + $post = Post::create(['title' => Str::random()]); + $relatedPost = Post::create(['title' => Str::random()]); + $post->{$relation}()->attach($relatedPost); + + // Eager matching would overwrite the related inverse if a declaring guess remained. + $result = Post::with($relation)->find($post->id)->{$relation}->first(); + + $this->assertSame($result, $result->pivot->post); + } + public function testChaperoneWithEagerLoading(): void { $post1 = Post::create(['title' => Str::random()]); @@ -1886,6 +1933,26 @@ public function tagsWithChaperonePartialPivot(): BelongsToMany ->using(ChaperonePartialPivot::class) ->chaperone(); } + + /** + * Get posts with an explicitly named related pivot inverse. + */ + public function postsWithChaperoneExplicitRelated(): BelongsToMany + { + return $this->belongsToMany(Post::class, 'posts_tags', 'post_id', 'tag_id') + ->using(ChaperoneReversedSelfPivot::class) + ->chaperone(related: 'post'); + } + + /** + * Get posts whose related pivot key identifies the inverse. + */ + public function postsWithChaperoneRelatedPivotKey(): BelongsToMany + { + return $this->belongsToMany(Post::class, 'posts_tags', 'tag_id', 'post_id') + ->using(ChaperonePartialPivot::class) + ->chaperone(); + } } class Tag extends Model @@ -2033,3 +2100,37 @@ public function post(): BelongsTo return $this->belongsTo(Post::class); } } + +class ChaperoneNamedSelfPivot extends Pivot +{ + protected ?string $table = 'posts_tags'; + + /** + * Get the declaring post associated with the pivot. + */ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class, 'owner_id'); + } + + /** + * Get the related post associated with the pivot. + */ + public function tag(): BelongsTo + { + return $this->belongsTo(Post::class, 'tag_id'); + } +} + +class ChaperoneReversedSelfPivot extends Pivot +{ + protected ?string $table = 'posts_tags'; + + /** + * Get the post identified by the opposite pivot key. + */ + public function post(): BelongsTo + { + return $this->belongsTo(Post::class, 'tag_id'); + } +}