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');
+ }
+}