diff --git a/src/Illuminate/Database/Eloquent/Attributes/CollectedBy.php b/src/Illuminate/Database/Eloquent/Attributes/CollectedBy.php new file mode 100644 index 000000000000..14eb3a43745d --- /dev/null +++ b/src/Illuminate/Database/Eloquent/Attributes/CollectedBy.php @@ -0,0 +1,19 @@ +> $collectionClass + * @return void + */ + public function __construct(public string $collectionClass) + { + } +} diff --git a/src/Illuminate/Database/Eloquent/HasCollection.php b/src/Illuminate/Database/Eloquent/HasCollection.php index e676bb79ae7c..a1b462784dd6 100644 --- a/src/Illuminate/Database/Eloquent/HasCollection.php +++ b/src/Illuminate/Database/Eloquent/HasCollection.php @@ -2,11 +2,21 @@ namespace Illuminate\Database\Eloquent; +use Illuminate\Database\Eloquent\Attributes\CollectedBy; +use ReflectionClass; + /** * @template TCollection of \Illuminate\Database\Eloquent\Collection */ trait HasCollection { + /** + * The resolved collection class names by model. + * + * @var array, class-string> + */ + protected static array $resolvedCollectionClasses = []; + /** * Create a new Eloquent Collection instance. * @@ -15,6 +25,26 @@ trait HasCollection */ public function newCollection(array $models = []) { - return new static::$collectionClass($models); + static::$resolvedCollectionClasses[static::class] ??= ($this->resolveCollectionFromAttribute() ?? static::$collectionClass); + + return new static::$resolvedCollectionClasses[static::class]($models); + } + + /** + * Resolve the collection class name from the CollectedBy attribute. + * + * @return class-string|null + */ + public function resolveCollectionFromAttribute() + { + $reflectionClass = new ReflectionClass(static::class); + + $attributes = $reflectionClass->getAttributes(CollectedBy::class); + + if (! isset($attributes[0]) || ! isset($attributes[0]->getArguments()[0])) { + return; + } + + return $attributes[0]->getArguments()[0]; } } diff --git a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php index d0e917a4583b..c7cab6453dfb 100644 --- a/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php +++ b/tests/Database/DatabaseEloquentBelongsToManyWithCastedAttributesTest.php @@ -62,6 +62,7 @@ protected function getRelation() $builder = m::mock(Builder::class); $related = m::mock(Model::class); $related->shouldReceive('newCollection')->passthru(); + $related->shouldReceive('resolveCollectionFromAttribute')->passthru(); $builder->shouldReceive('getModel')->andReturn($related); $related->shouldReceive('qualifyColumn'); $builder->shouldReceive('join', 'where'); diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index eb5ac3477e8f..e7f249512bbd 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -15,6 +15,7 @@ use Illuminate\Database\Connection; use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\ConnectionResolverInterface as Resolver; +use Illuminate\Database\Eloquent\Attributes\CollectedBy; use Illuminate\Database\Eloquent\Attributes\ObservedBy; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\ArrayObject; @@ -3169,6 +3170,14 @@ public function testGuardedWithMutators() $this->assertSame('123 Main Street', $model->address_line_one); $this->assertSame('Anytown', $model->address_line_two); } + + public function testCollectedByAttribute() + { + $model = new EloquentModelWithCollectedByAttribute; + $collection = $model->newCollection([$model]); + + $this->assertInstanceOf(CustomEloquentCollection::class, $collection); + } } class EloquentTestObserverStub @@ -3928,3 +3937,12 @@ public function setFullAddressAttribute($fullAddress) $this->attributes['address_line_two'] = $addressLineTwo; } } + +#[CollectedBy(CustomEloquentCollection::class)] +class EloquentModelWithCollectedByAttribute extends Model +{ +} + +class CustomEloquentCollection extends Collection +{ +} diff --git a/types/Database/Eloquent/Model.php b/types/Database/Eloquent/Model.php index 9bf2a8c7c531..3c9be34c4b11 100644 --- a/types/Database/Eloquent/Model.php +++ b/types/Database/Eloquent/Model.php @@ -2,6 +2,7 @@ namespace Illuminate\Types\Model; +use Illuminate\Database\Eloquent\Attributes\CollectedBy; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\HasCollection; use Illuminate\Database\Eloquent\Model; @@ -9,7 +10,7 @@ use function PHPStan\Testing\assertType; -function test(User $user, Post $post, Comment $comment): void +function test(User $user, Post $post, Comment $comment, Article $article): void { assertType('UserFactory', User::factory(function ($attributes, $model) { assertType('array', $attributes); @@ -34,6 +35,7 @@ function test(User $user, Post $post, Comment $comment): void assertType('Illuminate\Database\Eloquent\Collection<(int|string), User>', $user->newCollection([new User()])); assertType('Illuminate\Types\Model\Posts<(int|string), Illuminate\Types\Model\Post>', $post->newCollection(['foo' => new Post()])); + assertType('Illuminate\Types\Model\Articles<(int|string), Illuminate\Types\Model\Article>', $article->newCollection([new Article()])); assertType('Illuminate\Types\Model\Comments', $comment->newCollection([new Comment()])); assertType('bool', $user->restore()); @@ -74,3 +76,19 @@ public function newCollection(array $models = []): Comments final class Comments extends Collection { } + +#[CollectedBy(Articles::class)] +class Article extends Model +{ + /** @use HasCollection> */ + use HasCollection; +} + +/** + * @template TKey of array-key + * @template TModel of Article + * + * @extends Collection */ +class Articles extends Collection +{ +}