gamez/typed-collection
gamez/typed-collection adds type-safe, strongly typed collections to Laravel and PHP. Define the item type once, keep the familiar Collection API, and get better IDE autocompletion, static analysis, and early errors when invalid values are added.
Install with Composer; the package only needs illuminate/support (it is not a full Laravel package, so there is no service provider to register):
composer require gamez/typed-collection
Look at these first:
README.md — the canonical list of accepted type names and the exact mutation methods that are guarded.src/TypedCollection.php — the abstract base class; this is the file you subclass and the source of truth for validation behavior.tests/ — short, readable examples of every supported type declaration.The minimal use case is a domain collection: subclass Gamez\Illuminate\Support\TypedCollection, declare protected static $allowedTypes, and return it from repositories/services instead of a bare Collection.
use Gamez\Illuminate\Support\TypedCollection;
final class UserCollection extends TypedCollection
{
protected static $allowedTypes = [User::class];
}
$users = new UserCollection([$userA, $userB]); // validated in the constructor
$users->push($userC); // validated on mutation
$users->push('not-a-user'); // throws InvalidArgumentException
Allowed types generally include class/interface names plus the primitive names used by PHP's type system (string, int/integer, float, bool/boolean, array, object, callable, null, mixed). Confirm the exact spelling in the README before relying on aliases.
A good first use is replacing an untyped Collection return value on one service method and letting PHPStan/Psalm and your IDE document the element type at every call site. Start small: one collection class, one boundary, one test.
One collection class per element type. Define UserCollection, InvoiceCollection, MoneyCollection, etc. Keep them final where possible, and add domain helpers that read naturally at call sites:
final class InvoiceCollection extends TypedCollection
{
protected static $allowedTypes = [Invoice::class];
public function total(): int
{
return $this->sum(fn (Invoice $i) => $i->amountCents());
}
public function unpaid(): self
{
return $this->filter(fn (Invoice $i) => ! $i->isPaid());
}
}
Use typed collections as service/repository return types. This is where they pay for themselves: the type contract travels with the data, so controllers, jobs, and API resources do not need defensive instanceof checks or repeated docblocks.
final class UserRepository
{
public function active(): UserCollection
{
return new UserCollection(
User::query()->where('active', true)->get()->all()
);
}
}
Convert at the boundary, not in the middle. Eloquent returns Illuminate\Database\Eloquent\Collection; typed collections extend the base support collection. Wrap once when crossing from persistence into your domain layer, then keep the typed collection for the rest of the pipeline.
Handle type-changing transformations explicitly. Methods such as map(), filter(), and pluck() construct a new instance of the same class, so the result is re-validated against $allowedTypes. When the element type changes, drop to a base collection first and re-wrap in the target type:
$names = $users->toBase()->map(fn (User $u) => $u->name)->values();
$ids = new UserIdCollection(
$users->toBase()->pluck('id')->all()
);
Add named constructors for common hydrations. Keeps array/JSON/Eloquent conversion in one tested place instead of scattered new XCollection(...) calls:
public static function fromJson(string $json): self
{
return new self(json_decode($json, true, flags: JSON_THROW_ON_ERROR));
}
Integrate with Laravel without fighting it. Use typed collections in jobs, actions, and API resources. Do not try to make them the return type of an Eloquent relationship or Model::newCollection() — those slots require an Eloquent collection subclass. If you want Eloquent-level custom collections, that is a separate concern; convert to your typed collection when the data leaves the model layer.
Test the contract, not the internals. Data-provider tests for accepted and rejected types, plus one test per domain helper, are enough:
public function test_rejects_foreign_items(): void
{
$this->expectException(InvalidArgumentException::class);
new UserCollection([new stdClass()]);
}
Annotate for static analysis. Add @extends \Illuminate\Support\Collection<int, User> and @method tags for your helpers so PHPStan/Psalm understand element types through chain calls.
map()/filter() return new static(...), a UserCollection::map() that returns strings will throw at runtime. This is the most common surprise; use toBase() for cross-type transforms.push() are the documented choke points; verify in src/TypedCollection.php whether offsetSet, put, prepend, merge, and concat also validate. If any bypass validation, route bulk writes through the constructor instead of mutating in place.null is a type. An empty collection is fine, and first() returning null is normal collection behavior, but pushing null fails unless null is in $allowedTypes. Add it deliberately or not at all.int vs integer and bool vs boolean may behave differently depending on the validator. Pick one spelling, assert it in a test, and be consistent across the codebase.UserCollection cannot be returned from HasMany::get() expectations or Model::newCollection(). Convert with new UserCollection($eloquent->all()) at the seam.$allowedTypes is static. You cannot vary allowed types per instance. If you need two element types, define two collection classes rather than toggling configuration at runtime.get_debug_type($item) at the catch site. Add a small assertion helper in a base class if you need richer diagnostics across many collection types.function handle(UserCollection $users) gives readers and static analysers real information; TypedCollection alone does not.illuminate/support ranges; if you are pinned to an older or newer Laravel, check the Composer constraint before adopting it broadly.How can I help you explore Laravel packages today?